Compare commits
22 Commits
95e2fe1c41
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee77d666b | ||
|
|
7e0c209d9a | ||
|
|
13a8d8af91 | ||
|
|
3251de6406 | ||
|
|
9f86de0125 | ||
|
|
4a51bf0767 | ||
|
|
97b04b6ccc | ||
|
|
cbac58af62 | ||
|
|
b7ff505dea | ||
|
|
9f2442f2e3 | ||
|
|
89145a6743 | ||
|
|
07454b73c2 | ||
|
|
bbb9a5e1e1 | ||
|
|
98d928f457 | ||
|
|
004b03b374 | ||
|
|
475097d372 | ||
|
|
af59d6367f | ||
|
|
a3bf8abda5 | ||
|
|
26938449f0 | ||
|
|
64f4c438c3 | ||
|
|
88c37408e8 | ||
|
|
3264b3252a |
59
DEPLOY.md
59
DEPLOY.md
@@ -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。二选一即可:
|
||||
|
||||
**方式 A:Nginx 只反代,所有页面由 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";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**方式 B:Nginx 提供 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`)。
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
crawler/__pycache__/indicator_smooth.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/indicator_smooth.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
将 AI 提取的结构化数据合并到 SQLite
|
||||
与 panel schema 及 situationData.getSituation 对齐,支持回放
|
||||
与 panel schema 及 situationData.getSituation 对齐,支持回放。
|
||||
|
||||
地图打击数据(与前端攻击动画一致):
|
||||
- map_strike_sources: [{ "id": "israel"|"lincoln"|"ford", "name": "显示名", "lng", "lat" }] 写入 map_strike_source
|
||||
- map_strike_lines: [{ "source_id", "target_lng", "target_lat", "target_name?", "struck_at?" }] 追加到 map_strike_line
|
||||
爬虫/AI 可按此格式输出,落库后 GET /api/situation 的 mapData.strikeSources/strikeLines 会更新,前端直接追加攻击动画。
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
83
crawler/indicator_smooth.py
Normal file
83
crawler/indicator_smooth.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
20
crawler/pyproject.toml
Normal 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"]
|
||||
@@ -31,7 +31,7 @@ API_BASE = os.environ.get("API_BASE", "http://localhost:3001")
|
||||
QUERY = os.environ.get("GDELT_QUERY", "United States Iran military")
|
||||
MAX_RECORDS = int(os.environ.get("GDELT_MAX_RECORDS", "30"))
|
||||
FETCH_INTERVAL_SEC = int(os.environ.get("FETCH_INTERVAL_SEC", "60"))
|
||||
RSS_INTERVAL_SEC = int(os.environ.get("RSS_INTERVAL_SEC", "60")) # 每分钟抓取世界主流媒体
|
||||
# RSS 抓取间隔:优先从 DB crawler_config 读取(修订面板可调),否则用环境变量或默认 60
|
||||
# 时间范围:1h=1小时 1d=1天 1week=1周;不设则默认 3 个月(易返回旧文)
|
||||
GDELT_TIMESPAN = os.environ.get("GDELT_TIMESPAN", "1d")
|
||||
# 设为 1 则跳过 GDELT,仅用 RSS 新闻作为事件脉络(GDELT 国外可能无法访问)
|
||||
@@ -48,6 +48,22 @@ if os.environ.get("CRAWLER_USE_PROXY") != "1":
|
||||
EVENT_CACHE: List[dict] = []
|
||||
|
||||
|
||||
def _get_rss_interval_sec() -> int:
|
||||
"""从 DB crawler_config 读取 RSS 抓取间隔(秒),修订面板可调。无配置或异常时返回 60。"""
|
||||
try:
|
||||
if os.path.exists(DB_PATH):
|
||||
conn = sqlite3.connect(DB_PATH, timeout=3)
|
||||
row = conn.execute("SELECT rss_interval_sec FROM crawler_config WHERE id = 1").fetchone()
|
||||
conn.close()
|
||||
if row and row[0] is not None:
|
||||
val = int(row[0])
|
||||
if 30 <= val <= 86400:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
return int(os.environ.get("RSS_INTERVAL_SEC", "60"))
|
||||
|
||||
|
||||
# ==========================
|
||||
# 冲突强度评分 (1–10)
|
||||
# ==========================
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ==========================
|
||||
|
||||
@@ -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
9
crawler/run_uvicorn.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# PM2 用:在 crawler 目录下启动 uvicorn(GDELT/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
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -19,6 +19,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.crawler
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DB_PATH=/data/data.db
|
||||
- API_BASE=http://api:3001
|
||||
|
||||
69
docs/INDICATORS_WALLSTREET_RETALIATION.md
Normal file
69
docs/INDICATORS_WALLSTREET_RETALIATION.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 华尔街财团投入指数 & 反击情绪指数:更新逻辑与波动说明
|
||||
|
||||
## 一、数据流概览
|
||||
|
||||
```
|
||||
爬虫提取 (extractor_ai / extractor_dashscope)
|
||||
→ retaliation_sentiment / wall_street_value (0–100,按单篇报道)
|
||||
→ 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 或异常值影响(虽已夹到 1–99)。
|
||||
|
||||
### 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`:0–100;
|
||||
- `wall_street_value`:0–100。
|
||||
- 每条报道独立一个值,多篇报道会多次调用 `merge()`,因此**波动确实主要由爬虫数据更新频率和单条取值差异导致**。
|
||||
|
||||
## 四、稳定化思路(计算模块)
|
||||
|
||||
1. **华尔街**
|
||||
- 与**上一点**做平滑再写入:例如 `value = alpha * last_value + (1-alpha) * raw`,再限幅;
|
||||
- 可选:仅当距上一条时间超过一定间隔(如 15–30 分钟)才 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
68
docs/PRODUCTION.md
Normal 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 爬虫单独 Docker(API 在宿主机,如 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 宿主机 API(PM2)使用的 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
43
ecosystem.config.cjs
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
6
map.md
@@ -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
BIN
public/usa_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
6
run.sh
Normal file
6
run.sh
Normal 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
|
||||
154
scripts/align-production-schema.sh
Executable file
154
scripts/align-production-schema.sh
Executable 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);\""
|
||||
64
scripts/check-attack-locations.sh
Executable file
64
scripts/check-attack-locations.sh
Executable 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
|
||||
39
scripts/check-db-and-crawler.sh
Executable file
39
scripts/check-db-and-crawler.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# 查看数据库中的 lastUpdated 与条数,并提示如何用爬虫更新
|
||||
# 用法: ./scripts/check-db-and-crawler.sh
|
||||
|
||||
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
DB_PATH="${DB_PATH:-$PROJECT_ROOT/server/data.db}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "数据库与爬虫状态"
|
||||
echo "DB: $DB_PATH"
|
||||
echo "=========================================="
|
||||
|
||||
if [[ ! -f "$DB_PATH" ]]; then
|
||||
echo "数据库文件不存在。请先执行: node server/seed.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v sqlite3 &>/dev/null; then
|
||||
UPDATED_AT=$(sqlite3 "$DB_PATH" "SELECT updated_at FROM situation WHERE id = 1;" 2>/dev/null || echo "?")
|
||||
SU_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM situation_update;" 2>/dev/null || echo "?")
|
||||
NEWS_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM news_content;" 2>/dev/null || echo "?")
|
||||
echo "situation.updated_at (前端 lastUpdated): $UPDATED_AT"
|
||||
echo "situation_update 条数: $SU_COUNT"
|
||||
echo "news_content 条数: $NEWS_COUNT"
|
||||
else
|
||||
echo "未安装 sqlite3,无法直接查库。可安装: brew install sqlite3"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 为何数据停在旧日期? ---"
|
||||
echo " • lastUpdated 来自 situation.updated_at。"
|
||||
echo " • 已改为:每次爬虫运行都会更新该时间(不再仅在有新资讯时更新)。"
|
||||
echo " • 若从未跑爬虫或很久没跑,请执行一次爬虫:"
|
||||
echo ""
|
||||
echo " cd $PROJECT_ROOT && python crawler/run_once.py"
|
||||
echo " 或: npm run crawler:once"
|
||||
echo ""
|
||||
echo " 若需定时更新,可启动常驻爬虫: python crawler/main.py"
|
||||
echo "=========================================="
|
||||
77
scripts/gen-align-schema-from-local.sh
Executable file
77
scripts/gen-align-schema-from-local.sh
Executable 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"
|
||||
21
scripts/production-start.sh
Normal file
21
scripts/production-start.sh
Normal 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 与爬虫才能数据一致。"
|
||||
55
scripts/run-crawler-docker-standalone.sh
Normal file
55
scripts/run-crawler-docker-standalone.sh
Normal 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 ."
|
||||
126
scripts/test.sh
Normal file
126
scripts/test.sh
Normal 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';
|
||||
"
|
||||
@@ -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 秒检查一次(仍仅在数据变化时推送)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
114
server/db.js
114
server/db.js
@@ -174,6 +174,7 @@ function runMigrations(db) {
|
||||
if (!names.includes('region')) exec('ALTER TABLE key_location ADD COLUMN region TEXT')
|
||||
if (!names.includes('status')) exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"')
|
||||
if (!names.includes('damage_level')) exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER')
|
||||
if (!names.includes('attacked_at')) exec('ALTER TABLE key_location ADD COLUMN attacked_at TEXT')
|
||||
} catch (_) {}
|
||||
try {
|
||||
const lossCols = prepare('PRAGMA table_info(combat_losses)').all()
|
||||
@@ -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,15 @@ 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 (
|
||||
@@ -249,6 +264,105 @@ function runMigrations(db) {
|
||||
INSERT OR IGNORE INTO display_stats (id) VALUES (1);
|
||||
`)
|
||||
} catch (_) {}
|
||||
try {
|
||||
const dsCols = prepare('PRAGMA table_info(display_stats)').all()
|
||||
const dsNames = dsCols.map((c) => c.name)
|
||||
if (!dsNames.includes('override_enabled')) {
|
||||
exec('ALTER TABLE display_stats ADD COLUMN override_enabled INTEGER NOT NULL DEFAULT 0')
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
exec(`
|
||||
CREATE TABLE IF NOT EXISTS map_strike_source (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
lat REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS map_strike_line (
|
||||
source_id TEXT NOT NULL,
|
||||
target_lng REAL NOT NULL,
|
||||
target_lat REAL NOT NULL,
|
||||
target_name TEXT,
|
||||
struck_at TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES map_strike_source(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_map_strike_line_source ON map_strike_line(source_id);
|
||||
`)
|
||||
} catch (_) {}
|
||||
try {
|
||||
const lineCols = prepare('PRAGMA table_info(map_strike_line)').all()
|
||||
if (!lineCols.some((c) => c.name === 'struck_at')) {
|
||||
exec('ALTER TABLE map_strike_line ADD COLUMN struck_at TEXT')
|
||||
}
|
||||
} catch (_) {}
|
||||
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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
151
server/routes.js
151
server/routes.js
@@ -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 || ''
|
||||
|
||||
@@ -163,6 +170,18 @@ 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())
|
||||
@@ -172,6 +191,41 @@ router.get('/stats', (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 战区地图配置:钳形轴线、以黎轴线、防御线路径,供前端 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 })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/events', (req, res) => {
|
||||
try {
|
||||
const s = getSituation()
|
||||
@@ -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,14 +254,22 @@ 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 viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||
displayStats = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||
} catch (_) {}
|
||||
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
|
||||
@@ -215,18 +277,29 @@ router.get('/edit/raw', (req, res) => {
|
||||
"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 ?? 0,
|
||||
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)
|
||||
@@ -234,6 +307,33 @@ router.get('/edit/raw', (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** GET 留言列表(修订页查看) */
|
||||
router.get('/edit/feedback', (req, res) => {
|
||||
try {
|
||||
const list = db.prepare('SELECT id, content, ip, created_at FROM feedback ORDER BY id DESC LIMIT 500').all()
|
||||
res.json({ list })
|
||||
} catch (err) {
|
||||
console.error('[edit/feedback]', err?.message || err)
|
||||
// 表不存在或列缺失时返回空列表,避免修订页报错
|
||||
res.json({ list: [] })
|
||||
}
|
||||
})
|
||||
|
||||
/** PUT 爬虫配置(RSS 抓取间隔等,爬虫从同一 DB 读取) */
|
||||
router.put('/edit/crawler-config', (req, res) => {
|
||||
try {
|
||||
const n = req.body?.rssIntervalSec
|
||||
if (n === undefined) return res.status(400).json({ error: 'rssIntervalSec required' })
|
||||
const val = parseInt(String(n), 10)
|
||||
if (!Number.isFinite(val) || val < 30 || val > 86400) return res.status(400).json({ error: 'rssIntervalSec must be 30–86400' })
|
||||
db.prepare('INSERT OR REPLACE INTO crawler_config (id, rss_interval_sec) VALUES (1, ?)').run(val)
|
||||
res.json({ ok: true, rssIntervalSec: val })
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** PUT 更新战损(美/伊) */
|
||||
router.put('/edit/combat-losses', (req, res) => {
|
||||
try {
|
||||
@@ -273,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) {
|
||||
@@ -360,14 +460,31 @@ router.put('/edit/force-summary', (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除覆盖、改用实时统计 */
|
||||
/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除该字段;clearOverride=true 或全部为 null 时关闭覆盖、恢复实时统计 */
|
||||
router.put('/edit/display-stats', (req, res) => {
|
||||
try {
|
||||
db.prepare('INSERT OR IGNORE INTO display_stats (id) VALUES (1)').run()
|
||||
const body = req.body || {}
|
||||
const clearOverride = body.clearOverride === true || body.clearOverride === 'true'
|
||||
const viewers = body.viewers
|
||||
const cumulative = body.cumulative
|
||||
const shareCount = body.shareCount
|
||||
const likeCount = body.likeCount
|
||||
const feedbackCount = body.feedbackCount
|
||||
const allNull = viewers === null && cumulative === null && shareCount === null && likeCount === null && feedbackCount === null
|
||||
|
||||
if (clearOverride || allNull) {
|
||||
db.prepare(
|
||||
'UPDATE display_stats SET override_enabled = 0, viewers = NULL, cumulative = NULL, share_count = NULL, like_count = NULL, feedback_count = NULL WHERE id = 1'
|
||||
).run()
|
||||
broadcastAfterEdit(req)
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
const updates = []
|
||||
const values = []
|
||||
const setField = (key, bodyKey) => {
|
||||
const v = req.body?.[bodyKey ?? key]
|
||||
const v = body[bodyKey ?? key]
|
||||
if (v === undefined) return
|
||||
if (v === null) {
|
||||
updates.push(`${key} = ?`)
|
||||
@@ -385,6 +502,8 @@ router.put('/edit/display-stats', (req, res) => {
|
||||
setField('like_count', 'likeCount')
|
||||
setField('feedback_count', 'feedbackCount')
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
|
||||
updates.push('override_enabled = ?')
|
||||
values.push(1)
|
||||
values.push(1)
|
||||
db.prepare(`UPDATE display_stats SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||||
broadcastAfterEdit(req)
|
||||
@@ -395,4 +514,20 @@ router.put('/edit/display-stats', (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
259
server/seed.js
259
server/seed.js
@@ -3,84 +3,84 @@ const db = require('./db')
|
||||
// 与 src/data/mapLocations.ts 同步:62 基地,27 被袭 (严重6 中度12 轻度9)
|
||||
function getUsLocations() {
|
||||
const naval = [
|
||||
{ name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', region: '北阿拉伯海', status: 'operational', damage_level: null },
|
||||
{ name: '福特号航母 (CVN-78)', lat: 35.7397, lng: 24.1002, type: 'Aircraft Carrier', region: '东地中海', status: 'operational', damage_level: null },
|
||||
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational', damage_level: null },
|
||||
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null },
|
||||
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null },
|
||||
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', region: '北阿拉伯海', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '福特号航母 (CVN-78)', lat: 35.7397, lng: 24.1002, type: 'Aircraft Carrier', region: '东地中海', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
]
|
||||
const attacked = [
|
||||
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
|
||||
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
|
||||
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 3 },
|
||||
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
|
||||
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 3 },
|
||||
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, type: 'Base', region: '沙特', status: 'attacked', damage_level: 3 },
|
||||
{ name: '塔吉军营', lat: 33.556, lng: 44.256, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2 },
|
||||
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2 },
|
||||
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
|
||||
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
|
||||
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
|
||||
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
|
||||
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2 },
|
||||
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2 },
|
||||
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, type: 'Base', region: '巴林', status: 'attacked', damage_level: 2 },
|
||||
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, type: 'Base', region: '阿联酋', status: 'attacked', damage_level: 2 },
|
||||
{ name: '埃斯康村', lat: 24.774, lng: 46.738, type: 'Base', region: '沙特', status: 'attacked', damage_level: 2 },
|
||||
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, type: 'Base', region: '以色列', status: 'attacked', damage_level: 2 },
|
||||
{ name: '布林军营', lat: 29.603, lng: 47.456, type: 'Base', region: '科威特', status: 'attacked', damage_level: 1 },
|
||||
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 1 },
|
||||
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, type: 'Base', region: '以色列', status: 'attacked', damage_level: 1 },
|
||||
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, type: 'Base', region: '约旦', status: 'attacked', damage_level: 1 },
|
||||
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 1 },
|
||||
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1 },
|
||||
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1 },
|
||||
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, type: 'Base', region: '埃及', status: 'attacked', damage_level: 1 },
|
||||
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, type: 'Base', region: '吉布提', status: 'attacked', damage_level: 1 },
|
||||
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:00:00.000Z' },
|
||||
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:15:00.000Z' },
|
||||
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:30:00.000Z' },
|
||||
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:45:00.000Z' },
|
||||
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T07:00:00.000Z' },
|
||||
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, type: 'Base', region: '沙特', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T07:15:00.000Z' },
|
||||
{ name: '塔吉军营', lat: 33.556, lng: 44.256, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T07:30:00.000Z' },
|
||||
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T07:45:00.000Z' },
|
||||
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:00:00.000Z' },
|
||||
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:15:00.000Z' },
|
||||
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:30:00.000Z' },
|
||||
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:45:00.000Z' },
|
||||
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:00:00.000Z' },
|
||||
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:15:00.000Z' },
|
||||
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, type: 'Base', region: '巴林', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:30:00.000Z' },
|
||||
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, type: 'Base', region: '阿联酋', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:45:00.000Z' },
|
||||
{ name: '埃斯康村', lat: 24.774, lng: 46.738, type: 'Base', region: '沙特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T10:00:00.000Z' },
|
||||
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, type: 'Base', region: '以色列', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T10:15:00.000Z' },
|
||||
{ name: '布林军营', lat: 29.603, lng: 47.456, type: 'Base', region: '科威特', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T10:30:00.000Z' },
|
||||
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T10:45:00.000Z' },
|
||||
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, type: 'Base', region: '以色列', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:00:00.000Z' },
|
||||
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, type: 'Base', region: '约旦', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:15:00.000Z' },
|
||||
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:30:00.000Z' },
|
||||
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:45:00.000Z' },
|
||||
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:00:00.000Z' },
|
||||
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, type: 'Base', region: '埃及', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:15:00.000Z' },
|
||||
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, type: 'Base', region: '吉布提', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:30:00.000Z' },
|
||||
]
|
||||
const newBases = [
|
||||
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null },
|
||||
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林', status: 'operational', damage_level: null },
|
||||
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特', status: 'operational', damage_level: null },
|
||||
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特', status: 'operational', damage_level: null },
|
||||
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
|
||||
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
|
||||
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
|
||||
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
|
||||
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
|
||||
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
|
||||
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
|
||||
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦', status: 'operational', damage_level: null },
|
||||
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其', status: 'operational', damage_level: null },
|
||||
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列', status: 'operational', damage_level: null },
|
||||
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
|
||||
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
|
||||
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
|
||||
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
|
||||
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提', status: 'operational', damage_level: null },
|
||||
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null },
|
||||
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
|
||||
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
|
||||
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦', status: 'operational', damage_level: null },
|
||||
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其', status: 'operational', damage_level: null },
|
||||
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列', status: 'operational', damage_level: null },
|
||||
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
|
||||
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
|
||||
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提', status: 'operational', damage_level: null },
|
||||
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
|
||||
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
]
|
||||
return [...naval, ...attacked, ...newBases]
|
||||
}
|
||||
@@ -126,27 +126,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:00–04:00),回放时先出现
|
||||
const iranLocs = [
|
||||
['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Port', '伊朗', 'attacked', 3],
|
||||
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3],
|
||||
['iran', '布什尔核电站', 28.9681, 50.838, 'Nuclear', '伊朗', 'attacked', 2],
|
||||
['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2],
|
||||
['iran', '纳坦兹铀浓缩', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2],
|
||||
['iran', '米纳布岸防', 27.13, 57.08, 'Base', '伊朗', 'damaged', 2],
|
||||
['iran', '卡拉季无人机厂', 35.808, 51.002, 'Base', '伊朗', 'attacked', 2],
|
||||
['iran', '克尔曼沙赫导弹阵地', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2],
|
||||
['iran', '大不里士空军基地', 38.08, 46.29, 'Base', '伊朗', 'damaged', 1],
|
||||
['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Base', '伊朗', 'operational', null],
|
||||
['iran', '阿巴斯港', 27.1832, 56.2666, 'Port', '伊朗', 'operational', null, null],
|
||||
['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Naval', '伊朗', 'attacked', 3, '2026-02-28T02:15:00.000Z'],
|
||||
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3, '2026-02-28T02:00:00.000Z'],
|
||||
['iran', '哈梅内伊官邸', 35.69, 51.42, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:30:00.000Z'],
|
||||
['iran', '总统府/情报部', 35.72, 51.41, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:32:00.000Z'],
|
||||
['iran', '梅赫拉巴德机场', 35.69, 51.15, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:34:00.000Z'],
|
||||
['iran', '库姆', 34.64, 50.88, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:00:00.000Z'],
|
||||
['iran', '伊朗专家会议秘书处', 34.625448, 50.876409, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:02:00.000Z'],
|
||||
['iran', '布什尔', 28.9681, 50.838, 'Base', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'],
|
||||
['iran', '布什尔雷达站', 28.968, 50.838, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'],
|
||||
['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:22:00.000Z'],
|
||||
['iran', '纳坦兹', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:04:00.000Z'],
|
||||
['iran', '卡拉季无人机厂', 35.808, 51.002, 'UAV', '伊朗', 'attacked', 2, '2026-02-28T02:06:00.000Z'],
|
||||
['iran', '克尔曼沙赫导弹掩体', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:36:00.000Z'],
|
||||
['iran', '大不里士空军基地', 38.08, 46.29, 'Missile', '伊朗', 'damaged', 1, '2026-02-28T02:38:00.000Z'],
|
||||
['iran', '伊拉姆导弹阵地', 33.64, 46.42, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:40:00.000Z'],
|
||||
['iran', '霍拉马巴德储备库', 33.48, 48.35, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:42:00.000Z'],
|
||||
['iran', '米纳布', 27.13, 57.08, 'Naval', '伊朗', 'damaged', 2, '2026-02-28T02:18:00.000Z'],
|
||||
['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Naval', '伊朗', 'operational', null, null],
|
||||
]
|
||||
iranLocs.forEach((r) => insertLoc.run(...r))
|
||||
|
||||
const insertStrikeSource = db.prepare('INSERT OR REPLACE INTO map_strike_source (id, name, lng, lat) VALUES (?, ?, ?, ?)')
|
||||
const strikeSources = [
|
||||
['israel', '以色列', 34.78, 32.08],
|
||||
['lincoln', '林肯号航母', 58.4215, 24.1568],
|
||||
['ford', '福特号航母', 24.1002, 35.7397],
|
||||
['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:00–04:00),随后伊朗反击(06:00–12:00)
|
||||
const israelTargets = [
|
||||
[50.88, 34.64, '库姆', '2026-02-28T02:00:00.000Z'],
|
||||
[50.876409, 34.625448, '伊朗专家会议秘书处', '2026-02-28T02:02:00.000Z'],
|
||||
[51.916, 33.666, '纳坦兹', '2026-02-28T02:04:00.000Z'],
|
||||
[51.002, 35.808, '卡拉季无人机厂', '2026-02-28T02:06:00.000Z'],
|
||||
]
|
||||
const lincolnTargets = [
|
||||
[56.27, 27.18, '阿巴斯港海军司令部', '2026-02-28T02:15:00.000Z'],
|
||||
[57.08, 27.13, '米纳布', '2026-02-28T02:18:00.000Z'],
|
||||
[56.5, 27.0, '霍尔木兹岸防阵地', '2026-02-28T02:19:00.000Z'],
|
||||
[50.838, 28.968, '布什尔雷达站', '2026-02-28T02:20:00.000Z'],
|
||||
[51.667, 32.654, '伊斯法罕核设施', '2026-02-28T02:22:00.000Z'],
|
||||
]
|
||||
const fordTargets = [
|
||||
[51.42, 35.69, '哈梅内伊官邸', '2026-02-28T02:30:00.000Z'],
|
||||
[51.41, 35.72, '总统府/情报部', '2026-02-28T02:32:00.000Z'],
|
||||
[51.15, 35.69, '梅赫拉巴德机场', '2026-02-28T02:34:00.000Z'],
|
||||
[46.29, 38.08, '大不里士空军基地', '2026-02-28T02:38:00.000Z'],
|
||||
[47.076, 34.314, '克尔曼沙赫导弹掩体', '2026-02-28T02:36:00.000Z'],
|
||||
[46.42, 33.64, '伊拉姆导弹阵地', '2026-02-28T02:40:00.000Z'],
|
||||
[48.35, 33.48, '霍拉马巴德储备库', '2026-02-28T02:42:00.000Z'],
|
||||
]
|
||||
// 以色列攻击黎巴嫩(真主党目标),时间在伊朗反击之后 14:00–14: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.')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,20 +13,26 @@ function getStats() {
|
||||
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 = 0
|
||||
let likeCount = realLikeCount
|
||||
let display = null
|
||||
try {
|
||||
display = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||
display = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||
} catch (_) {}
|
||||
if (display) {
|
||||
const useOverride = display && display.override_enabled === 1
|
||||
if (useOverride && display) {
|
||||
if (display.viewers != null) viewers = toNum(display.viewers)
|
||||
if (display.cumulative != null) cumulative = toNum(display.cumulative)
|
||||
if (display.share_count != null) shareCount = toNum(display.share_count)
|
||||
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 }
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface ForceSummaryRow {
|
||||
}
|
||||
|
||||
export interface DisplayStatsRow {
|
||||
overrideEnabled?: boolean
|
||||
viewers: number
|
||||
cumulative: number
|
||||
shareCount: number
|
||||
@@ -62,12 +63,29 @@ export interface DisplayStatsRow {
|
||||
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> {
|
||||
@@ -140,7 +158,10 @@ export async function putForceSummary(side: 'us' | 'iran', body: Partial<ForceSu
|
||||
}
|
||||
|
||||
/** 传 null 的字段会清除覆盖,改回实时统计 */
|
||||
export async function putDisplayStats(body: Partial<{ [K in keyof DisplayStatsRow]: number | null }>): Promise<void> {
|
||||
/** 传 clearOverride: true 可关闭覆盖、恢复实时统计 */
|
||||
export async function putDisplayStats(
|
||||
body: Partial<{ [K in keyof DisplayStatsRow]: number | null }> & { clearOverride?: boolean }
|
||||
): Promise<void> {
|
||||
const res = await fetch('/api/edit/display-stats', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -151,3 +172,33 @@ export async function putDisplayStats(body: Partial<{ [K in keyof DisplayStatsRo
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { StatCard } from './StatCard'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { useStatsStore } from '@/store/statsStore'
|
||||
@@ -39,6 +39,8 @@ export function HeaderPanel() {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
const [likes, setLikes] = useState(getStoredLikes)
|
||||
const [liked, setLiked] = useState(false)
|
||||
const [likeBurst, setLikeBurst] = useState(0)
|
||||
const pendingLikesRef = useRef(0)
|
||||
const stats = useStatsStore((s) => s.stats)
|
||||
const setStats = useStatsStore((s) => s.setStats)
|
||||
const viewers = stats.viewers ?? 0
|
||||
@@ -50,6 +52,7 @@ export function HeaderPanel() {
|
||||
const [feedbackText, setFeedbackText] = useState('')
|
||||
const [feedbackSending, setFeedbackSending] = useState(false)
|
||||
const [feedbackDone, setFeedbackDone] = useState(false)
|
||||
const [likeSending, setLikeSending] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||
@@ -139,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', {
|
||||
@@ -185,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>
|
||||
@@ -218,13 +257,25 @@ 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' : ''}`} />
|
||||
<span className="relative inline-flex">
|
||||
<Heart className={`h-2.5 w-2.5 sm:h-3 sm:w-3 transition-transform duration-150 ${liked ? 'fill-current' : ''} ${likeBurst ? 'scale-125' : ''}`} />
|
||||
{likeBurst > 0 && (
|
||||
<span
|
||||
key={likeBurst}
|
||||
className="absolute -top-1 -right-1 min-w-[14px] rounded bg-red-500 px-0.5 text-[10px] font-bold text-white"
|
||||
style={{ animation: 'likePop 0.5s ease-out forwards' }}
|
||||
onAnimationEnd={() => setLikeBurst(0)}
|
||||
>
|
||||
+1
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
点赞 {(serverLikeCount ?? likes) > 0 && <span className="tabular-nums">{serverLikeCount ?? likes}</span>}
|
||||
</button>
|
||||
<span className={`flex items-center gap-1 ${isConnected ? 'text-green-500' : 'text-military-text-secondary'}`}>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function RetaliationGauge({ value, history, className = '' }: Retaliation
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#EF4444',
|
||||
formatter: '{value}',
|
||||
formatter: (val: number) => Number(val).toFixed(2),
|
||||
},
|
||||
data: [{ value }],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
||||
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||
import { usePlaybackStore, getTicks, REPLAY_START, REPLAY_END, type ReplayScale } from '@/store/playbackStore'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { NewsTicker } from './NewsTicker'
|
||||
import { config } from '@/config'
|
||||
@@ -33,16 +33,20 @@ export function TimelinePanel() {
|
||||
const {
|
||||
isReplayMode,
|
||||
playbackTime,
|
||||
replayScale,
|
||||
isPlaying,
|
||||
speedSecPerTick,
|
||||
setReplayMode,
|
||||
setPlaybackTime,
|
||||
setReplayScale,
|
||||
setIsPlaying,
|
||||
stepForward,
|
||||
stepBack,
|
||||
setSpeed,
|
||||
} = usePlaybackStore()
|
||||
|
||||
const replayTicks = getTicks(replayScale)
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,27 +66,30 @@ export function TimelinePanel() {
|
||||
return
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
const current = usePlaybackStore.getState().playbackTime
|
||||
const i = REPLAY_TICKS.indexOf(current)
|
||||
if (i >= REPLAY_TICKS.length - 1) {
|
||||
const { playbackTime: current, replayScale: scale } = usePlaybackStore.getState()
|
||||
const ticks = getTicks(scale)
|
||||
const i = ticks.indexOf(current)
|
||||
if (i >= ticks.length - 1) {
|
||||
setIsPlaying(false)
|
||||
return
|
||||
}
|
||||
setPlaybackTime(REPLAY_TICKS[i + 1])
|
||||
setPlaybackTime(ticks[i + 1])
|
||||
}, speedSecPerTick * 1000)
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying])
|
||||
|
||||
const index = REPLAY_TICKS.indexOf(playbackTime)
|
||||
const value = index >= 0 ? index : REPLAY_TICKS.length - 1
|
||||
const index = replayTicks.indexOf(playbackTime)
|
||||
const value = index >= 0 ? index : replayTicks.length - 1
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const i = parseInt(e.target.value, 10)
|
||||
setPlaybackTime(REPLAY_TICKS[i])
|
||||
setPlaybackTime(replayTicks[i])
|
||||
}
|
||||
|
||||
const scaleLabels: Record<ReplayScale, string> = { '30m': '30分钟', '1h': '1小时', '1d': '1天' }
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
|
||||
{!isReplayMode && (
|
||||
@@ -142,7 +149,7 @@ export function TimelinePanel() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={stepForward}
|
||||
disabled={index >= REPLAY_TICKS.length - 1}
|
||||
disabled={index >= replayTicks.length - 1}
|
||||
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
|
||||
title="下一步"
|
||||
>
|
||||
@@ -150,21 +157,45 @@ export function TimelinePanel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 lg:min-w-[320px]">
|
||||
<div className="flex items-center gap-1 text-[11px] text-military-text-secondary">
|
||||
<span>时间刻度</span>
|
||||
<select
|
||||
value={replayScale}
|
||||
onChange={(e) => setReplayScale(e.target.value as ReplayScale)}
|
||||
className="rounded border border-military-border bg-military-dark/80 px-2 py-1 text-[11px] text-military-text-secondary focus:border-military-accent focus:outline-none"
|
||||
title="回放刻度"
|
||||
>
|
||||
{(['30m', '1h', '1d'] as const).map((s) => (
|
||||
<option key={s} value={s}>{scaleLabels[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 lg:min-w-[320px]">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={REPLAY_TICKS.length - 1}
|
||||
max={replayTicks.length - 1}
|
||||
value={value}
|
||||
onChange={handleSliderChange}
|
||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-military-border [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-military-accent"
|
||||
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-military-border [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-military-accent"
|
||||
/>
|
||||
{/* 按刻度划分的时间轴:均匀取约 5~6 个刻度标签 */}
|
||||
<div className="flex justify-between text-[10px] tabular-nums text-military-text-secondary">
|
||||
{replayTicks.length <= 1 ? (
|
||||
<span>{formatTick(replayTicks[0] ?? REPLAY_START)}</span>
|
||||
) : (
|
||||
(() => {
|
||||
const n = replayTicks.length - 1
|
||||
const maxLabels = 6
|
||||
const step = Math.max(1, Math.floor(n / (maxLabels - 1)))
|
||||
const indices = [0, ...Array.from({ length: maxLabels - 2 }, (_, j) => Math.min((j + 1) * step, n)), n]
|
||||
return [...new Set(indices)].map((i) => (
|
||||
<span key={i}>{formatTick(replayTicks[i])}</span>
|
||||
))
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div 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
@@ -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: [
|
||||
|
||||
@@ -1,188 +1,15 @@
|
||||
/** 航母标记 - 全部中文 */
|
||||
export const CARRIER_MARKERS = [
|
||||
{
|
||||
id: 'CVN-72',
|
||||
name: '林肯号航母',
|
||||
coordinates: [58.4215, 24.1568] as [number, number],
|
||||
type: 'Aircraft Carrier',
|
||||
status: 'Active - Combat Readiness',
|
||||
details: '林肯号航母打击群 (CSG-3) 部署于北阿拉伯海。',
|
||||
},
|
||||
{
|
||||
id: 'CVN-78',
|
||||
name: '福特号航母',
|
||||
coordinates: [24.1002, 35.7397] as [number, number],
|
||||
type: 'Aircraft Carrier',
|
||||
status: 'Active - Forward Deployed',
|
||||
details: '距克里特苏达湾约 15 公里。',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 地图点位类型定义。实际数据来自 API(getSituation 的 usForces.keyLocations / iranForces.keyLocations 与 mapData)。
|
||||
*/
|
||||
export type KeyLocItem = {
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
type?: string
|
||||
region?: string
|
||||
id?: number
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
/** 遭袭时间 ISO 字符串,用于状态管理与数据回放 */
|
||||
attacked_at?: string | null
|
||||
}
|
||||
|
||||
/** 美军基地总数 62,被袭击 27 个。损毁程度:严重 6 / 中度 12 / 轻度 9 */
|
||||
const ATTACKED_BASES = [
|
||||
// 严重损毁 (6): 高价值目标,近伊朗
|
||||
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, region: '卡塔尔', damage_level: 3 },
|
||||
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, region: '土耳其', damage_level: 3 },
|
||||
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, region: '沙特', damage_level: 3 },
|
||||
// 中度损毁 (12)
|
||||
{ name: '塔吉军营', lat: 33.556, lng: 44.256, region: '伊拉克', damage_level: 2 },
|
||||
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, region: '伊拉克', damage_level: 2 },
|
||||
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, region: '科威特', damage_level: 2 },
|
||||
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, region: '科威特', damage_level: 2 },
|
||||
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, region: '巴林', damage_level: 2 },
|
||||
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, region: '阿联酋', damage_level: 2 },
|
||||
{ name: '埃斯康村', lat: 24.774, lng: 46.738, region: '沙特', damage_level: 2 },
|
||||
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, region: '以色列', damage_level: 2 },
|
||||
// 轻度损毁 (9)
|
||||
{ name: '布林军营', lat: 29.603, lng: 47.456, region: '科威特', damage_level: 1 },
|
||||
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, region: '卡塔尔', damage_level: 1 },
|
||||
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, region: '以色列', damage_level: 1 },
|
||||
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, region: '约旦', damage_level: 1 },
|
||||
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, region: '土耳其', damage_level: 1 },
|
||||
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, region: '阿曼', damage_level: 1 },
|
||||
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, region: '阿曼', damage_level: 1 },
|
||||
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, region: '埃及', damage_level: 1 },
|
||||
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, region: '吉布提', damage_level: 1 },
|
||||
]
|
||||
|
||||
/** 35 个新增 operational 基地 */
|
||||
const NEW_BASES: KeyLocItem[] = [
|
||||
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔' },
|
||||
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林' },
|
||||
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特' },
|
||||
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特' },
|
||||
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋' },
|
||||
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋' },
|
||||
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特' },
|
||||
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特' },
|
||||
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特' },
|
||||
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克' },
|
||||
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克' },
|
||||
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克' },
|
||||
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克' },
|
||||
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚' },
|
||||
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚' },
|
||||
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦' },
|
||||
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其' },
|
||||
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列' },
|
||||
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼' },
|
||||
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼' },
|
||||
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及' },
|
||||
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及' },
|
||||
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提' },
|
||||
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔' },
|
||||
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特' },
|
||||
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克' },
|
||||
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚' },
|
||||
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦' },
|
||||
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其' },
|
||||
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列' },
|
||||
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼' },
|
||||
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及' },
|
||||
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提' },
|
||||
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋' },
|
||||
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克' },
|
||||
]
|
||||
|
||||
/** 美军全部地图点位:2 航母 + 9 海军 + 62 基地 */
|
||||
export const US_KEY_LOCATIONS: KeyLocItem[] = [
|
||||
...CARRIER_MARKERS.map((c) => ({
|
||||
name: c.name + ` (${c.id})`,
|
||||
lat: c.coordinates[1],
|
||||
lng: c.coordinates[0],
|
||||
type: 'Aircraft Carrier' as const,
|
||||
region: c.id === 'CVN-72' ? '北阿拉伯海' : '东地中海',
|
||||
status: 'operational' as const,
|
||||
damage_level: undefined as number | undefined,
|
||||
})),
|
||||
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
...ATTACKED_BASES.map((b) => ({
|
||||
...b,
|
||||
type: 'Base' as const,
|
||||
status: 'attacked' as const,
|
||||
})),
|
||||
...NEW_BASES,
|
||||
]
|
||||
|
||||
/** 被袭击的 27 个基地坐标 [lng, lat],用于绘制攻击曲线 */
|
||||
export const ATTACKED_TARGETS: [number, number][] = ATTACKED_BASES.map((b) => [b.lng, b.lat])
|
||||
|
||||
/** 美以联军打击伊朗目标 (2026.03.01) - 中文标注,coords [lng, lat] */
|
||||
export const ALLIED_STRIKE_LOCATIONS = [
|
||||
// 1. 核心指挥与政治中枢
|
||||
{ name: '哈梅内伊官邸', coords: [51.42, 35.69] as [number, number], type: 'Leadership' },
|
||||
{ name: '总统府/情报部', coords: [51.41, 35.72] as [number, number], type: 'Leadership' },
|
||||
{ name: '梅赫拉巴德机场', coords: [51.15, 35.69] as [number, number], type: 'Leadership' },
|
||||
{ name: '库姆', coords: [50.88, 34.64] as [number, number], type: 'Leadership' },
|
||||
// 2. 核设施与战略研究点
|
||||
{ name: '伊斯法罕核设施', coords: [51.667, 32.654] as [number, number], type: 'Nuclear' },
|
||||
{ name: '纳坦兹', coords: [51.916, 33.666] as [number, number], type: 'Nuclear' },
|
||||
{ name: '布什尔雷达站', coords: [50.838, 28.968] as [number, number], type: 'Nuclear' },
|
||||
// 3. 导弹与无人机基地
|
||||
{ name: '卡拉季无人机厂', coords: [51.002, 35.808] as [number, number], type: 'UAV' },
|
||||
{ name: '克尔曼沙赫导弹掩体', coords: [47.076, 34.314] as [number, number], type: 'Missile' },
|
||||
{ name: '大不里士空军基地', coords: [46.29, 38.08] as [number, number], type: 'Missile' },
|
||||
{ name: '伊拉姆导弹阵地', coords: [46.42, 33.64] as [number, number], type: 'Missile' },
|
||||
{ name: '霍拉马巴德储备库', coords: [48.35, 33.48] as [number, number], type: 'Missile' },
|
||||
// 4. 海军与南部封锁节点
|
||||
{ name: '阿巴斯港海军司令部', coords: [56.27, 27.18] as [number, number], type: 'Naval' },
|
||||
{ name: '米纳布', coords: [57.08, 27.13] as [number, number], type: 'Naval' },
|
||||
{ name: '霍尔木兹岸防阵地', coords: [56.5, 27.0] as [number, number], type: 'Naval' },
|
||||
]
|
||||
|
||||
/** 盟军打击目标坐标 [lng, lat] */
|
||||
export const ALLIED_STRIKE_TARGETS: [number, number][] = ALLIED_STRIKE_LOCATIONS.map((s) => s.coords)
|
||||
|
||||
/** 林肯号航母位置 [lng, lat] - 北阿拉伯海 */
|
||||
export const LINCOLN_COORDS: [number, number] = [58.4215, 24.1568]
|
||||
/** 福特号航母位置 [lng, lat] - 东地中海 */
|
||||
export const FORD_COORDS: [number, number] = [24.1002, 35.7397]
|
||||
/** 以色列打击源 [lng, lat] - 特拉维夫附近 */
|
||||
export const ISRAEL_STRIKE_SOURCE: [number, number] = [34.78, 32.08]
|
||||
|
||||
/** 林肯号打击目标:南部海军/核设施 */
|
||||
export const LINCOLN_STRIKE_TARGETS: [number, number][] = [
|
||||
[56.27, 27.18], [57.08, 27.13], [56.5, 27.0], // 阿巴斯港、米纳布、霍尔木兹
|
||||
[50.838, 28.968], [51.667, 32.654], // 布什尔、伊斯法罕
|
||||
]
|
||||
/** 福特号打击目标:北部/西部 */
|
||||
export const FORD_STRIKE_TARGETS: [number, number][] = [
|
||||
[51.42, 35.69], [51.41, 35.72], [51.15, 35.69], // 德黑兰核心
|
||||
[46.29, 38.08], [47.076, 34.314], [46.42, 33.64], [48.35, 33.48], // 大不里士、克尔曼沙赫、伊拉姆、霍拉马巴德
|
||||
]
|
||||
/** 以色列打击目标:中部核设施/指挥 */
|
||||
export const ISRAEL_STRIKE_TARGETS: [number, number][] = [
|
||||
[50.88, 34.64], [51.916, 33.666], [51.002, 35.808], // 库姆、纳坦兹、卡拉季
|
||||
]
|
||||
|
||||
export const IRAN_KEY_LOCATIONS: KeyLocItem[] = [
|
||||
{ name: '阿巴斯港', lat: 27.1832, lng: 56.2666, type: 'Port', region: '伊朗' },
|
||||
{ name: '德黑兰', lat: 35.6892, lng: 51.389, type: 'Capital', region: '伊朗' },
|
||||
{ name: '布什尔', lat: 28.9681, lng: 50.838, type: 'Base', region: '伊朗' },
|
||||
]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// TypeScript interfaces for military situation data
|
||||
import { US_KEY_LOCATIONS, IRAN_KEY_LOCATIONS } from './mapLocations'
|
||||
|
||||
export interface ForceAsset {
|
||||
id: string
|
||||
@@ -90,6 +89,8 @@ export interface MilitarySituation {
|
||||
id?: number
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
/** 遭袭时间 ISO 字符串,用于状态管理与回放 */
|
||||
attacked_at?: string | null
|
||||
}[]
|
||||
combatLosses: CombatLosses
|
||||
/** 华尔街财团投入趋势 { time: ISO string, value: 0-100 } */
|
||||
@@ -108,6 +109,7 @@ export interface MilitarySituation {
|
||||
id?: number
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
attacked_at?: string | null
|
||||
}[]
|
||||
combatLosses: CombatLosses
|
||||
/** 反击情绪指标 0-100 */
|
||||
@@ -122,6 +124,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: '霍拉马巴德储备库' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { MilitarySituation } from '@/data/mockData'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { usePlaybackStore } from '@/store/playbackStore'
|
||||
import { usePlaybackStore, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||
|
||||
/** 将系列时间映射到回放日 (2026-03-01) 以便按当天时刻插值 */
|
||||
/** 盟军打击伊朗结束时刻:伊朗战损在此阶段增长 */
|
||||
const ALLIED_STRIKE_END = '2026-02-28T04:00:00.000Z'
|
||||
/** 伊朗反击开始时刻:美军战损在此阶段增长 */
|
||||
const IRAN_RETALIATION_START = '2026-02-28T06:00:00.000Z'
|
||||
|
||||
/** 将系列时间映射到回放日以便按当天时刻插值 */
|
||||
function toReplayDay(iso: string, baseDay: string): string {
|
||||
const d = new Date(iso)
|
||||
const [y, m, day] = baseDay.slice(0, 10).split('-').map(Number)
|
||||
return new Date(y, (m || 1) - 1, day || 1, d.getUTCHours(), d.getUTCMinutes(), 0, 0).toISOString()
|
||||
}
|
||||
|
||||
/** 判断数据库时间戳是否已到回放时刻(用于按日期刻度自动追加) */
|
||||
function atOrBefore(ts: string | null | undefined, playbackTime: string): boolean {
|
||||
if (!ts) return true
|
||||
return new Date(ts).getTime() <= new Date(playbackTime).getTime()
|
||||
}
|
||||
|
||||
function interpolateAt(
|
||||
series: { time: string; value: number }[],
|
||||
at: string,
|
||||
@@ -43,7 +54,7 @@ function linearProgress(start: string, end: string, at: string): number {
|
||||
return (ta - ts) / (te - ts)
|
||||
}
|
||||
|
||||
/** 根据回放时刻派生态势数据 */
|
||||
/** 根据回放时刻派生态势数据:按日期刻度过滤,只显示 attacked_at / struck_at / timestamp 不晚于 playbackTime 的数据库数据 */
|
||||
export function useReplaySituation(): MilitarySituation {
|
||||
const situation = useSituationStore((s) => s.situation)
|
||||
const { isReplayMode, playbackTime } = usePlaybackStore()
|
||||
@@ -51,86 +62,110 @@ export function useReplaySituation(): MilitarySituation {
|
||||
return useMemo(() => {
|
||||
if (!isReplayMode) return situation
|
||||
|
||||
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime)
|
||||
// 战损阶段:伊朗(盟军打击 02:00–04:00)先行,美军(伊朗反击 06:00 起)随后
|
||||
const progressIran = linearProgress(REPLAY_START, ALLIED_STRIKE_END, playbackTime)
|
||||
const progressUs = linearProgress(IRAN_RETALIATION_START, REPLAY_END, playbackTime)
|
||||
const lerpIran = (a: number, b: number) => Math.round(a + progressIran * (b - a))
|
||||
const lerpUs = (a: number, b: number) => Math.round(a + progressUs * (b - a))
|
||||
|
||||
// 华尔街趋势、反击情绪:按时间插值
|
||||
const wsValue = interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime)
|
||||
const retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime)
|
||||
// 华尔街趋势、反击情绪:按回放日当天的时刻插值,保留两位小数避免穿模
|
||||
const replayDay = playbackTime.slice(0, 10)
|
||||
const wsValue = Math.round(interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime, replayDay) * 100) / 100
|
||||
const retValue = Math.round(interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime, replayDay) * 100) / 100
|
||||
|
||||
// 战斗损失:从 0 线性增长到当前值
|
||||
const lerp = (a: number, b: number) => Math.round(a + progress * (b - a))
|
||||
const usLoss = situation.usForces.combatLosses
|
||||
const irLoss = situation.iranForces.combatLosses
|
||||
const civTotal = situation.civilianCasualtiesTotal ?? { killed: 0, wounded: 0 }
|
||||
const usLossesAt = {
|
||||
bases: {
|
||||
destroyed: lerp(0, usLoss.bases.destroyed),
|
||||
damaged: lerp(0, usLoss.bases.damaged),
|
||||
destroyed: lerpUs(0, usLoss.bases.destroyed),
|
||||
damaged: lerpUs(0, usLoss.bases.damaged),
|
||||
},
|
||||
personnelCasualties: {
|
||||
killed: lerp(0, usLoss.personnelCasualties.killed),
|
||||
wounded: lerp(0, usLoss.personnelCasualties.wounded),
|
||||
killed: lerpUs(0, usLoss.personnelCasualties.killed),
|
||||
wounded: lerpUs(0, usLoss.personnelCasualties.wounded),
|
||||
},
|
||||
civilianCasualties: { killed: 0, wounded: 0 },
|
||||
aircraft: lerp(0, usLoss.aircraft),
|
||||
warships: lerp(0, usLoss.warships),
|
||||
armor: lerp(0, usLoss.armor),
|
||||
vehicles: lerp(0, usLoss.vehicles),
|
||||
drones: lerp(0, usLoss.drones ?? 0),
|
||||
missiles: lerp(0, usLoss.missiles ?? 0),
|
||||
helicopters: lerp(0, usLoss.helicopters ?? 0),
|
||||
submarines: lerp(0, usLoss.submarines ?? 0),
|
||||
carriers: lerp(0, usLoss.carriers ?? 0),
|
||||
civilianShips: lerp(0, usLoss.civilianShips ?? 0),
|
||||
airportPort: lerp(0, usLoss.airportPort ?? 0),
|
||||
aircraft: lerpUs(0, usLoss.aircraft),
|
||||
warships: lerpUs(0, usLoss.warships),
|
||||
armor: lerpUs(0, usLoss.armor),
|
||||
vehicles: lerpUs(0, usLoss.vehicles),
|
||||
drones: lerpUs(0, usLoss.drones ?? 0),
|
||||
missiles: lerpUs(0, usLoss.missiles ?? 0),
|
||||
helicopters: lerpUs(0, usLoss.helicopters ?? 0),
|
||||
submarines: lerpUs(0, usLoss.submarines ?? 0),
|
||||
carriers: lerpUs(0, usLoss.carriers ?? 0),
|
||||
civilianShips: lerpUs(0, usLoss.civilianShips ?? 0),
|
||||
airportPort: lerpUs(0, usLoss.airportPort ?? 0),
|
||||
}
|
||||
const irLossesAt = {
|
||||
bases: {
|
||||
destroyed: lerp(0, irLoss.bases.destroyed),
|
||||
damaged: lerp(0, irLoss.bases.damaged),
|
||||
destroyed: lerpIran(0, irLoss.bases.destroyed),
|
||||
damaged: lerpIran(0, irLoss.bases.damaged),
|
||||
},
|
||||
personnelCasualties: {
|
||||
killed: lerp(0, irLoss.personnelCasualties.killed),
|
||||
wounded: lerp(0, irLoss.personnelCasualties.wounded),
|
||||
killed: lerpIran(0, irLoss.personnelCasualties.killed),
|
||||
wounded: lerpIran(0, irLoss.personnelCasualties.wounded),
|
||||
},
|
||||
civilianCasualties: { killed: 0, wounded: 0 },
|
||||
aircraft: lerp(0, irLoss.aircraft),
|
||||
warships: lerp(0, irLoss.warships),
|
||||
armor: lerp(0, irLoss.armor),
|
||||
vehicles: lerp(0, irLoss.vehicles),
|
||||
drones: lerp(0, irLoss.drones ?? 0),
|
||||
missiles: lerp(0, irLoss.missiles ?? 0),
|
||||
helicopters: lerp(0, irLoss.helicopters ?? 0),
|
||||
submarines: lerp(0, irLoss.submarines ?? 0),
|
||||
carriers: lerp(0, irLoss.carriers ?? 0),
|
||||
civilianShips: lerp(0, irLoss.civilianShips ?? 0),
|
||||
airportPort: lerp(0, irLoss.airportPort ?? 0),
|
||||
aircraft: lerpIran(0, irLoss.aircraft),
|
||||
warships: lerpIran(0, irLoss.warships),
|
||||
armor: lerpIran(0, irLoss.armor),
|
||||
vehicles: lerpIran(0, irLoss.vehicles),
|
||||
drones: lerpIran(0, irLoss.drones ?? 0),
|
||||
missiles: lerpIran(0, irLoss.missiles ?? 0),
|
||||
helicopters: lerpIran(0, irLoss.helicopters ?? 0),
|
||||
submarines: lerpIran(0, irLoss.submarines ?? 0),
|
||||
carriers: lerpIran(0, irLoss.carriers ?? 0),
|
||||
civilianShips: lerpIran(0, irLoss.civilianShips ?? 0),
|
||||
airportPort: lerpIran(0, irLoss.airportPort ?? 0),
|
||||
}
|
||||
|
||||
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked
|
||||
// 被袭基地:按 DB attacked_at 过滤,只显示已到回放时刻的遭袭点
|
||||
const usLocs = situation.usForces.keyLocations || []
|
||||
const attackedBases = usLocs
|
||||
.filter((loc) => loc.status === 'attacked')
|
||||
.sort((a, b) => (b.damage_level ?? 0) - (a.damage_level ?? 0))
|
||||
const totalAttacked = attackedBases.length
|
||||
const shownAttackedCount = Math.round(progress * totalAttacked)
|
||||
const attackedNames = new Set(
|
||||
attackedBases.slice(0, shownAttackedCount).map((l) => l.name)
|
||||
)
|
||||
|
||||
const usLocsAt = usLocs.map((loc) => {
|
||||
if (loc.status === 'attacked' && !attackedNames.has(loc.name)) {
|
||||
if (loc.status === 'attacked' && !atOrBefore(loc.attacked_at, playbackTime)) {
|
||||
return { ...loc, status: 'operational' as const }
|
||||
}
|
||||
return { ...loc }
|
||||
})
|
||||
|
||||
const irLocs = situation.iranForces.keyLocations || []
|
||||
const irLocsAt = irLocs.map((loc) => {
|
||||
if ((loc.status === 'attacked' || loc.status === 'damaged') && !atOrBefore(loc.attacked_at, playbackTime)) {
|
||||
return { ...loc, status: 'operational' as const }
|
||||
}
|
||||
return { ...loc }
|
||||
})
|
||||
|
||||
// mapData:按 struck_at / attacked_at 过滤;打击线固定顺序 以色列 → 林肯 → 福特,保证回放时以色列动画先出现
|
||||
const mapData = situation.mapData
|
||||
const mapDataAt = mapData
|
||||
? (() => {
|
||||
const filtered = mapData.strikeLines.map((line) => ({
|
||||
sourceId: line.sourceId,
|
||||
targets: line.targets.filter((t) => atOrBefore(t.struck_at, playbackTime)),
|
||||
}))
|
||||
const order = ['israel', 'lincoln', 'ford']
|
||||
const ordered = order.map((id) => filtered.find((l) => l.sourceId === id)).filter(Boolean) as typeof filtered
|
||||
const rest = filtered.filter((l) => !order.includes(l.sourceId))
|
||||
const strikeLines = [...ordered, ...rest]
|
||||
return {
|
||||
attackedTargets: (usLocsAt.filter((l) => l.status === 'attacked') as { lng: number; lat: number }[]).map(
|
||||
(l) => [l.lng, l.lat] as [number, number]
|
||||
),
|
||||
strikeSources: mapData.strikeSources,
|
||||
strikeLines,
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...situation,
|
||||
lastUpdated: playbackTime,
|
||||
civilianCasualtiesTotal: {
|
||||
killed: lerp(0, civTotal.killed),
|
||||
wounded: lerp(0, civTotal.wounded),
|
||||
killed: lerpIran(0, civTotal.killed),
|
||||
wounded: lerpIran(0, civTotal.wounded),
|
||||
},
|
||||
usForces: {
|
||||
...situation.usForces,
|
||||
@@ -143,6 +178,7 @@ export function useReplaySituation(): MilitarySituation {
|
||||
},
|
||||
iranForces: {
|
||||
...situation.iranForces,
|
||||
keyLocations: irLocsAt,
|
||||
combatLosses: irLossesAt,
|
||||
retaliationSentiment: retValue,
|
||||
retaliationSentimentHistory: [
|
||||
@@ -153,8 +189,11 @@ export function useReplaySituation(): MilitarySituation {
|
||||
recentUpdates: (situation.recentUpdates || []).filter(
|
||||
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime()
|
||||
),
|
||||
conflictEvents: situation.conflictEvents || [],
|
||||
conflictEvents: (situation.conflictEvents || []).filter(
|
||||
(e) => new Date(e.event_time).getTime() <= new Date(playbackTime).getTime()
|
||||
),
|
||||
conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
|
||||
mapData: mapDataAt,
|
||||
}
|
||||
}, [situation, isReplayMode, playbackTime])
|
||||
}
|
||||
|
||||
109
src/hooks/useWarMapData.ts
Normal file
109
src/hooks/useWarMapData.ts
Normal 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])
|
||||
}
|
||||
@@ -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 缩放,保持比例正常 */
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeft, RefreshCw, Save, Trash2, Plus, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ArrowLeft, RefreshCw, Save, Trash2, Plus, ChevronDown, ChevronRight, MessageSquare, X } from 'lucide-react'
|
||||
import {
|
||||
fetchEditRaw,
|
||||
putCombatLosses,
|
||||
@@ -9,11 +9,17 @@ import {
|
||||
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'
|
||||
@@ -66,6 +72,9 @@ export function EditDashboard() {
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['displayStats', 'losses', 'updates']))
|
||||
const [newUpdate, setNewUpdate] = useState({ category: 'other', summary: '', severity: 'medium' })
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||
const [feedbackList, setFeedbackList] = useState<FeedbackRow[]>([])
|
||||
const [feedbackLoading, setFeedbackLoading] = useState(false)
|
||||
const setStats = useStatsStore((s) => s.setStats)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -186,17 +195,49 @@ export function EditDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
viewers: null,
|
||||
cumulative: null,
|
||||
shareCount: null,
|
||||
likeCount: null,
|
||||
feedbackCount: null,
|
||||
})
|
||||
await putDisplayStats({ clearOverride: true })
|
||||
await load()
|
||||
const res = await fetch('/api/stats', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
@@ -251,22 +292,133 @@ export function EditDashboard() {
|
||||
)}
|
||||
|
||||
<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('displayStats')}
|
||||
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 抓取间隔(秒):爬虫每轮抓取后等待该秒数再执行下一轮。范围 30–86400(1 分钟–24 小时)。修改后爬虫下次循环生效。
|
||||
</p>
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const v = Number((e.currentTarget.querySelector('input[name="rssIntervalSec"]') as HTMLInputElement)?.value ?? 60)
|
||||
const sec = Number.isFinite(v) && v >= 30 && v <= 86400 ? v : data.crawlerConfig?.rssIntervalSec ?? 60
|
||||
handleSaveCrawlerConfig({ rssIntervalSec: sec })
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-xs text-military-text-secondary">
|
||||
<span>抓取间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
name="rssIntervalSec"
|
||||
min={30}
|
||||
max={86400}
|
||||
defaultValue={data.crawlerConfig?.rssIntervalSec ?? 60}
|
||||
className="w-24 rounded border border-military-border bg-black/40 px-2 py-1 text-right text-xs text-military-text-primary"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving === 'crawlerConfig'}
|
||||
className="inline-flex items-center gap-1 rounded border border-military-border px-3 py-1.5 text-xs text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
保存
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 看过、在看、分享、点赞、留言 */}
|
||||
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleSection('displayStats')}
|
||||
className="flex flex-1 items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
|
||||
>
|
||||
<span className="font-medium text-cyan-400">看过 / 在看 / 分享 / 点赞 / 留言</span>
|
||||
{openSections.has('displayStats') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenFeedback}
|
||||
className="flex items-center gap-1.5 rounded border border-military-border px-2 py-1.5 text-xs text-military-text-secondary hover:bg-military-border/30 shrink-0"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
查看留言数据
|
||||
</button>
|
||||
</div>
|
||||
{openSections.has('displayStats') && data && (
|
||||
<div className="border-t border-military-border p-4 space-y-3">
|
||||
<p className="text-military-text-secondary text-xs">
|
||||
统计方式:在看 = 近 2 分钟内不同设备数(按设备 ID 去重);看过 = 累计访问次数。手动填写会覆盖实时值;点「恢复实时统计」可清除覆盖。
|
||||
{data.displayStats?.overrideEnabled
|
||||
? '当前看板显示为覆盖值。'
|
||||
: '当前看板显示为实时统计。'}
|
||||
在看 = 近 2 分钟访问数,看过 = 累计访问。保存后开启覆盖;点「恢复实时统计」关闭覆盖。
|
||||
</p>
|
||||
<DisplayStatsForm
|
||||
row={data.displayStats ?? { viewers: 0, cumulative: 0, shareCount: 0, likeCount: 0, feedbackCount: 0 }}
|
||||
row={data.displayStats ?? { overrideEnabled: false, viewers: 0, cumulative: 0, shareCount: 0, likeCount: 0, feedbackCount: 0 }}
|
||||
onSave={handleSaveDisplayStats}
|
||||
onClearOverrides={handleClearDisplayStatsOverrides}
|
||||
saving={saving === 'displayStats'}
|
||||
@@ -446,6 +598,56 @@ export function EditDashboard() {
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* 留言数据弹窗 */}
|
||||
{feedbackOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={() => setFeedbackOpen(false)}
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="max-h-[85vh] w-full max-w-2xl rounded border border-military-border bg-military-panel shadow-xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-military-border px-4 py-3">
|
||||
<h3 className="font-medium text-cyan-400">留言数据</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackOpen(false)}
|
||||
className="rounded p-1 text-military-text-secondary hover:bg-military-border/50 hover:text-military-text-primary"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{feedbackLoading ? (
|
||||
<p className="text-military-text-secondary text-sm">加载中…</p>
|
||||
) : feedbackList.length === 0 ? (
|
||||
<p className="text-military-text-secondary text-sm">暂无留言(若刚点击后即显示,可能是接口暂时不可用,请稍后重试)</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{feedbackList.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="rounded border border-military-border/50 bg-military-dark/30 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-military-text-secondary mb-1">
|
||||
<span>#{f.id}</span>
|
||||
<span>{f.created_at?.slice(0, 19).replace('T', ' ') ?? ''}</span>
|
||||
{f.ip && <span>{f.ip}</span>}
|
||||
</div>
|
||||
<p className="text-military-text-primary whitespace-pre-wrap break-words">{f.content}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,51 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const REPLAY_DAY = '2026-03-01'
|
||||
const TICK_MS = 30 * 60 * 1000 // 30 minutes
|
||||
/** 回放日期范围:覆盖 2 月 28 日(盟军打击伊朗 → 伊朗反击),与 DB attacked_at / struck_at 对齐 */
|
||||
const REPLAY_START_ISO = '2026-02-28T00:00:00.000Z'
|
||||
const REPLAY_END_ISO = '2026-03-01T23:30:00.000Z'
|
||||
|
||||
export const REPLAY_START = `${REPLAY_DAY}T00:00:00.000Z`
|
||||
export const REPLAY_END = `${REPLAY_DAY}T23:30:00.000Z`
|
||||
export const REPLAY_START = REPLAY_START_ISO
|
||||
export const REPLAY_END = REPLAY_END_ISO
|
||||
|
||||
export type ReplayScale = '30m' | '1h' | '1d'
|
||||
|
||||
const SCALE_MS: Record<ReplayScale, number> = {
|
||||
'30m': 30 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'1d': 24 * 60 * 60 * 1000,
|
||||
}
|
||||
|
||||
function parseTime(iso: string): number {
|
||||
return new Date(iso).getTime()
|
||||
}
|
||||
|
||||
export function getTicks(): string[] {
|
||||
/** 按刻度生成回放时间点;不传 scale 时默认 30 分钟 */
|
||||
export function getTicks(scale: ReplayScale = '30m'): string[] {
|
||||
const step = SCALE_MS[scale]
|
||||
const ticks: string[] = []
|
||||
let t = parseTime(REPLAY_START)
|
||||
const end = parseTime(REPLAY_END)
|
||||
while (t <= end) {
|
||||
ticks.push(new Date(t).toISOString())
|
||||
t += TICK_MS
|
||||
t += step
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
export const REPLAY_TICKS = getTicks()
|
||||
|
||||
export interface PlaybackState {
|
||||
/** 是否开启回放模式 */
|
||||
isReplayMode: boolean
|
||||
/** 当前回放时刻 (ISO) */
|
||||
playbackTime: string
|
||||
/** 回放刻度:30分钟 / 1小时 / 1天 */
|
||||
replayScale: ReplayScale
|
||||
/** 是否正在自动播放 */
|
||||
isPlaying: boolean
|
||||
/** 播放速度 (秒/刻度) */
|
||||
speedSecPerTick: number
|
||||
setReplayMode: (v: boolean) => void
|
||||
setPlaybackTime: (iso: string) => void
|
||||
setReplayScale: (scale: ReplayScale) => void
|
||||
setIsPlaying: (v: boolean) => void
|
||||
stepForward: () => void
|
||||
stepBack: () => void
|
||||
@@ -43,13 +55,20 @@ export interface PlaybackState {
|
||||
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
isReplayMode: false,
|
||||
playbackTime: REPLAY_END,
|
||||
replayScale: '30m',
|
||||
isPlaying: false,
|
||||
speedSecPerTick: 2,
|
||||
|
||||
setReplayMode: (v) => set({ isReplayMode: v, isPlaying: false }),
|
||||
setReplayMode: (v) =>
|
||||
set({
|
||||
isReplayMode: v,
|
||||
isPlaying: false,
|
||||
...(v ? { playbackTime: REPLAY_START } : {}),
|
||||
}),
|
||||
|
||||
setPlaybackTime: (iso) => {
|
||||
const ticks = REPLAY_TICKS
|
||||
const { replayScale } = get()
|
||||
const ticks = getTicks(replayScale)
|
||||
if (ticks.includes(iso)) {
|
||||
set({ playbackTime: iso })
|
||||
return
|
||||
@@ -59,19 +78,27 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
set({ playbackTime: ticks[clamp] })
|
||||
},
|
||||
|
||||
setReplayScale: (scale) => {
|
||||
const { playbackTime } = get()
|
||||
const ticks = getTicks(scale)
|
||||
const idx = ticks.findIndex((t) => t >= playbackTime)
|
||||
const clamp = Math.max(0, Math.min(idx < 0 ? ticks.length - 1 : idx, ticks.length - 1))
|
||||
set({ replayScale: scale, playbackTime: ticks[clamp] })
|
||||
},
|
||||
|
||||
setIsPlaying: (v) => set({ isPlaying: v }),
|
||||
|
||||
stepForward: () => {
|
||||
const { playbackTime } = get()
|
||||
const ticks = REPLAY_TICKS
|
||||
const { playbackTime, replayScale } = get()
|
||||
const ticks = getTicks(replayScale)
|
||||
const i = ticks.indexOf(playbackTime)
|
||||
if (i < ticks.length - 1) set({ playbackTime: ticks[i + 1] })
|
||||
else set({ isPlaying: false })
|
||||
},
|
||||
|
||||
stepBack: () => {
|
||||
const { playbackTime } = get()
|
||||
const ticks = REPLAY_TICKS
|
||||
const { playbackTime, replayScale } = get()
|
||||
const ticks = getTicks(replayScale)
|
||||
const i = ticks.indexOf(playbackTime)
|
||||
if (i > 0) set({ playbackTime: ticks[i - 1] })
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
// situation.lastUpdated 与后端 situation.updated_at 一致,后端在爬虫 notify、编辑保存时更新并广播
|
||||
function pollSituation() {
|
||||
fetchSituation()
|
||||
.then((situation) => useSituationStore.getState().setSituation(situation))
|
||||
|
||||
106
src/utils/defenseLine.ts
Normal file
106
src/utils/defenseLine.ts
Normal 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
|
||||
}
|
||||
282
src/utils/tacticalPincerArrow.ts
Normal file
282
src/utils/tacticalPincerArrow.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 军事钳形箭头多边形生成
|
||||
* 支持两种形态:
|
||||
* 1) 底宽头小 + 曲线轮廓 + 生长动画(单头)
|
||||
* 2) 集团军级三叉戟(Pro Tactical Trident):22 点逻辑模型,基座→柄部收腰→三支独立箭指(左/右副箭 + 主箭),腋下回撤无自交
|
||||
*/
|
||||
|
||||
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→1,t>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.04–0.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)
|
||||
}
|
||||
8
start.sh
8
start.sh
@@ -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=仅用规则提取战损/据点(无需 Ollama),0=用 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)..."
|
||||
|
||||
@@ -22,11 +22,18 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('error', () => {}) // 后端未启动时静默失败
|
||||
// 抑制关标签/刷新时代理底层 socket 的 ECONNRESET,避免刷屏
|
||||
proxy.on('proxyReqWs', (proxyReq, req, socket, head) => {
|
||||
socket?.on?.('error', () => {})
|
||||
const out = (proxyReq as { socket?: { on?: (e: string, fn: () => void) => void } })?.socket
|
||||
out?.on?.('error', () => {})
|
||||
// 吞掉 ECONNRESET,防止 Vite 打印 "ws proxy socket error"(关标签/刷新时常见)
|
||||
function swallowEconnreset(s: { emit?: (ev: string, ...a: unknown[]) => boolean } | null) {
|
||||
if (!s?.emit) return
|
||||
const orig = s.emit.bind(s)
|
||||
s.emit = function (ev: string, ...a: unknown[]) {
|
||||
if (ev === 'error' && (a[0] as { code?: string })?.code === 'ECONNRESET') return false
|
||||
return orig(ev, ...a)
|
||||
}
|
||||
}
|
||||
proxy.on('proxyReqWs', (proxyReq, _req, socket, _head) => {
|
||||
swallowEconnreset(socket ?? null)
|
||||
swallowEconnreset((proxyReq as { socket?: unknown })?.socket ?? null)
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user