diff --git a/crawler/README.md b/crawler/README.md index e9bf003..fc7b711 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -14,16 +14,17 @@ **数据偏老原因**:未传 `timespan` 和 `sort=datedesc`,API 返回 3 个月内“最相关”文章,不保证最新。 -### 2. RSS 新闻 (situation_update) — 主事件脉络来源 +### 2. RSS 新闻 → 看板实时数据(主输出)+ 事件脉络 | 项目 | 说明 | |------|------| -| 源 | 多国主流媒体:美(Reuters/NYT)、英(BBC)、法(France 24)、俄(TASS/RT)、中(Xinhua/CGTN)、伊(Press TV)、卡塔尔(Al Jazeera) | +| **主输出** | **看板实时数据**:战损(combat_losses)、据点状态(key_location)、冲突事件(gdelt_events)、统计(conflict_stats)等,供前端战损/基地/地图等面板展示。 | +| 辅助输出 | 事件脉络(situation_update):时间线摘要,非主展示目标。 | +| 源 | 多国主流媒体:美/英/法/俄/中/伊/卡塔尔等(见 `config.RSS_FEEDS`) | | 过滤 | 标题/摘要需含 `KEYWORDS` 之一(iran、usa、strike、military 等) | -| 更新 | 爬虫 45 秒拉一次(`RSS_INTERVAL_SEC`),优先保证事件脉络 | -| 优先级 | 启动时先拉 RSS,再拉 GDELT | +| 更新 | 爬虫按 `RSS_INTERVAL_SEC` 拉取;每 `BACKFILL_CYCLES` 轮会从近期事件回填一次战损/据点,保证面板数据与最新内容一致。 | -**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS 新闻即可维持事件脉络。部分境外源可能受网络限制。 +**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS;部分境外源可能需代理。 ### 3. AI 新闻清洗与分类(可选) @@ -34,7 +35,7 @@ --- -**事件脉络可实时更新**:爬虫抓取后 → 写入 SQLite → 调用 Node 通知 → WebSocket 广播 → 前端自动刷新。 +**看板实时数据更新**:爬虫抓取 → 提取战损/据点等 → 写入 combat_losses、key_location 等 → 调用 Node 通知 → WebSocket 广播 → 前端战损/基地/地图等面板刷新。事件脉络(时间线)为同一流水线的辅助输出。 ## 依赖 @@ -141,6 +142,7 @@ npm run crawler:test:extraction # 规则/db_merge 测试 | **地图冲突点** (conflictEvents) | gdelt_events 或 RSS→gdelt 回填 | ✅ 是 | GDELT 或 GDELT 禁用时由 situation_update 同步到 gdelt_events | | **战损/装备毁伤** (combatLosses) | combat_losses | ⚠️ 有条件 | 仅当 AI/规则从新闻中提取到数字(如「2 名美军死亡」)时,merge 才写入增量 | | **基地/地点状态** (keyLocations) | key_location | ⚠️ 有条件 | 仅当提取到 key_location_updates(如某基地遭袭)时更新 | +| **地图打击/攻击动画** (mapData.strikeSources, strikeLines) | map_strike_source, map_strike_line | ⚠️ 有条件 | 仅当提取到 map_strike_sources / map_strike_lines 时写入;格式见下「地图打击数据」 | | **力量摘要/指数/资产** (summary, powerIndex, assets) | force_summary, power_index, force_asset | ❌ 否 | 仅 seed 初始化,爬虫不写 | | **华尔街/报复情绪** (wallStreet, retaliation) | wall_street_trend, retaliation_* | ⚠️ 有条件 | 仅当提取器输出对应字段时更新 | @@ -255,6 +257,32 @@ npm run crawler:test --- +## 数据流与 AI 自检 + +**完整链路**:RSS 抓取 → 关键词过滤 → 翻译/清洗 → 去重(news_content)→ 写 situation_update → 正文抓取(可选)→ **AI 提取**(战损/基地等)→ db_merge 写 combat_losses/key_location 等 → POST /api/crawler/notify → Node 重载并广播。 + +| 环节 | 说明 | 自检 | +|------|------|------| +| 抓取 | `scrapers/rss_scraper.fetch_all()`,按 KEYWORDS 过滤 | `npm run crawler:test` 看条数 | +| 去重 | `news_storage.save_and_dedup()`,content_hash 落库 news_content | 查 `news_content` 表条数 | +| 事件脉络 | `db_writer.write_updates()` 写 situation_update(与 pipeline 使用同一 db_path) | 查 `situation_update` 表 | +| AI 提取 | 战损/基地等:**有 DASHSCOPE_API_KEY 用通义**,**否则 CLEANER_AI_DISABLED=1 用规则**,否则用 **Ollama**(extractor_ai) | 见下 | +| 分类/严重度 | 每条 RSS 的 category/severity:**PARSER_AI_DISABLED=1 用规则**,否则 DashScope 或 Ollama | 无 AI 时设 `PARSER_AI_DISABLED=1` 可正常跑 | + +**如何保证「面板实时数据」有更新**(战损、据点等): + +- **推荐**:设 `CLEANER_AI_DISABLED=1` → 使用 `extractor_rules`(纯规则),无需 Ollama/通义,即可从新闻中提取战损/基地并写入 combat_losses、key_location。 +- 或设 `DASHSCOPE_API_KEY` → 用通义做更细的提取。 +- 否则用 `extractor_ai`(需本机 `ollama run llama3.1`),未起则提取静默失败、面板数字不更新。 +- 服务会每 `BACKFILL_CYCLES` 轮(默认 2 轮)从近期事件再跑一次提取并合并,保证战损/据点与最新内容一致。 + +**常见 bug 与修复**: + +- **事件脉络有、战损/基地不更新**:多为 AI 未跑通(Ollama 未起且未设 DashScope、未设 CLEANER_AI_DISABLED)。可设 `CLEANER_AI_DISABLED=1` 用规则提取,或起 Ollama / 配置 DashScope。 +- **多 DB 路径不一致**:pipeline 已统一 `db_path`,`write_updates`、`save_and_dedup`、`merge` 均使用同一 path(或 `config.DB_PATH`)。 + +--- + ## 优化后验证效果示例 以下为「正文抓取 + AI 精确提取 + 增量与地点更新」优化后,单条新闻从输入到前端展示的完整示例,便于对照验证。 @@ -321,6 +349,15 @@ print('key_location_updates:', out.get('key_location_updates')) 期望:`combat_losses_delta.us` 含 personnel_killed=2、personnel_wounded=14、aircraft=1 等增量;`key_location_updates` 含阿萨德 side=us 等条目。 +### 地图打击数据(与前端攻击动画统一格式) + +爬虫/AI 若输出以下字段,`db_merge` 会写入 `map_strike_source`、`map_strike_line`,`GET /api/situation` 的 `mapData.strikeSources` / `mapData.strikeLines` 会更新,前端可直接追加打击线与飞行动画。 + +- **map_strike_sources**(可选):`[{ "id": "israel"|"lincoln"|"ford", "name": "显示名", "lng": 经度, "lat": 纬度 }]`,与 seed 中打击源 id 一致时可覆盖位置。 +- **map_strike_lines**(可选):`[{ "source_id": "israel"|"lincoln"|"ford", "target_lng", "target_lat", "target_name": "目标名", "struck_at": "ISO 时间" }]`,每条追加一条打击线(不删已有),便于按时间回放。 + +示例:`{ "map_strike_lines": [{ "source_id": "israel", "target_lng": 51.916, "target_lat": 33.666, "target_name": "纳坦兹", "struck_at": "2026-03-01T02:04:00.000Z" }] }` + --- ## 冲突强度 (impact_score) diff --git a/crawler/__pycache__/db_writer.cpython-39.pyc b/crawler/__pycache__/db_writer.cpython-39.pyc index f0ac9bc..71c6865 100644 Binary files a/crawler/__pycache__/db_writer.cpython-39.pyc and b/crawler/__pycache__/db_writer.cpython-39.pyc differ diff --git a/crawler/__pycache__/pipeline.cpython-39.pyc b/crawler/__pycache__/pipeline.cpython-39.pyc index 5491a58..463c6ea 100644 Binary files a/crawler/__pycache__/pipeline.cpython-39.pyc and b/crawler/__pycache__/pipeline.cpython-39.pyc differ diff --git a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc index ce16d42..26f43ed 100644 Binary files a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc and b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc differ diff --git a/crawler/db_merge.py b/crawler/db_merge.py index 380d055..910ea29 100644 --- a/crawler/db_merge.py +++ b/crawler/db_merge.py @@ -1,7 +1,12 @@ # -*- coding: utf-8 -*- """ 将 AI 提取的结构化数据合并到 SQLite -与 panel schema 及 situationData.getSituation 对齐,支持回放 +与 panel schema 及 situationData.getSituation 对齐,支持回放。 + +地图打击数据(与前端攻击动画一致): +- map_strike_sources: [{ "id": "israel"|"lincoln"|"ford", "name": "显示名", "lng", "lat" }] 写入 map_strike_source +- map_strike_lines: [{ "source_id", "target_lng", "target_lat", "target_name?", "struck_at?" }] 追加到 map_strike_line +爬虫/AI 可按此格式输出,落库后 GET /api/situation 的 mapData.strikeSources/strikeLines 会更新,前端直接追加攻击动画。 """ import os import sqlite3 @@ -67,6 +72,37 @@ def _ensure_tables(conn: sqlite3.Connection) -> None: conn.execute("CREATE TABLE IF NOT EXISTS retaliation_current (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL)") conn.execute("CREATE TABLE IF NOT EXISTS retaliation_history (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)") conn.execute("CREATE TABLE IF NOT EXISTS situation (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL, updated_at TEXT NOT NULL)") + # 地图打击源与打击线(与 server/db.js 一致),供 getSituation mapData 与前端攻击动画使用 + conn.execute(""" + CREATE TABLE IF NOT EXISTS map_strike_source ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + lng REAL NOT NULL, + lat REAL NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS map_strike_line ( + source_id TEXT NOT NULL, + target_lng REAL NOT NULL, + target_lat REAL NOT NULL, + target_name TEXT, + struck_at TEXT, + FOREIGN KEY (source_id) REFERENCES map_strike_source(id) + ) + """) + try: + conn.execute("CREATE INDEX IF NOT EXISTS idx_map_strike_line_source ON map_strike_line(source_id)") + except sqlite3.OperationalError: + pass + try: + for col in ("struck_at",): + cur = conn.execute("PRAGMA table_info(map_strike_line)") + cols = [r[1] for r in cur.fetchall()] + if col not in cols: + conn.execute(f"ALTER TABLE map_strike_line ADD COLUMN {col} TEXT") + except sqlite3.OperationalError: + pass conn.commit() @@ -183,6 +219,41 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: updated = True except Exception: pass + # map_strike_source:打击源(与前端 mapData.strikeSources 一致),爬虫可补充或覆盖 + if "map_strike_sources" in extracted: + try: + for s in extracted["map_strike_sources"]: + sid = (s.get("id") or "").strip() + name = (s.get("name") or "").strip() or sid + lng = float(s.get("lng", 0)) + lat = float(s.get("lat", 0)) + if sid: + conn.execute( + "INSERT OR REPLACE INTO map_strike_source (id, name, lng, lat) VALUES (?, ?, ?, ?)", + (sid, name[:200], lng, lat), + ) + if conn.total_changes > 0: + updated = True + except Exception: + pass + # map_strike_lines:打击线(与前端 mapData.strikeLines 一致),爬虫可追加新打击,便于前端追加攻击动画 + if "map_strike_lines" in extracted: + try: + for line in extracted["map_strike_lines"]: + source_id = (line.get("source_id") or "").strip() + target_lng = float(line.get("target_lng", 0)) + target_lat = float(line.get("target_lat", 0)) + target_name = (line.get("target_name") or "").strip()[:200] or None + struck_at = (line.get("struck_at") or "").strip() or None + if source_id: + conn.execute( + "INSERT INTO map_strike_line (source_id, target_lng, target_lat, target_name, struck_at) VALUES (?, ?, ?, ?, ?)", + (source_id, target_lng, target_lat, target_name, struck_at), + ) + if conn.total_changes > 0: + updated = True + except Exception: + pass if updated: conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),)) conn.commit() diff --git a/crawler/db_writer.py b/crawler/db_writer.py index 64bb6da..b27f10c 100644 --- a/crawler/db_writer.py +++ b/crawler/db_writer.py @@ -4,6 +4,7 @@ import sqlite3 import hashlib import os from datetime import datetime, timezone +from typing import Optional from config import DB_PATH @@ -73,14 +74,29 @@ def touch_situation_updated_at(conn: sqlite3.Connection) -> None: conn.commit() -def write_updates(updates: list[dict]) -> int: +def touch_situation_updated_at_path(db_path: Optional[str] = None) -> bool: + """仅更新 situation.updated_at 为当前时间(每次爬虫运行都调用,便于前端显示「最后抓取时间」)。返回是否成功。""" + path = db_path or DB_PATH + if not os.path.exists(path): + return False + conn = sqlite3.connect(path, timeout=10) + try: + touch_situation_updated_at(conn) + return True + finally: + conn.close() + + +def write_updates(updates: list[dict], db_path: Optional[str] = None) -> int: """ updates: [{"title","summary","url","published","category","severity"}, ...] + db_path: 与 pipeline 一致,缺省用 config.DB_PATH 返回新增条数。 """ - if not os.path.exists(DB_PATH): + path = db_path or DB_PATH + if not os.path.exists(path): return 0 - conn = sqlite3.connect(DB_PATH, timeout=10) + conn = sqlite3.connect(path, timeout=10) try: count = 0 for u in updates: diff --git a/crawler/pipeline.py b/crawler/pipeline.py index da7d680..a99414f 100644 --- a/crawler/pipeline.py +++ b/crawler/pipeline.py @@ -8,6 +8,7 @@ from datetime import datetime, timezone from typing import Callable, Optional, Tuple from config import DB_PATH, API_BASE +from db_writer import touch_situation_updated_at_path def _notify_api(api_base: str) -> bool: @@ -172,15 +173,18 @@ def run_full_pipeline( except Exception as e: print(f" [warn] 正文抓取: {e}") - # 4. 映射到前端库字段并更新表 - n_panel = write_updates(new_items) if new_items else 0 + # 4. 映射到前端库字段并更新表(与去重/AI 使用同一 db path) + n_panel = write_updates(new_items, db_path=path) if new_items else 0 if new_items: _extract_and_merge(new_items, path) - # 5. 通知(有新增时才通知;可选:先执行外部逻辑如 GDELT 回填,再通知) + # 4.5 每次运行都刷新 situation.updated_at,便于前端显示「最后抓取时间」(否则只有新增条目时才更新,数据会一直停在旧日期) + touch_situation_updated_at_path(db_path=path) + + # 5. 通知(每次运行都通知,让 API 重载并广播最新 lastUpdated) if on_notify: on_notify() - if notify and (n_panel > 0 or n_news > 0): + if notify: _notify_api(base) return len(items), n_news, n_panel diff --git a/crawler/realtime_conflict_service.py b/crawler/realtime_conflict_service.py index abeaab5..aed9cb2 100644 --- a/crawler/realtime_conflict_service.py +++ b/crawler/realtime_conflict_service.py @@ -297,8 +297,43 @@ def _rss_to_gdelt_fallback() -> None: LAST_FETCH = {"items": 0, "inserted": 0, "error": None} +def _refresh_panel_data() -> int: + """从近期事件重新提取并合并战损/据点等面板实时数据,不依赖本轮是否有新 RSS。返回合并条数。""" + if not os.path.exists(DB_PATH): + return 0 + try: + from db_merge import merge + use_dashscope = bool(os.environ.get("DASHSCOPE_API_KEY", "").strip()) + if use_dashscope: + from extractor_dashscope import extract_from_news + elif os.environ.get("CLEANER_AI_DISABLED", "0") == "1": + from extractor_rules import extract_from_news + else: + from extractor_ai import extract_from_news + conn = sqlite3.connect(DB_PATH, timeout=10) + rows = conn.execute( + "SELECT id, timestamp, category, summary FROM situation_update ORDER BY timestamp DESC LIMIT 50" + ).fetchall() + conn.close() + merged = 0 + for r in rows: + uid, ts, cat, summary = r + text = ((cat or "") + " " + (summary or "")).strip() + if len(text) < 20: + continue + try: + extracted = extract_from_news(text, timestamp=ts) + if extracted and merge(extracted, db_path=DB_PATH): + merged += 1 + except Exception: + pass + return merged + except Exception: + return 0 + + def fetch_news() -> None: - """执行完整写库流水线;GDELT 禁用时用 RSS 回填 gdelt_events,再通知 Node。""" + """执行完整写库流水线;产出看板实时数据(战损、据点、冲突事件)+ 事件脉络。GDELT 禁用时用 RSS 回填 gdelt_events。""" try: from pipeline import run_full_pipeline LAST_FETCH["error"] = None @@ -314,7 +349,7 @@ def fetch_news() -> None: _rss_to_gdelt_fallback() _notify_node() ts = datetime.now().strftime("%H:%M:%S") - print(f"[{ts}] RSS 抓取 {n_fetched} 条,去重后新增 {n_news} 条资讯,写入事件脉络 {n_panel} 条") + print(f"[{ts}] 抓取 {n_fetched} 条,去重新增 {n_news} 条,写脉络 {n_panel} 条 → 面板实时数据(战损/据点)已由本批提取更新") if n_fetched == 0: print(f"[{ts}] (0 条:检查网络、RSS 源或 KEYWORDS 过滤)") except Exception as e: @@ -322,6 +357,10 @@ def fetch_news() -> None: print(f"[{datetime.now().strftime('%H:%M:%S')}] 新闻抓取失败: {e}") +# 每 N 轮做一次「从近期事件回填面板实时数据」,保证战损/据点等与最新内容一致 +BACKFILL_CYCLES = int(os.environ.get("BACKFILL_CYCLES", "2")) +_cycle_count = 0 + # ========================== # 定时任务(asyncio 后台任务,避免 APScheduler executor 关闭竞态) # ========================== @@ -329,11 +368,20 @@ _bg_task: Optional[asyncio.Task] = None async def _periodic_fetch() -> None: + global _cycle_count loop = asyncio.get_event_loop() while True: try: await loop.run_in_executor(None, fetch_news) await loop.run_in_executor(None, fetch_gdelt_events) + _cycle_count += 1 + if _cycle_count >= BACKFILL_CYCLES: + _cycle_count = 0 + merged = _refresh_panel_data() + if merged > 0: + _notify_node() + ts = datetime.now().strftime("%H:%M:%S") + print(f"[{ts}] 面板实时数据回填:从近期事件合并 {merged} 条(战损/据点)") except asyncio.CancelledError: break except Exception as e: diff --git a/crawler/scrapers/__pycache__/rss_scraper.cpython-39.pyc b/crawler/scrapers/__pycache__/rss_scraper.cpython-39.pyc index 669cca2..77cdb31 100644 Binary files a/crawler/scrapers/__pycache__/rss_scraper.cpython-39.pyc and b/crawler/scrapers/__pycache__/rss_scraper.cpython-39.pyc differ diff --git a/crawler/scrapers/rss_scraper.py b/crawler/scrapers/rss_scraper.py index b00b30d..0bf1d45 100644 --- a/crawler/scrapers/rss_scraper.py +++ b/crawler/scrapers/rss_scraper.py @@ -88,7 +88,6 @@ def fetch_all() -> list[dict]: if key in seen: continue seen.add(key) - # 写入 DB 的 schema 不包含 source,可后续扩展 - items.append({k: v for k, v in item.items() if k != "source"}) + items.append(item) return items diff --git a/scripts/check-db-and-crawler.sh b/scripts/check-db-and-crawler.sh new file mode 100755 index 0000000..13482b2 --- /dev/null +++ b/scripts/check-db-and-crawler.sh @@ -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 "==========================================" diff --git a/server/data.db-shm b/server/data.db-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/server/data.db-shm differ diff --git a/server/data.db-wal b/server/data.db-wal new file mode 100644 index 0000000..e69de29 diff --git a/server/db.js b/server/db.js index fa6f0fd..f6e94fb 100644 --- a/server/db.js +++ b/server/db.js @@ -174,6 +174,7 @@ function runMigrations(db) { if (!names.includes('region')) exec('ALTER TABLE key_location ADD COLUMN region TEXT') if (!names.includes('status')) exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"') if (!names.includes('damage_level')) exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER') + if (!names.includes('attacked_at')) exec('ALTER TABLE key_location ADD COLUMN attacked_at TEXT') } catch (_) {} try { const lossCols = prepare('PRAGMA table_info(combat_losses)').all() @@ -258,6 +259,38 @@ function runMigrations(db) { INSERT OR IGNORE INTO display_stats (id) VALUES (1); `) } catch (_) {} + try { + const dsCols = prepare('PRAGMA table_info(display_stats)').all() + const dsNames = dsCols.map((c) => c.name) + if (!dsNames.includes('override_enabled')) { + exec('ALTER TABLE display_stats ADD COLUMN override_enabled INTEGER NOT NULL DEFAULT 0') + } + } catch (_) {} + try { + exec(` + CREATE TABLE IF NOT EXISTS map_strike_source ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + lng REAL NOT NULL, + lat REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS map_strike_line ( + source_id TEXT NOT NULL, + target_lng REAL NOT NULL, + target_lat REAL NOT NULL, + target_name TEXT, + struck_at TEXT, + FOREIGN KEY (source_id) REFERENCES map_strike_source(id) + ); + CREATE INDEX IF NOT EXISTS idx_map_strike_line_source ON map_strike_line(source_id); + `) + } catch (_) {} + try { + const lineCols = prepare('PRAGMA table_info(map_strike_line)').all() + if (!lineCols.some((c) => c.name === 'struck_at')) { + exec('ALTER TABLE map_strike_line ADD COLUMN struck_at TEXT') + } + } catch (_) {} } async function initDb() { diff --git a/server/index.js b/server/index.js index e594f75..e38786e 100644 --- a/server/index.js +++ b/server/index.js @@ -92,7 +92,7 @@ if (BROADCAST_INTERVAL_MS > 0) { setInterval(() => broadcastSituation(false), BROADCAST_INTERVAL_MS) } -// 供爬虫调用:先从磁盘重载 DB(纳入爬虫写入),再更新 updated_at 并立即广播 +// 供爬虫调用:先从磁盘重载 DB(纳入爬虫写入),再更新 situation.updated_at 并立即广播;前端据此显示「实时更新」时间 function notifyCrawlerUpdate() { try { const db = require('./db') diff --git a/server/routes.js b/server/routes.js index 2624113..3578b62 100644 --- a/server/routes.js +++ b/server/routes.js @@ -219,14 +219,14 @@ router.get('/edit/raw', (req, res) => { try { const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us') const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran') - const locUs = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('us') - const locIr = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran') + const locUs = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('us') + const locIr = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('iran') const updates = db.prepare('SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT 80').all() const summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us') const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran') let displayStats = null try { - displayStats = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get() + displayStats = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get() } catch (_) {} const realCumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0 const realShare = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0 @@ -244,6 +244,7 @@ router.get('/edit/raw', (req, res) => { situationUpdates: updates || [], forceSummary: { us: summaryUs || null, iran: summaryIr || null }, displayStats: { + overrideEnabled: displayStats?.override_enabled === 1, viewers: displayStats?.viewers ?? liveViewers, cumulative: displayStats?.cumulative ?? realCumulative, shareCount: displayStats?.share_count ?? realShare, @@ -296,7 +297,7 @@ router.patch('/edit/key-location/:id', (req, res) => { if (!Number.isFinite(id)) return res.status(400).json({ error: 'invalid id' }) const row = db.prepare('SELECT id FROM key_location WHERE id = ?').get(id) if (!row) return res.status(404).json({ error: 'key_location not found' }) - const allowed = ['name', 'lat', 'lng', 'type', 'region', 'status', 'damage_level'] + const allowed = ['name', 'lat', 'lng', 'type', 'region', 'status', 'damage_level', 'attacked_at'] const updates = [] const values = [] for (const k of allowed) { @@ -383,14 +384,31 @@ router.put('/edit/force-summary', (req, res) => { } }) -/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除覆盖、改用实时统计 */ +/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除该字段;clearOverride=true 或全部为 null 时关闭覆盖、恢复实时统计 */ router.put('/edit/display-stats', (req, res) => { try { db.prepare('INSERT OR IGNORE INTO display_stats (id) VALUES (1)').run() + const body = req.body || {} + const clearOverride = body.clearOverride === true || body.clearOverride === 'true' + const viewers = body.viewers + const cumulative = body.cumulative + const shareCount = body.shareCount + const likeCount = body.likeCount + const feedbackCount = body.feedbackCount + const allNull = viewers === null && cumulative === null && shareCount === null && likeCount === null && feedbackCount === null + + if (clearOverride || allNull) { + db.prepare( + 'UPDATE display_stats SET override_enabled = 0, viewers = NULL, cumulative = NULL, share_count = NULL, like_count = NULL, feedback_count = NULL WHERE id = 1' + ).run() + broadcastAfterEdit(req) + return res.json({ ok: true }) + } + const updates = [] const values = [] const setField = (key, bodyKey) => { - const v = req.body?.[bodyKey ?? key] + const v = body[bodyKey ?? key] if (v === undefined) return if (v === null) { updates.push(`${key} = ?`) @@ -408,6 +426,8 @@ router.put('/edit/display-stats', (req, res) => { setField('like_count', 'likeCount') setField('feedback_count', 'feedbackCount') if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' }) + updates.push('override_enabled = ?') + values.push(1) values.push(1) db.prepare(`UPDATE display_stats SET ${updates.join(', ')} WHERE id = ?`).run(...values) broadcastAfterEdit(req) diff --git a/server/seed.js b/server/seed.js index 2c96344..60801d7 100644 --- a/server/seed.js +++ b/server/seed.js @@ -3,84 +3,84 @@ const db = require('./db') // 与 src/data/mapLocations.ts 同步:62 基地,27 被袭 (严重6 中度12 轻度9) function getUsLocations() { const naval = [ - { name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', region: '北阿拉伯海', status: 'operational', damage_level: null }, - { name: '福特号航母 (CVN-78)', lat: 35.7397, lng: 24.1002, type: 'Aircraft Carrier', region: '东地中海', status: 'operational', damage_level: null }, - { name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational', damage_level: null }, - { name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null }, - { name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null }, - { name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational', damage_level: null }, - { name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null }, - { name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null }, - { name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null }, - { name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null }, - { name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null }, - { name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null }, + { name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', region: '北阿拉伯海', status: 'operational', damage_level: null, attacked_at: null }, + { name: '福特号航母 (CVN-78)', lat: 35.7397, lng: 24.1002, type: 'Aircraft Carrier', region: '东地中海', status: 'operational', damage_level: null, attacked_at: null }, + { name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null }, + { name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null }, ] const attacked = [ - { name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 }, - { name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 }, - { name: '乌代德空军基地', lat: 25.117, lng: 51.314, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 3 }, - { name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 }, - { name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 3 }, - { name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, type: 'Base', region: '沙特', status: 'attacked', damage_level: 3 }, - { name: '塔吉军营', lat: 33.556, lng: 44.256, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2 }, - { name: '阿因·阿萨德', lat: 33.8, lng: 42.45, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2 }, - { name: '坦夫驻军', lat: 33.49, lng: 38.618, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 }, - { name: '沙达迪基地', lat: 36.058, lng: 40.73, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 }, - { name: '康诺克气田基地', lat: 35.336, lng: 40.295, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 }, - { name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 }, - { name: '阿里夫坚军营', lat: 28.832, lng: 47.799, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2 }, - { name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2 }, - { name: '巴林海军支援站', lat: 26.236, lng: 50.608, type: 'Base', region: '巴林', status: 'attacked', damage_level: 2 }, - { name: '达夫拉空军基地', lat: 24.248, lng: 54.547, type: 'Base', region: '阿联酋', status: 'attacked', damage_level: 2 }, - { name: '埃斯康村', lat: 24.774, lng: 46.738, type: 'Base', region: '沙特', status: 'attacked', damage_level: 2 }, - { name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, type: 'Base', region: '以色列', status: 'attacked', damage_level: 2 }, - { name: '布林军营', lat: 29.603, lng: 47.456, type: 'Base', region: '科威特', status: 'attacked', damage_level: 1 }, - { name: '赛利耶军营', lat: 25.275, lng: 51.52, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 1 }, - { name: '拉蒙空军基地', lat: 30.776, lng: 34.666, type: 'Base', region: '以色列', status: 'attacked', damage_level: 1 }, - { name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, type: 'Base', region: '约旦', status: 'attacked', damage_level: 1 }, - { name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 1 }, - { name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1 }, - { name: '马西拉空军基地', lat: 20.675, lng: 58.89, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1 }, - { name: '西开罗空军基地', lat: 30.915, lng: 30.298, type: 'Base', region: '埃及', status: 'attacked', damage_level: 1 }, - { name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, type: 'Base', region: '吉布提', status: 'attacked', damage_level: 1 }, + { name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:00:00.000Z' }, + { name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:15:00.000Z' }, + { name: '乌代德空军基地', lat: 25.117, lng: 51.314, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:30:00.000Z' }, + { name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:45:00.000Z' }, + { name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T07:00:00.000Z' }, + { name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, type: 'Base', region: '沙特', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T07:15:00.000Z' }, + { name: '塔吉军营', lat: 33.556, lng: 44.256, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T07:30:00.000Z' }, + { name: '阿因·阿萨德', lat: 33.8, lng: 42.45, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T07:45:00.000Z' }, + { name: '坦夫驻军', lat: 33.49, lng: 38.618, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:00:00.000Z' }, + { name: '沙达迪基地', lat: 36.058, lng: 40.73, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:15:00.000Z' }, + { name: '康诺克气田基地', lat: 35.336, lng: 40.295, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:30:00.000Z' }, + { name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:45:00.000Z' }, + { name: '阿里夫坚军营', lat: 28.832, lng: 47.799, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:00:00.000Z' }, + { name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:15:00.000Z' }, + { name: '巴林海军支援站', lat: 26.236, lng: 50.608, type: 'Base', region: '巴林', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:30:00.000Z' }, + { name: '达夫拉空军基地', lat: 24.248, lng: 54.547, type: 'Base', region: '阿联酋', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:45:00.000Z' }, + { name: '埃斯康村', lat: 24.774, lng: 46.738, type: 'Base', region: '沙特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T10:00:00.000Z' }, + { name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, type: 'Base', region: '以色列', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T10:15:00.000Z' }, + { name: '布林军营', lat: 29.603, lng: 47.456, type: 'Base', region: '科威特', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T10:30:00.000Z' }, + { name: '赛利耶军营', lat: 25.275, lng: 51.52, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T10:45:00.000Z' }, + { name: '拉蒙空军基地', lat: 30.776, lng: 34.666, type: 'Base', region: '以色列', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:00:00.000Z' }, + { name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, type: 'Base', region: '约旦', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:15:00.000Z' }, + { name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:30:00.000Z' }, + { name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:45:00.000Z' }, + { name: '马西拉空军基地', lat: 20.675, lng: 58.89, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:00:00.000Z' }, + { name: '西开罗空军基地', lat: 30.915, lng: 30.298, type: 'Base', region: '埃及', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:15:00.000Z' }, + { name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, type: 'Base', region: '吉布提', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:30:00.000Z' }, ] const newBases = [ - { name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null }, - { name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林', status: 'operational', damage_level: null }, - { name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特', status: 'operational', damage_level: null }, - { name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特', status: 'operational', damage_level: null }, - { name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null }, - { name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null }, - { name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特', status: 'operational', damage_level: null }, - { name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特', status: 'operational', damage_level: null }, - { name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特', status: 'operational', damage_level: null }, - { name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null }, - { name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null }, - { name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null }, - { name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null }, - { name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null }, - { name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null }, - { name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦', status: 'operational', damage_level: null }, - { name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其', status: 'operational', damage_level: null }, - { name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列', status: 'operational', damage_level: null }, - { name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼', status: 'operational', damage_level: null }, - { name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼', status: 'operational', damage_level: null }, - { name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及', status: 'operational', damage_level: null }, - { name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及', status: 'operational', damage_level: null }, - { name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提', status: 'operational', damage_level: null }, - { name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null }, - { name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特', status: 'operational', damage_level: null }, - { name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null }, - { name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null }, - { name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦', status: 'operational', damage_level: null }, - { name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其', status: 'operational', damage_level: null }, - { name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列', status: 'operational', damage_level: null }, - { name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼', status: 'operational', damage_level: null }, - { name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及', status: 'operational', damage_level: null }, - { name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提', status: 'operational', damage_level: null }, - { name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null }, - { name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null }, + { name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null, attacked_at: null }, + { name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林', status: 'operational', damage_level: null, attacked_at: null }, + { name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特', status: 'operational', damage_level: null, attacked_at: null }, + { name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特', status: 'operational', damage_level: null, attacked_at: null }, + { name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null }, + { name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null }, + { name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null }, + { name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null }, + { name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null }, + { name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null }, + { name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null }, + { name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null }, + { name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null }, + { name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null }, + { name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null }, + { name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦', status: 'operational', damage_level: null, attacked_at: null }, + { name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其', status: 'operational', damage_level: null, attacked_at: null }, + { name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列', status: 'operational', damage_level: null, attacked_at: null }, + { name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null }, + { name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null }, + { name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null }, + { name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null }, + { name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提', status: 'operational', damage_level: null, attacked_at: null }, + { name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null, attacked_at: null }, + { name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null }, + { name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null }, + { name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null }, + { name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦', status: 'operational', damage_level: null, attacked_at: null }, + { name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其', status: 'operational', damage_level: null, attacked_at: null }, + { name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列', status: 'operational', damage_level: null, attacked_at: null }, + { name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null }, + { name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null }, + { name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提', status: 'operational', damage_level: null, attacked_at: null }, + { name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null }, + { name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null }, ] return [...naval, ...attacked, ...newBases] } @@ -126,27 +126,74 @@ function seed() { ;[...usAssets, ...iranAssets].forEach((row) => insertAsset.run(...row)) const insertLoc = db.prepare(` - INSERT INTO key_location (side, name, lat, lng, type, region, status, damage_level) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO key_location (side, name, lat, lng, type, region, status, damage_level, attacked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `) db.exec('DELETE FROM key_location') for (const loc of getUsLocations()) { - insertLoc.run('us', loc.name, loc.lat, loc.lng, loc.type, loc.region, loc.status, loc.damage_level) + insertLoc.run('us', loc.name, loc.lat, loc.lng, loc.type, loc.region, loc.status, loc.damage_level, loc.attacked_at ?? null) } + // 盟军先打击伊朗(2月28日 02:00–04:00),回放时先出现 const iranLocs = [ - ['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Port', '伊朗', 'attacked', 3], - ['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3], - ['iran', '布什尔核电站', 28.9681, 50.838, 'Nuclear', '伊朗', 'attacked', 2], - ['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2], - ['iran', '纳坦兹铀浓缩', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2], - ['iran', '米纳布岸防', 27.13, 57.08, 'Base', '伊朗', 'damaged', 2], - ['iran', '卡拉季无人机厂', 35.808, 51.002, 'Base', '伊朗', 'attacked', 2], - ['iran', '克尔曼沙赫导弹阵地', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2], - ['iran', '大不里士空军基地', 38.08, 46.29, 'Base', '伊朗', 'damaged', 1], - ['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Base', '伊朗', 'operational', null], + ['iran', '阿巴斯港', 27.1832, 56.2666, 'Port', '伊朗', 'operational', null, null], + ['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Naval', '伊朗', 'attacked', 3, '2026-02-28T02:15:00.000Z'], + ['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3, '2026-02-28T02:00:00.000Z'], + ['iran', '哈梅内伊官邸', 35.69, 51.42, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:30:00.000Z'], + ['iran', '总统府/情报部', 35.72, 51.41, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:32:00.000Z'], + ['iran', '梅赫拉巴德机场', 35.69, 51.15, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:34:00.000Z'], + ['iran', '库姆', 34.64, 50.88, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:00:00.000Z'], + ['iran', '伊朗专家会议秘书处', 34.625448, 50.876409, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:02:00.000Z'], + ['iran', '布什尔', 28.9681, 50.838, 'Base', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'], + ['iran', '布什尔雷达站', 28.968, 50.838, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'], + ['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:22:00.000Z'], + ['iran', '纳坦兹', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:04:00.000Z'], + ['iran', '卡拉季无人机厂', 35.808, 51.002, 'UAV', '伊朗', 'attacked', 2, '2026-02-28T02:06:00.000Z'], + ['iran', '克尔曼沙赫导弹掩体', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:36:00.000Z'], + ['iran', '大不里士空军基地', 38.08, 46.29, 'Missile', '伊朗', 'damaged', 1, '2026-02-28T02:38:00.000Z'], + ['iran', '伊拉姆导弹阵地', 33.64, 46.42, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:40:00.000Z'], + ['iran', '霍拉马巴德储备库', 33.48, 48.35, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:42:00.000Z'], + ['iran', '米纳布', 27.13, 57.08, 'Naval', '伊朗', 'damaged', 2, '2026-02-28T02:18:00.000Z'], + ['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Naval', '伊朗', 'operational', null, null], ] iranLocs.forEach((r) => insertLoc.run(...r)) + const insertStrikeSource = db.prepare('INSERT OR REPLACE INTO map_strike_source (id, name, lng, lat) VALUES (?, ?, ?, ?)') + const strikeSources = [ + ['israel', '以色列', 34.78, 32.08], + ['lincoln', '林肯号航母', 58.4215, 24.1568], + ['ford', '福特号航母', 24.1002, 35.7397], + ] + strikeSources.forEach((r) => insertStrikeSource.run(...r)) + + const insertStrikeLine = db.prepare('INSERT INTO map_strike_line (source_id, target_lng, target_lat, target_name, struck_at) VALUES (?, ?, ?, ?, ?)') + db.prepare('DELETE FROM map_strike_line').run() + // 盟军先打击伊朗(2月28日 02:00–04:00),随后伊朗反击(06:00–12:00) + const israelTargets = [ + [50.88, 34.64, '库姆', '2026-02-28T02:00:00.000Z'], + [50.876409, 34.625448, '伊朗专家会议秘书处', '2026-02-28T02:02:00.000Z'], + [51.916, 33.666, '纳坦兹', '2026-02-28T02:04:00.000Z'], + [51.002, 35.808, '卡拉季无人机厂', '2026-02-28T02:06:00.000Z'], + ] + const lincolnTargets = [ + [56.27, 27.18, '阿巴斯港海军司令部', '2026-02-28T02:15:00.000Z'], + [57.08, 27.13, '米纳布', '2026-02-28T02:18:00.000Z'], + [56.5, 27.0, '霍尔木兹岸防阵地', '2026-02-28T02:19:00.000Z'], + [50.838, 28.968, '布什尔雷达站', '2026-02-28T02:20:00.000Z'], + [51.667, 32.654, '伊斯法罕核设施', '2026-02-28T02:22:00.000Z'], + ] + const fordTargets = [ + [51.42, 35.69, '哈梅内伊官邸', '2026-02-28T02:30:00.000Z'], + [51.41, 35.72, '总统府/情报部', '2026-02-28T02:32:00.000Z'], + [51.15, 35.69, '梅赫拉巴德机场', '2026-02-28T02:34:00.000Z'], + [46.29, 38.08, '大不里士空军基地', '2026-02-28T02:38:00.000Z'], + [47.076, 34.314, '克尔曼沙赫导弹掩体', '2026-02-28T02:36:00.000Z'], + [46.42, 33.64, '伊拉姆导弹阵地', '2026-02-28T02:40:00.000Z'], + [48.35, 33.48, '霍拉马巴德储备库', '2026-02-28T02:42:00.000Z'], + ] + israelTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('israel', lng, lat, name, struckAt)) + lincolnTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('lincoln', lng, lat, name, struckAt)) + fordTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('ford', lng, lat, name, struckAt)) + try { db.exec(` INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, aircraft, warships, armor, vehicles, drones, missiles, helicopters, submarines, tanks, carriers, civilian_ships, airport_port) VALUES diff --git a/server/situationData.js b/server/situationData.js index 85b5d95..b59a145 100644 --- a/server/situationData.js +++ b/server/situationData.js @@ -54,14 +54,33 @@ function getSituation() { const powerIr = db.prepare('SELECT * FROM power_index WHERE side = ?').get('iran') const assetsUs = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('us') const assetsIr = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('iran') - const locUs = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('us') - const locIr = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran') + const locUs = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('us') + const locIr = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('iran') + let mapStrikeSources = [] + let mapStrikeLines = [] + try { + mapStrikeSources = db.prepare('SELECT id, name, lng, lat FROM map_strike_source').all() + const lines = db.prepare('SELECT source_id, target_lng, target_lat, target_name, struck_at FROM map_strike_line ORDER BY source_id, struck_at').all() + const bySource = {} + for (const row of lines) { + if (!bySource[row.source_id]) bySource[row.source_id] = [] + bySource[row.source_id].push({ + lng: row.target_lng, + lat: row.target_lat, + name: row.target_name || '', + struck_at: row.struck_at || null, + }) + } + mapStrikeLines = Object.entries(bySource).map(([sourceId, targets]) => ({ sourceId, targets })) + } catch (_) {} + const attackedTargets = (locUs || []).filter((l) => l.status === 'attacked').map((l) => [l.lng, l.lat]) const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us') const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran') const trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all() const retaliationCur = db.prepare('SELECT value FROM retaliation_current WHERE id = 1').get() const retaliationHist = db.prepare('SELECT time, value FROM retaliation_history ORDER BY time').all() const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all() + // 数据更新时间:与前端「实时更新」一致,仅在爬虫 notify / 编辑保存时由 index.js 或 routes 更新 const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get() let conflictEvents = [] @@ -155,8 +174,12 @@ function getSituation() { })), conflictStats, civilianCasualtiesTotal, - // 顶层聚合,便于 sit.combatLosses.us / sit.combatLosses.iran 与 usForces/iranForces 内保持一致 combatLosses: { us: usLosses, iran: irLosses }, + mapData: { + attackedTargets, + strikeSources: mapStrikeSources, + strikeLines: mapStrikeLines, + }, } } diff --git a/server/stats.js b/server/stats.js index 51fc3f7..e519818 100644 --- a/server/stats.js +++ b/server/stats.js @@ -24,9 +24,10 @@ function getStats() { let likeCount = realLikeCount let display = null try { - display = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get() + display = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get() } catch (_) {} - if (display) { + const useOverride = display && display.override_enabled === 1 + if (useOverride && display) { if (display.viewers != null) viewers = toNum(display.viewers) if (display.cumulative != null) cumulative = toNum(display.cumulative) if (display.share_count != null) shareCount = toNum(display.share_count) diff --git a/src/api/edit.ts b/src/api/edit.ts index abad7f4..210a30f 100644 --- a/src/api/edit.ts +++ b/src/api/edit.ts @@ -55,6 +55,7 @@ export interface ForceSummaryRow { } export interface DisplayStatsRow { + overrideEnabled?: boolean viewers: number cumulative: number shareCount: number @@ -140,7 +141,10 @@ export async function putForceSummary(side: 'us' | 'iran', body: Partial): Promise { +/** 传 clearOverride: true 可关闭覆盖、恢复实时统计 */ +export async function putDisplayStats( + body: Partial<{ [K in keyof DisplayStatsRow]: number | null }> & { clearOverride?: boolean } +): Promise { const res = await fetch('/api/edit/display-stats', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/src/components/HeaderPanel.tsx b/src/components/HeaderPanel.tsx index 4c9fdf8..2a06708 100644 --- a/src/components/HeaderPanel.tsx +++ b/src/components/HeaderPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { StatCard } from './StatCard' import { useSituationStore } from '@/store/situationStore' import { useStatsStore } from '@/store/statsStore' @@ -39,6 +39,8 @@ export function HeaderPanel() { const [now, setNow] = useState(() => new Date()) const [likes, setLikes] = useState(getStoredLikes) const [liked, setLiked] = useState(false) + const [likeBurst, setLikeBurst] = useState(0) + const pendingLikesRef = useRef(0) const stats = useStatsStore((s) => s.stats) const setStats = useStatsStore((s) => s.setStats) const viewers = stats.viewers ?? 0 @@ -140,10 +142,7 @@ export function HeaderPanel() { } } - const handleLike = async () => { - if (liked || likeSending) return - setLiked(true) - setLikeSending(true) + const sendOneLike = async () => { try { const res = await fetch('/api/like', { method: 'POST' }) const data = await res.json() @@ -159,21 +158,28 @@ export function HeaderPanel() { try { localStorage.setItem(STORAGE_LIKES, String(data.likeCount)) } catch {} - } else { - const next = likes + 1 - setLikes(next) - try { - localStorage.setItem(STORAGE_LIKES, String(next)) - } catch {} } } catch { - const next = likes + 1 - setLikes(next) - try { - localStorage.setItem(STORAGE_LIKES, String(next)) - } catch {} + setLikes((prev) => Math.max(0, prev - 1)) } finally { - setLikeSending(false) + if (pendingLikesRef.current > 0) { + pendingLikesRef.current -= 1 + sendOneLike() + } else { + setLikeSending(false) + } + } + } + + const handleLike = () => { + setLiked(true) + setLikes((prev) => (serverLikeCount ?? prev) + 1) + setLikeBurst((n) => n + 1) + if (!likeSending) { + setLikeSending(true) + sendOneLike() + } else { + pendingLikesRef.current += 1 } } @@ -213,9 +219,14 @@ export function HeaderPanel() { {formatDateTime(now)} - {(isConnected || isReplayMode) && ( - - {formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'} + {/* 非回放时显示数据更新时间,与后端 situation.updated_at 一致(爬虫 notify / 编辑保存时后端更新并广播) */} + {isReplayMode ? ( + + {formatDataTime(situation.lastUpdated)} (回放) + + ) : ( + + {formatDataTime(situation.lastUpdated)} (实时更新) )} @@ -246,14 +257,25 @@ export function HeaderPanel() { diff --git a/src/components/RetaliationGauge.tsx b/src/components/RetaliationGauge.tsx index ef52093..388c94f 100644 --- a/src/components/RetaliationGauge.tsx +++ b/src/components/RetaliationGauge.tsx @@ -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 }], }, diff --git a/src/components/TimelinePanel.tsx b/src/components/TimelinePanel.tsx index c3ec0b7..884ed62 100644 --- a/src/components/TimelinePanel.tsx +++ b/src/components/TimelinePanel.tsx @@ -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 | 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) => { const i = parseInt(e.target.value, 10) - setPlaybackTime(REPLAY_TICKS[i]) + setPlaybackTime(replayTicks[i]) } + const scaleLabels: Record = { '30m': '30分钟', '1h': '1小时', '1d': '1天' } + return (
{!isReplayMode && ( @@ -142,7 +149,7 @@ export function TimelinePanel() {
-
+
+ 时间刻度 + +
+ +
-
- -
- {formatTick(REPLAY_START)} - {formatTick(playbackTime)} - {formatTick(REPLAY_END)} + {/* 按刻度划分的时间轴:均匀取约 5~6 个刻度标签 */} +
+ {replayTicks.length <= 1 ? ( + {formatTick(replayTicks[0] ?? REPLAY_START)} + ) : ( + (() => { + 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) => ( + {formatTick(replayTicks[i])} + )) + })() + )} +