fix: 优化docker 镜像

This commit is contained in:
Daniel
2026-03-02 14:10:43 +08:00
parent 783a69dad1
commit 36576592a2
25 changed files with 491 additions and 58 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.git
.env
.env.local
*.log
dist
server/data.db
.DS_Store
*.md
.cursor
.venv
__pycache__
*.pyc

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# 前端 + API 一体化镜像(使用 DaoCloud 国内镜像源)
FROM docker.m.daocloud.io/library/node:20-alpine AS frontend-builder
WORKDIR /app
ARG VITE_MAPBOX_ACCESS_TOKEN
ENV VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN}
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM docker.m.daocloud.io/library/node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=frontend-builder /app/dist ./dist
COPY server ./server
ENV NODE_ENV=production
ENV API_PORT=3001
ENV DB_PATH=/data/data.db
EXPOSE 3001
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
ENTRYPOINT ["./docker-entrypoint.sh"]

17
Dockerfile.crawler Normal file
View File

@@ -0,0 +1,17 @@
# Python 爬虫服务(使用 DaoCloud 国内镜像源 + 清华 PyPI 源)
FROM docker.m.daocloud.io/library/python:3.11-slim
WORKDIR /app
COPY crawler/requirements.txt ./
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
COPY crawler ./
ENV DB_PATH=/data/data.db
ENV API_BASE=http://api:3001
ENV CLEANER_AI_DISABLED=1
ENV GDELT_DISABLED=1
ENV RSS_INTERVAL_SEC=60
EXPOSE 8000
CMD ["uvicorn", "realtime_conflict_service:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -78,6 +78,25 @@ npm run dev
npm run build npm run build
``` ```
## Docker 部署
```bash
# 构建并启动(需 .env 中配置 VITE_MAPBOX_ACCESS_TOKEN 以启用地图)
docker compose up -d
# 访问前端http://localhost:3001
# 数据库与爬虫共享 volume首次启动自动 seed
```
**拉取镜像超时?** 在 Docker Desktop 配置镜像加速,见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md)
环境变量(可选,在 .env 或 docker-compose.yml 中配置):
- `VITE_MAPBOX_ACCESS_TOKEN`Mapbox 令牌,构建时注入
- `DB_PATH`:数据库路径(默认 /data/data.db
- `CLEANER_AI_DISABLED=1`:爬虫默认禁用 Ollama
- `GDELT_DISABLED=1`:爬虫默认禁用 GDELT国内易超时
## Project Structure ## Project Structure
``` ```

Binary file not shown.

View File

@@ -39,11 +39,37 @@ RSS_FEEDS = [
"https://www.aljazeera.com/xml/rss/middleeast.xml", "https://www.aljazeera.com/xml/rss/middleeast.xml",
] ]
# 关键词过滤:至少匹配一个才会入库 # 关键词过滤:至少匹配一个才会入库(与地图区域对应:伊拉克/叙利亚/海湾/红海/地中海等)
KEYWORDS = [ KEYWORDS = [
"iran", "iranian", "tehran", "以色列", "israel", # 伊朗
"usa", "us ", "american", "美军", "美国", "iran", "iranian", "tehran", "德黑兰", "bushehr", "布什尔", "abbas", "阿巴斯",
"middle east", "中东", "persian gulf", "波斯湾", # 以色列 / 巴勒斯坦
"israel", "以色列", "hamas", "gaza", "加沙", "hezbollah", "真主党",
# 美国
"usa", "us ", "american", "美军", "美国", "pentagon",
# 区域(地图覆盖)
"middle east", "中东", "persian gulf", "波斯湾", "gulf of oman", "阿曼湾",
"arabian sea", "阿拉伯海", "red sea", "红海", "mediterranean", "地中海",
"strait of hormuz", "霍尔木兹",
# 伊拉克 / 叙利亚
"iraq", "伊拉克", "baghdad", "巴格达", "erbil", "埃尔比勒", "basra", "巴士拉",
"syria", "叙利亚", "damascus", "大马士革", "deir", "代尔祖尔",
# 海湾国家
"saudi", "沙特", "riyadh", "利雅得", "qatar", "卡塔尔", "doha", "多哈",
"uae", "emirates", "阿联酋", "dubai", "迪拜", "abu dhabi",
"bahrain", "巴林", "kuwait", "科威特", "oman", "阿曼", "yemen", "也门",
# 约旦 / 土耳其 / 埃及 / 吉布提 / 黎巴嫩
"jordan", "约旦", "amman", "安曼",
"lebanon", "黎巴嫩",
"turkey", "土耳其", "incirlik", "因吉尔利克",
"egypt", "埃及", "cairo", "开罗", "sinai", "西奈",
"djibouti", "吉布提",
# 军事 / 基地
"al-asad", "al asad", "阿萨德", "al udeid", "乌代德", "incirlik",
"strike", "attack", "military", "missile", "", "nuclear", "strike", "attack", "military", "missile", "", "nuclear",
"carrier", "航母", "houthi", "胡塞", "hamas", "carrier", "航母", "drone", "uav", "无人机", "retaliation", "报复",
"base", "基地", "troops", "troop", "soldier", "personnel",
# 胡塞 / 武装 / 军力
"houthi", "胡塞", "houthis",
"idf", "irgc", "革命卫队", "qassem soleimani", "苏莱曼尼",
] ]

View File

@@ -67,7 +67,7 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
) )
if conn.total_changes > 0: if conn.total_changes > 0:
updated = True updated = True
# combat_losses增量叠加到当前值 # combat_losses增量叠加到当前值,无行则先插入初始行
if "combat_losses_delta" in extracted: if "combat_losses_delta" in extracted:
for side, delta in extracted["combat_losses_delta"].items(): for side, delta in extracted["combat_losses_delta"].items():
if side not in ("us", "iran"): if side not in ("us", "iran"):
@@ -77,8 +77,9 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
"SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles FROM combat_losses WHERE side = ?", "SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles FROM combat_losses WHERE side = ?",
(side,), (side,),
).fetchone() ).fetchone()
if not row: cur = {"personnel_killed": 0, "personnel_wounded": 0, "civilian_killed": 0, "civilian_wounded": 0,
continue "bases_destroyed": 0, "bases_damaged": 0, "aircraft": 0, "warships": 0, "armor": 0, "vehicles": 0}
if row:
cur = { cur = {
"personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0, "personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0,
"civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5], "civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5],
@@ -95,11 +96,18 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
ar = max(0, (cur["armor"] or 0) + delta.get("armor", 0)) ar = max(0, (cur["armor"] or 0) + delta.get("armor", 0))
vh = max(0, (cur["vehicles"] or 0) + delta.get("vehicles", 0)) vh = max(0, (cur["vehicles"] or 0) + delta.get("vehicles", 0))
ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z") ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
if row:
conn.execute( conn.execute(
"""UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?, """UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?,
bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""", bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""",
(pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side), (pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side),
) )
else:
conn.execute(
"""INSERT OR REPLACE INTO combat_losses (side, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded,
bases_destroyed, bases_damaged, aircraft, warships, armor, vehicles, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(side, pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts),
)
if conn.total_changes > 0: if conn.total_changes > 0:
updated = True updated = True
except Exception: except Exception:
@@ -115,6 +123,30 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
w = extracted["wall_street"] w = extracted["wall_street"]
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"])) conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"]))
updated = True updated = True
# key_location更新受袭基地 status/damage_level
if "key_location_updates" in extracted:
try:
for u in extracted["key_location_updates"]:
kw = (u.get("name_keywords") or "").replace("|", " ").split()
side = u.get("side")
status = u.get("status", "attacked")[:20]
dmg = u.get("damage_level", 2)
if not kw or side not in ("us", "iran"):
continue
conditions = " OR ".join(
"(LOWER(name) LIKE ? OR name LIKE ?)" for _ in kw
)
params = [status, dmg, side]
for k in kw:
params.extend([f"%{k}%", f"%{k}%"])
cur = conn.execute(
f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})",
params,
)
if cur.rowcount > 0:
updated = True
except Exception:
pass
if updated: if updated:
conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),)) conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),))
conn.commit() conn.commit()

View File

@@ -27,11 +27,16 @@ def _call_ollama_extract(text: str, timeout: int = 10) -> Optional[Dict[str, Any
- summary: 1-2句中文事实≤80字 - summary: 1-2句中文事实≤80字
- category: deployment|alert|intel|diplomatic|other - category: deployment|alert|intel|diplomatic|other
- severity: low|medium|high|critical - severity: low|medium|high|critical
- us_personnel_killed, iran_personnel_killed 等:仅当新闻明确提及具体数字时填写 - 战损(仅当新闻明确提及数字时填写,格式 us_XXX / iran_XXX:
us_personnel_killed, iran_personnel_killed, us_personnel_wounded, iran_personnel_wounded,
us_civilian_killed, iran_civilian_killed, us_civilian_wounded, iran_civilian_wounded,
us_bases_destroyed, iran_bases_destroyed, us_bases_damaged, iran_bases_damaged,
us_aircraft, iran_aircraft, us_warships, iran_warships, us_armor, iran_armor, us_vehicles, iran_vehicles
- retaliation_sentiment: 0-100仅当新闻涉及伊朗报复情绪时 - retaliation_sentiment: 0-100仅当新闻涉及伊朗报复情绪时
- wall_street_value: 0-100仅当新闻涉及美股/市场反应时 - wall_street_value: 0-100仅当新闻涉及美股/市场反应时
- key_location_updates: 当新闻提及具体基地/地点遭袭时,数组项 { "name_keywords": "asad|阿萨德|assad", "side": "us", "status": "attacked", "damage_level": 1-3 }
原文:{str(text)[:500]} 原文:{str(text)[:800]}
直接输出 JSON不要解释""" 直接输出 JSON不要解释"""
r = requests.post( r = requests.post(
@@ -97,4 +102,17 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
v = parsed["wall_street_value"] v = parsed["wall_street_value"]
if isinstance(v, (int, float)) and 0 <= v <= 100: if isinstance(v, (int, float)) and 0 <= v <= 100:
out["wall_street"] = {"time": ts, "value": int(v)} out["wall_street"] = {"time": ts, "value": int(v)}
# key_location_updates受袭基地
if "key_location_updates" in parsed and isinstance(parsed["key_location_updates"], list):
valid = []
for u in parsed["key_location_updates"]:
if isinstance(u, dict) and u.get("name_keywords") and u.get("side") in ("us", "iran"):
valid.append({
"name_keywords": str(u["name_keywords"]),
"side": u["side"],
"status": str(u.get("status", "attacked"))[:20],
"damage_level": min(3, max(1, int(u["damage_level"]))) if isinstance(u.get("damage_level"), (int, float)) else 2,
})
if valid:
out["key_location_updates"] = valid
return out return out

View File

@@ -24,18 +24,64 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
t = (text or "").lower() t = (text or "").lower()
loss_us, loss_ir = {}, {} loss_us, loss_ir = {}, {}
v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)")
# 美军人员伤亡
v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)")
if v is not None: if v is not None:
loss_us["personnel_killed"] = v loss_us["personnel_killed"] = v
v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|killed|dead)") v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)")
if v is not None: if v is not None:
loss_us["personnel_killed"] = v loss_us["personnel_killed"] = v
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)") v = _first_int(t, r"(?:us|american)[\s\w]*(\d+)[\s\w]*(?:wounded|injured)")
if v is not None:
loss_us["personnel_wounded"] = v
# 伊朗人员伤亡
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|guard|killed|dead)")
if v is not None: if v is not None:
loss_ir["personnel_killed"] = v loss_ir["personnel_killed"] = v
v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|killed|dead)") v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|guard|killed|dead)")
if v is not None: if v is not None:
loss_ir["personnel_killed"] = v loss_ir["personnel_killed"] = v
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(\d+)[\s\w]*(?:wounded|injured)")
if v is not None:
loss_ir["personnel_wounded"] = v
# 平民伤亡(多不区分阵营,计入双方或仅 us 因多为美国基地周边)
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:killed|dead)")
if v is not None:
loss_us["civilian_killed"] = v
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:wounded|injured)")
if v is not None:
loss_us["civilian_wounded"] = v
# 基地损毁(美方基地居多)
v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:destroyed|leveled)")
if v is not None:
loss_us["bases_destroyed"] = v
v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:damaged|hit|struck)")
if v is not None:
loss_us["bases_damaged"] = v
if "base" in t and ("destroy" in t or "level" in t) and not loss_us.get("bases_destroyed"):
loss_us["bases_destroyed"] = 1
if "base" in t and ("damage" in t or "hit" in t or "struck" in t or "strike" in t) and not loss_us.get("bases_damaged"):
loss_us["bases_damaged"] = 1
# 战机 / 舰船(根据上下文判断阵营)
v = _first_int(t, r"(\d+)[\s\w]*(?:aircraft|plane|jet|fighter|f-?16|f-?35|f-?18)[\s\w]*(?:down|destroyed|lost|shot)")
if v is not None:
if "us" in t or "american" in t or "u.s" in t:
loss_us["aircraft"] = v
elif "iran" in t:
loss_ir["aircraft"] = v
else:
loss_us["aircraft"] = v
v = _first_int(t, r"(\d+)[\s\w]*(?:ship|destroyer|warship|vessel)[\s\w]*(?:hit|damaged|sunk)")
if v is not None:
if "iran" in t:
loss_ir["warships"] = v
else:
loss_us["warships"] = v
if loss_us: if loss_us:
out.setdefault("combat_losses_delta", {})["us"] = loss_us out.setdefault("combat_losses_delta", {})["us"] = loss_us

View File

@@ -14,13 +14,13 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
import asyncio
import logging import logging
import requests import requests
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from apscheduler.schedulers.background import BackgroundScheduler
logging.getLogger("apscheduler.scheduler").setLevel(logging.ERROR) logging.getLogger("uvicorn").setLevel(logging.INFO)
app = FastAPI(title="GDELT Conflict Service") app = FastAPI(title="GDELT Conflict Service")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])
@@ -263,8 +263,9 @@ def _extract_and_merge_panel_data(items: list) -> None:
from extractor_ai import extract_from_news from extractor_ai import extract_from_news
from datetime import timezone from datetime import timezone
merged_any = False merged_any = False
# 只对前几条有足够文本的新闻做提取,避免 Ollama 调用过多 # 规则模式可多处理几条(无 OllamaAI 模式限制 5 条避免调用过多
for it in items[:5]: limit = 10 if os.environ.get("CLEANER_AI_DISABLED", "0") == "1" else 5
for it in items[:limit]:
text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "") text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "")
if len(text.strip()) < 20: if len(text.strip()) < 20:
continue continue
@@ -292,12 +293,22 @@ def _extract_and_merge_panel_data(items: list) -> None:
# ========================== # ==========================
# 定时任务(RSS 更频繁,优先保证事件脉络实时 # 定时任务(asyncio 后台任务,避免 APScheduler executor 关闭竞态
# ========================== # ==========================
scheduler = BackgroundScheduler() _bg_task: Optional[asyncio.Task] = None
scheduler.add_job(fetch_news, "interval", seconds=RSS_INTERVAL_SEC, max_instances=2, coalesce=True)
scheduler.add_job(fetch_gdelt_events, "interval", seconds=FETCH_INTERVAL_SEC, max_instances=2, coalesce=True)
scheduler.start() async def _periodic_fetch() -> None:
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)
except asyncio.CancelledError:
break
except Exception as e:
print(f" [warn] 定时抓取: {e}")
await asyncio.sleep(min(RSS_INTERVAL_SEC, FETCH_INTERVAL_SEC))
# ========================== # ==========================
@@ -356,17 +367,22 @@ def _get_conflict_stats() -> dict:
@app.on_event("startup") @app.on_event("startup")
def startup(): async def startup():
# 新闻优先启动,确保事件脉络有数据 global _bg_task
fetch_news() loop = asyncio.get_event_loop()
fetch_gdelt_events() await loop.run_in_executor(None, fetch_news)
await loop.run_in_executor(None, fetch_gdelt_events)
_bg_task = asyncio.create_task(_periodic_fetch())
@app.on_event("shutdown") @app.on_event("shutdown")
def shutdown(): async def shutdown():
global _bg_task
if _bg_task and not _bg_task.done():
_bg_task.cancel()
try: try:
scheduler.shutdown(wait=False) await _bg_task
except Exception: except asyncio.CancelledError:
pass pass

View File

@@ -2,5 +2,4 @@ requests>=2.31.0
feedparser>=6.0.0 feedparser>=6.0.0
fastapi>=0.109.0 fastapi>=0.109.0
uvicorn>=0.27.0 uvicorn>=0.27.0
apscheduler>=3.10.0
deep-translator>=1.11.0 deep-translator>=1.11.0

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
services:
api:
build:
context: .
args:
- VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN:-}
ports:
- "3001:3001"
environment:
- DB_PATH=/data/data.db
- API_PORT=3001
volumes:
- app-data:/data
restart: unless-stopped
crawler:
build:
context: .
dockerfile: Dockerfile.crawler
environment:
- DB_PATH=/data/data.db
- API_BASE=http://api:3001
- CLEANER_AI_DISABLED=1
- GDELT_DISABLED=1
- RSS_INTERVAL_SEC=60
volumes:
- app-data:/data
depends_on:
- api
restart: unless-stopped
volumes:
app-data:

8
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
export DB_PATH="${DB_PATH:-/data/data.db}"
if [ ! -f "$DB_PATH" ]; then
echo "==> Seeding database..."
node server/seed.js
fi
exec node server/index.js

30
docs/DOCKER_MIRROR.md Normal file
View File

@@ -0,0 +1,30 @@
# Docker 拉取超时 / 配置镜像加速
国内环境从 Docker Hub 拉取镜像常超时,需在 Docker 中配置镜像加速。
## Docker DesktopmacOS / Windows
1. 打开 **Docker Desktop**
2. **Settings****Docker Engine**
3. 在 JSON 中增加 `registry-mirrors`(若已有其他配置,只需合并进该字段):
```json
{
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://docker.1ms.run"
]
}
```
4. 点击 **Apply & Restart**
5. 重新执行:`docker compose up -d --build`
## 备选镜像源
可替换或补充到 `registry-mirrors` 中:
- `https://docker.m.daocloud.io`DaoCloud
- `https://docker.1ms.run`
- `https://docker.rainbond.cc`(好雨科技)
- 阿里云 / 腾讯云等:在对应云控制台的「容器镜像服务」中获取个人专属加速地址

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,7 @@
const Database = require('better-sqlite3') const Database = require('better-sqlite3')
const path = require('path') const path = require('path')
const dbPath = path.join(__dirname, 'data.db') const dbPath = process.env.DB_PATH || path.join(__dirname, 'data.db')
const db = new Database(dbPath) const db = new Database(dbPath)
// 启用外键 // 启用外键
@@ -147,4 +147,19 @@ addUpdatedAt('force_asset')
addUpdatedAt('key_location') addUpdatedAt('key_location')
addUpdatedAt('retaliation_current') addUpdatedAt('retaliation_current')
// 来访统计visits 用于在看(近期活跃 IPvisitor_count 用于累积人次(每次接入 +1
try {
db.exec(`
CREATE TABLE IF NOT EXISTS visits (
ip TEXT PRIMARY KEY,
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS visitor_count (
id INTEGER PRIMARY KEY CHECK (id = 1),
total INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO visitor_count (id, total) VALUES (1, 0);
`)
} catch (_) {}
module.exports = db module.exports = db

View File

@@ -1,4 +1,6 @@
const http = require('http') const http = require('http')
const path = require('path')
const fs = require('fs')
const express = require('express') const express = require('express')
const cors = require('cors') const cors = require('cors')
const { WebSocketServer } = require('ws') const { WebSocketServer } = require('ws')
@@ -8,6 +10,7 @@ const { getSituation } = require('./situationData')
const app = express() const app = express()
const PORT = process.env.API_PORT || 3001 const PORT = process.env.API_PORT || 3001
app.set('trust proxy', 1)
app.use(cors()) app.use(cors())
app.use(express.json()) app.use(express.json())
app.use('/api', routes) app.use('/api', routes)
@@ -17,6 +20,17 @@ app.post('/api/crawler/notify', (_, res) => {
res.json({ ok: true }) res.json({ ok: true })
}) })
// 生产环境:提供前端静态文件
const distPath = path.join(__dirname, '..', 'dist')
if (fs.existsSync(distPath)) {
app.use(express.static(distPath))
app.get('*', (req, res, next) => {
if (!req.path.startsWith('/api') && req.path !== '/ws') {
res.sendFile(path.join(distPath, 'index.html'))
} else next()
})
}
const server = http.createServer(app) const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws' }) const wss = new WebSocketServer({ server, path: '/ws' })

View File

@@ -62,6 +62,46 @@ router.get('/situation', (req, res) => {
} }
}) })
// 来访统计:记录 IP返回在看/累积
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for']
if (forwarded) return forwarded.split(',')[0].trim()
return req.ip || req.socket?.remoteAddress || 'unknown'
}
router.post('/visit', (req, res) => {
try {
const ip = getClientIp(req)
db.prepare(
"INSERT OR REPLACE INTO visits (ip, last_seen) VALUES (?, datetime('now'))"
).run(ip)
db.prepare(
'INSERT INTO visitor_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1'
).run()
const viewers = db.prepare(
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
).get().n
const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
res.json({ viewers, cumulative })
} catch (err) {
console.error(err)
res.status(500).json({ viewers: 0, cumulative: 0 })
}
})
router.get('/stats', (req, res) => {
try {
const viewers = db.prepare(
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
).get().n
const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
res.json({ viewers, cumulative })
} catch (err) {
console.error(err)
res.status(500).json({ viewers: 0, cumulative: 0 })
}
})
router.get('/events', (req, res) => { router.get('/events', (req, res) => {
try { try {
const s = getSituation() const s = getSituation()

View File

@@ -1,10 +1,19 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { StatCard } from './StatCard' import { StatCard } from './StatCard'
import { useSituationStore } from '@/store/situationStore' import { useSituationStore } from '@/store/situationStore'
import { useReplaySituation } from '@/hooks/useReplaySituation' import { useReplaySituation } from '@/hooks/useReplaySituation'
import { usePlaybackStore } from '@/store/playbackStore' import { usePlaybackStore } from '@/store/playbackStore'
import { Wifi, WifiOff, Clock, Database } from 'lucide-react' import { Wifi, WifiOff, Clock, Share2, Heart, Eye } from 'lucide-react'
const STORAGE_LIKES = 'us-iran-dashboard-likes'
function getStoredLikes(): number {
try {
return parseInt(localStorage.getItem(STORAGE_LIKES) ?? '0', 10)
} catch {
return 0
}
}
export function HeaderPanel() { export function HeaderPanel() {
const situation = useReplaySituation() const situation = useReplaySituation()
@@ -12,12 +21,64 @@ export function HeaderPanel() {
const isReplayMode = usePlaybackStore((s) => s.isReplayMode) const isReplayMode = usePlaybackStore((s) => s.isReplayMode)
const { usForces, iranForces } = situation const { usForces, iranForces } = situation
const [now, setNow] = useState(() => new Date()) const [now, setNow] = useState(() => new Date())
const [likes, setLikes] = useState(getStoredLikes)
const [liked, setLiked] = useState(false)
const [viewers, setViewers] = useState(0)
const [cumulative, setCumulative] = useState(0)
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000) const timer = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, []) }, [])
const fetchStats = async () => {
try {
const res = await fetch('/api/visit', { method: 'POST' })
const data = await res.json()
if (data.viewers != null) setViewers(data.viewers)
if (data.cumulative != null) setCumulative(data.cumulative)
} catch {
setViewers((v) => (v > 0 ? v : 0))
setCumulative((c) => (c > 0 ? c : 0))
}
}
useEffect(() => {
fetchStats()
const t = setInterval(fetchStats, 30000)
return () => clearInterval(t)
}, [])
const handleShare = async () => {
const url = window.location.href
const title = '美伊军事态势显示'
if (typeof navigator.share === 'function') {
try {
await navigator.share({ title, url })
} catch (e) {
if ((e as Error).name !== 'AbortError') {
await copyToClipboard(url)
}
}
} else {
await copyToClipboard(url)
}
}
const copyToClipboard = (text: string) => {
return navigator.clipboard?.writeText(text) ?? Promise.resolve()
}
const handleLike = () => {
if (liked) return
setLiked(true)
const next = likes + 1
setLikes(next)
try {
localStorage.setItem(STORAGE_LIKES, String(next))
} catch {}
}
const formatDateTime = (d: Date) => const formatDateTime = (d: Date) =>
d.toLocaleString('zh-CN', { d.toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
@@ -59,14 +120,33 @@ export function HeaderPanel() {
)} )}
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-3"> <div className="flex shrink-0 items-center gap-4">
<Link <div className="flex items-center gap-2 text-military-text-secondary">
to="/db" <Eye className="h-3.5 w-3.5" />
<span className="text-[10px]"> <b className="text-military-accent tabular-nums">{viewers}</b></span>
<span className="text-[10px] opacity-70">|</span>
<span className="text-[10px]"> <b className="tabular-nums">{cumulative}</b></span>
</div>
<button
type="button"
onClick={handleShare}
className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400" className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400"
> >
<Database className="h-3 w-3" /> <Share2 className="h-3 w-3" />
</Link> </button>
<button
type="button"
onClick={handleLike}
className={`flex items-center gap-1 rounded border px-2 py-1 text-[10px] transition-colors ${
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-3 w-3 ${liked ? 'fill-current' : ''}`} />
{likes > 0 && <span className="tabular-nums">{likes}</span>}
</button>
{isConnected ? ( {isConnected ? (
<> <>
<Wifi className="h-3.5 w-3.5 text-green-500" /> <Wifi className="h-3.5 w-3.5 text-green-500" />

View File

@@ -286,6 +286,8 @@ export function WarMap() {
const tick = (t: number) => { const tick = (t: number) => {
const elapsed = t - startRef.current const elapsed = t - startRef.current
const zoom = map.getZoom()
const zoomScale = Math.max(0.5, zoom / 4.2) // 随镜头缩放放大变大、缩小变小4.2 为默认 zoom
try { try {
// 光点从起点飞向目标的循环动画 // 光点从起点飞向目标的循环动画
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
@@ -307,11 +309,11 @@ export function WarMap() {
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003) const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
map.setPaintProperty('points-damaged', 'circle-opacity', blink) map.setPaintProperty('points-damaged', 'circle-opacity', blink)
} }
// attacked: 红色脉冲 2s 循环, 扩散半径 0→40px, opacity 1→0 (map.md) // attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放
if (map.getLayer('points-attacked-pulse')) { if (map.getLayer('points-attacked-pulse')) {
const cycle = 2000 const cycle = 2000
const phase = (elapsed % cycle) / cycle const phase = (elapsed % cycle) / cycle
const r = 40 * phase const r = 40 * phase * zoomScale
const opacity = 1 - phase const opacity = 1 - phase
map.setPaintProperty('points-attacked-pulse', 'circle-radius', r) map.setPaintProperty('points-attacked-pulse', 'circle-radius', r)
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity) map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
@@ -376,11 +378,11 @@ export function WarMap() {
) )
israelSrc.setData({ type: 'FeatureCollection', features }) israelSrc.setData({ type: 'FeatureCollection', features })
} }
// 伊朗被打击目标:蓝色脉冲 (2s 周期) // 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放
if (map.getLayer('allied-strike-targets-pulse')) { if (map.getLayer('allied-strike-targets-pulse')) {
const cycle = 2000 const cycle = 2000
const phase = (elapsed % cycle) / cycle const phase = (elapsed % cycle) / cycle
const r = 35 * phase const r = 35 * phase * zoomScale
const opacity = Math.max(0, 1 - phase * 1.2) const opacity = Math.max(0, 1 - phase * 1.2)
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r) map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity) map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
@@ -390,11 +392,11 @@ export function WarMap() {
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004) const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink) map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
} }
// GDELT 红色 (710):脉冲扩散 // GDELT 红色 (710):脉冲扩散, 半径随 zoom 缩放
if (map.getLayer('gdelt-events-red-pulse')) { if (map.getLayer('gdelt-events-red-pulse')) {
const cycle = 2200 const cycle = 2200
const phase = (elapsed % cycle) / cycle const phase = (elapsed % cycle) / cycle
const r = 30 * phase const r = 30 * phase * zoomScale
const opacity = Math.max(0, 1 - phase * 1.1) const opacity = Math.max(0, 1 - phase * 1.1)
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r) map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity) map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)