fix:增面

This commit is contained in:
Daniel
2026-03-03 22:42:21 +08:00
parent 09ec2e3a69
commit 86e50debec
13 changed files with 1486 additions and 0 deletions

269
docs/DEBUG_PANELS.md Normal file
View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,131 @@
/** 手动修正看板数据 API */
export interface CombatLossesRow {
side: string
bases_destroyed: number
bases_damaged: number
personnel_killed: number
personnel_wounded: number
civilian_killed?: number
civilian_wounded?: number
aircraft: number
warships: number
armor: number
vehicles: number
drones?: number
missiles?: number
helicopters?: number
submarines?: number
tanks?: number
carriers?: number
civilian_ships?: number
airport_port?: number
}
export interface KeyLocationRow {
id: number
side: string
name: string
lat: number
lng: number
type?: string | null
region?: string | null
status?: string | null
damage_level?: number | null
}
export interface SituationUpdateRow {
id: string
timestamp: string
category: string
summary: string
severity: string
}
export interface ForceSummaryRow {
side: string
total_assets: number
personnel: number
naval_ships: number
aircraft: number
ground_units: number
uav: number
missile_consumed: number
missile_stock: number
}
export interface EditRawData {
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
situationUpdates: SituationUpdateRow[]
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
}
export async function fetchEditRaw(): Promise<EditRawData> {
const res = await fetch('/api/edit/raw', { cache: 'no-store' })
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function putCombatLosses(side: 'us' | 'iran', body: Partial<CombatLossesRow>): Promise<void> {
const res = await fetch('/api/edit/combat-losses', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ side, ...body }),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function patchKeyLocation(id: number, body: Partial<KeyLocationRow>): Promise<void> {
const res = await fetch(`/api/edit/key-location/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function postSituationUpdate(body: {
id?: string
timestamp?: string
category: string
summary: string
severity?: string
}): Promise<{ id: string }> {
const res = await fetch('/api/edit/situation-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
return res.json()
}
export async function deleteSituationUpdate(id: string): Promise<void> {
const res = await fetch(`/api/edit/situation-update/${encodeURIComponent(id)}`, { method: 'DELETE' })
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function putForceSummary(side: 'us' | 'iran', body: Partial<ForceSummaryRow>): Promise<void> {
const res = await fetch('/api/edit/force-summary', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ side, ...body }),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}

552
src/pages/EditDashboard.tsx Normal file
View File

@@ -0,0 +1,552 @@
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft, RefreshCw, Save, Trash2, Plus, ChevronDown, ChevronRight } from 'lucide-react'
import {
fetchEditRaw,
putCombatLosses,
patchKeyLocation,
postSituationUpdate,
deleteSituationUpdate,
putForceSummary,
type EditRawData,
type CombatLossesRow,
type KeyLocationRow,
type SituationUpdateRow,
type ForceSummaryRow,
} from '@/api/edit'
import { fetchAndSetSituation } from '@/store/situationStore'
const LOSS_FIELDS: { key: keyof CombatLossesRow; label: string }[] = [
{ key: 'bases_destroyed', label: '基地摧毁' },
{ key: 'bases_damaged', label: '基地受损' },
{ key: 'personnel_killed', label: '人员阵亡' },
{ key: 'personnel_wounded', label: '人员受伤' },
{ key: 'civilian_killed', label: '平民死亡' },
{ key: 'civilian_wounded', label: '平民受伤' },
{ key: 'aircraft', label: '飞机' },
{ key: 'warships', label: '军舰' },
{ key: 'armor', label: '装甲' },
{ key: 'vehicles', label: '车辆' },
{ key: 'drones', label: '无人机' },
{ key: 'missiles', label: '导弹' },
{ key: 'helicopters', label: '直升机' },
{ key: 'submarines', label: '潜艇' },
{ key: 'carriers', label: '航母' },
{ key: 'civilian_ships', label: '民船' },
{ key: 'airport_port', label: '机场/港口' },
]
const SUMMARY_FIELDS: { key: keyof ForceSummaryRow; label: string }[] = [
{ key: 'total_assets', label: '总资产' },
{ key: 'personnel', label: '人员' },
{ key: 'naval_ships', label: '舰艇' },
{ key: 'aircraft', label: '飞机' },
{ key: 'ground_units', label: '地面单位' },
{ key: 'uav', label: '无人机' },
{ key: 'missile_consumed', label: '导弹消耗' },
{ key: 'missile_stock', label: '导弹库存' },
]
const CATEGORIES = ['deployment', 'alert', 'intel', 'diplomatic', 'other'] as const
const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
const STATUS_OPTIONS = ['operational', 'damaged', 'attacked'] as const
function num(v: unknown): number {
if (v === null || v === undefined) return 0
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
export function EditDashboard() {
const [data, setData] = useState<EditRawData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<string | null>(null)
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['losses', 'updates']))
const [newUpdate, setNewUpdate] = useState({ category: 'other', summary: '', severity: 'medium' })
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const raw = await fetchEditRaw()
setData(raw)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const toggleSection = (id: string) => {
setOpenSections((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const afterSave = async () => {
await load()
await fetchAndSetSituation()
}
const handleSaveLosses = async (side: 'us' | 'iran', row: CombatLossesRow | null) => {
if (!row) return
setSaving(`losses-${side}`)
try {
await putCombatLosses(side, row)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleSaveKeyLocation = async (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => {
setSaving(`loc-${loc.id}`)
try {
await patchKeyLocation(loc.id, patch)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleAddUpdate = async () => {
if (!newUpdate.summary.trim()) return
setSaving('add-update')
try {
await postSituationUpdate({
timestamp: new Date().toISOString(),
category: newUpdate.category,
summary: newUpdate.summary.trim(),
severity: newUpdate.severity,
})
setNewUpdate({ category: 'other', summary: '', severity: 'medium' })
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '添加失败')
} finally {
setSaving(null)
}
}
const handleDeleteUpdate = async (id: string) => {
if (!confirm('确定删除这条事件?')) return
setSaving(`del-${id}`)
try {
await deleteSituationUpdate(id)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '删除失败')
} finally {
setSaving(null)
}
}
const handleSaveForceSummary = async (side: 'us' | 'iran', row: ForceSummaryRow | null) => {
if (!row) return
setSaving(`summary-${side}`)
try {
await putForceSummary(side, row)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
if (loading && !data) {
return (
<div className="flex min-h-screen items-center justify-center bg-military-dark text-military-text-secondary">
<RefreshCw className="h-6 w-6 animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-military-dark font-orbitron text-military-text-primary">
<header className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b border-military-border bg-military-panel/95 px-4 py-3">
<div className="flex items-center gap-4">
<Link
to="/"
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 hover:text-military-text-primary"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="flex items-center gap-2 text-lg text-cyan-400">
</span>
</div>
<button
onClick={load}
disabled={loading}
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</header>
{error && (
<div className="mx-4 mt-4 rounded border border-amber-600/50 bg-amber-950/30 px-4 py-2 text-amber-400">
{error}
<button type="button" className="ml-2 underline" onClick={() => setError(null)}></button>
</div>
)}
<main className="max-w-4xl space-y-2 p-4">
{/* 战损 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('losses')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / </span>
{openSections.has('losses') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('losses') && data && (
<div className="border-t border-military-border p-4 space-y-6">
{(['us', 'iran'] as const).map((side) => {
const row = data.combatLosses[side]
if (!row) return <p key={side} className="text-military-text-secondary text-sm"></p>
return (
<LossForm
key={side}
side={side}
row={row}
onSave={(updated) => handleSaveLosses(side, updated)}
saving={saving === `losses-${side}`}
/>
)
})}
</div>
)}
</section>
{/* 美军据点 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('loc-us')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-military-us"></span>
{openSections.has('loc-us') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('loc-us') && data && (
<div className="border-t border-military-border p-4">
<KeyLocationList
list={data.keyLocations.us}
onSave={handleSaveKeyLocation}
savingId={saving}
/>
</div>
)}
</section>
{/* 伊朗据点 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('loc-iran')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-military-iran"></span>
{openSections.has('loc-iran') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('loc-iran') && data && (
<div className="border-t border-military-border p-4">
<KeyLocationList
list={data.keyLocations.iran}
onSave={handleSaveKeyLocation}
savingId={saving}
/>
</div>
)}
</section>
{/* 事件脉络 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('updates')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"></span>
{openSections.has('updates') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('updates') && data && (
<div className="border-t border-military-border p-4 space-y-4">
<div className="flex flex-wrap gap-2 items-end rounded border border-military-border/50 bg-military-dark/50 p-3">
<select
value={newUpdate.category}
onChange={(e) => setNewUpdate((u) => ({ ...u, category: e.target.value }))}
className="rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
value={newUpdate.severity}
onChange={(e) => setNewUpdate((u) => ({ ...u, severity: e.target.value }))}
className="rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
>
{SEVERITIES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<input
type="text"
placeholder="摘要"
value={newUpdate.summary}
onChange={(e) => setNewUpdate((u) => ({ ...u, summary: e.target.value }))}
className="min-w-[200px] flex-1 rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
/>
<button
type="button"
onClick={handleAddUpdate}
disabled={saving === 'add-update' || !newUpdate.summary.trim()}
className="flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
<ul className="space-y-2 max-h-64 overflow-y-auto">
{data.situationUpdates.map((u) => (
<li
key={u.id}
className="flex items-start gap-2 rounded border border-military-border/50 bg-military-dark/30 px-3 py-2 text-sm"
>
<span className="shrink-0 text-military-text-secondary text-xs">
{u.timestamp.slice(0, 19).replace('T', ' ')}
</span>
<span className="shrink-0 rounded bg-military-border/50 px-1.5 py-0.5 text-xs">{u.category}</span>
<span className="shrink-0 rounded bg-amber-900/40 px-1.5 py-0.5 text-xs">{u.severity}</span>
<span className="min-w-0 flex-1 truncate" title={u.summary}>{u.summary}</span>
<button
type="button"
onClick={() => handleDeleteUpdate(u.id)}
disabled={String(saving).startsWith('del-')}
className="shrink-0 rounded p-1 text-red-400 hover:bg-red-950/50 disabled:opacity-50"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
</div>
)}
</section>
{/* 军力概要 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('summary')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / </span>
{openSections.has('summary') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('summary') && data && (
<div className="border-t border-military-border p-4 space-y-6">
{(['us', 'iran'] as const).map((side) => {
const row = data.forceSummary[side]
if (!row) return <p key={side} className="text-military-text-secondary text-sm"></p>
return (
<ForceSummaryForm
key={side}
side={side}
row={row}
onSave={(updated) => handleSaveForceSummary(side, updated)}
saving={saving === `summary-${side}`}
/>
)
})}
</div>
)}
</section>
</main>
</div>
)
}
function LossForm({
side,
row,
onSave,
saving,
}: {
side: 'us' | 'iran'
row: CombatLossesRow
onSave: (row: CombatLossesRow) => void
saving: boolean
}) {
const [edit, setEdit] = useState<CombatLossesRow>({ ...row })
useEffect(() => {
setEdit({ ...row })
}, [row])
const sideLabel = side === 'us' ? '美军' : '伊朗'
return (
<div className={`rounded border ${side === 'us' ? 'border-military-us/40' : 'border-military-iran/40'} bg-military-dark/50 p-4`}>
<div className="mb-3 font-medium">{sideLabel}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3">
{LOSS_FIELDS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm">
<span className="w-24 shrink-0 text-military-text-secondary">{label}</span>
<input
type="number"
min={0}
value={edit[key] ?? 0}
onChange={(e) => setEdit((r) => ({ ...r, [key]: num(e.target.value) }))}
className="w-20 rounded border border-military-border bg-military-panel px-2 py-1 text-right tabular-nums"
/>
</label>
))}
</div>
<button
type="button"
onClick={() => onSave(edit)}
disabled={saving}
className="mt-3 flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-4 w-4" />
</button>
</div>
)
}
function KeyLocationList({
list,
onSave,
savingId,
}: {
list: KeyLocationRow[]
onSave: (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => void
savingId: string | null
}) {
if (list.length === 0) return <p className="text-military-text-secondary text-sm"></p>
return (
<ul className="space-y-3">
{list.map((loc) => (
<KeyLocationRowEdit
key={loc.id}
loc={loc}
onSave={onSave}
saving={savingId === `loc-${loc.id}`}
/>
))}
</ul>
)
}
function KeyLocationRowEdit({
loc,
onSave,
saving,
}: {
loc: KeyLocationRow
onSave: (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => void
saving: boolean
}) {
const [status, setStatus] = useState(loc.status ?? 'operational')
const [damageLevel, setDamageLevel] = useState(num(loc.damage_level))
useEffect(() => {
setStatus(loc.status ?? 'operational')
setDamageLevel(num(loc.damage_level))
}, [loc.id, loc.status, loc.damage_level])
const hasChange = status !== (loc.status ?? 'operational') || damageLevel !== num(loc.damage_level)
return (
<li className="flex flex-wrap items-center gap-2 rounded border border-military-border/50 bg-military-dark/30 px-3 py-2">
<span className="font-medium min-w-0 truncate">{loc.name}</span>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="rounded border border-military-border bg-military-panel px-2 py-1 text-sm"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<label className="flex items-center gap-1 text-sm">
<span className="text-military-text-secondary"></span>
<input
type="number"
min={0}
max={3}
value={damageLevel}
onChange={(e) => setDamageLevel(num(e.target.value))}
className="w-14 rounded border border-military-border bg-military-panel px-2 py-1 text-right"
/>
</label>
<button
type="button"
onClick={() => onSave(loc, { status, damage_level: damageLevel })}
disabled={saving || !hasChange}
className="flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-2 py-1 text-xs text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-3 w-3" />
</button>
{saving && <span className="text-cyan-400 text-xs"></span>}
</li>
)
}
function ForceSummaryForm({
side,
row,
onSave,
saving,
}: {
side: 'us' | 'iran'
row: ForceSummaryRow
onSave: (row: ForceSummaryRow) => void
saving: boolean
}) {
const [edit, setEdit] = useState<ForceSummaryRow>({ ...row })
useEffect(() => {
setEdit({ ...row })
}, [row])
const sideLabel = side === 'us' ? '美军' : '伊朗'
return (
<div className={`rounded border ${side === 'us' ? 'border-military-us/40' : 'border-military-iran/40'} bg-military-dark/50 p-4`}>
<div className="mb-3 font-medium">{sideLabel}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
{SUMMARY_FIELDS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm">
<span className="w-24 shrink-0 text-military-text-secondary">{label}</span>
<input
type="number"
min={0}
value={edit[key] ?? 0}
onChange={(e) => setEdit((r) => ({ ...r, [key]: num(e.target.value) }))}
className="w-20 rounded border border-military-border bg-military-panel px-2 py-1 text-right tabular-nums"
/>
</label>
))}
</div>
<button
type="button"
onClick={() => onSave(edit)}
disabled={saving}
className="mt-3 flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-4 w-4" />
</button>
</div>
)
}