fix: 优化后端数据
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -25,7 +25,24 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
|||||||
|
|
||||||
loss_us, loss_ir = {}, {}
|
loss_us, loss_ir = {}, {}
|
||||||
|
|
||||||
# 美军人员伤亡
|
# 美军人员伤亡(中文,优先匹配)
|
||||||
|
v = _first_int(t, r"造成\s*(\d+)\s*名?\s*美军\s*伤亡")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)\s*名?\s*美军\s*伤亡") if loss_us.get("personnel_killed") is None else None
|
||||||
|
if v is not None:
|
||||||
|
loss_us["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)\s*名?\s*(?:美军|美国军队|美国)\s*(?:死亡|阵亡)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)\s*名?\s*(?:美军|美国)\s*受伤")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["personnel_wounded"] = v
|
||||||
|
v = _first_int(t, r"美军\s*伤亡\s*(\d+)")
|
||||||
|
if v is not None and loss_us.get("personnel_killed") is None:
|
||||||
|
loss_us["personnel_killed"] = v
|
||||||
|
|
||||||
|
# 美军人员伤亡(英文)
|
||||||
v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)")
|
v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)")
|
||||||
if v is not None:
|
if v is not None:
|
||||||
loss_us["personnel_killed"] = v
|
loss_us["personnel_killed"] = v
|
||||||
@@ -36,7 +53,18 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
|||||||
if v is not None:
|
if v is not None:
|
||||||
loss_us["personnel_wounded"] = v
|
loss_us["personnel_wounded"] = v
|
||||||
|
|
||||||
# 伊朗人员伤亡
|
# 伊朗人员伤亡(中文)
|
||||||
|
v = _first_int(t, r"(\d+)\s*名?\s*伊朗\s*伤亡")
|
||||||
|
if v is not None:
|
||||||
|
loss_ir["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)\s*名?\s*(?:伊朗|伊朗军队)\s*(?:死亡|阵亡)")
|
||||||
|
if v is not None:
|
||||||
|
loss_ir["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)\s*名?\s*伊朗\s*受伤")
|
||||||
|
if v is not None:
|
||||||
|
loss_ir["personnel_wounded"] = v
|
||||||
|
|
||||||
|
# 伊朗人员伤亡(英文)
|
||||||
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|guard|killed|dead)")
|
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|guard|killed|dead)")
|
||||||
if v is not None:
|
if v is not None:
|
||||||
loss_ir["personnel_killed"] = v
|
loss_ir["personnel_killed"] = v
|
||||||
@@ -47,8 +75,11 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
|||||||
if v is not None:
|
if v is not None:
|
||||||
loss_ir["personnel_wounded"] = v
|
loss_ir["personnel_wounded"] = v
|
||||||
|
|
||||||
# 平民伤亡(多不区分阵营,计入双方或仅 us 因多为美国基地周边)
|
# 平民伤亡(中英文)
|
||||||
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:killed|dead)")
|
v = _first_int(t, r"(\d+)\s*名?\s*平民\s*(?:伤亡|死亡)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["civilian_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:killed|dead)") if loss_us.get("civilian_killed") is None else None
|
||||||
if v is not None:
|
if v is not None:
|
||||||
loss_us["civilian_killed"] = v
|
loss_us["civilian_killed"] = v
|
||||||
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:wounded|injured)")
|
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:wounded|injured)")
|
||||||
@@ -87,7 +118,7 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
|||||||
out.setdefault("combat_losses_delta", {})["us"] = loss_us
|
out.setdefault("combat_losses_delta", {})["us"] = loss_us
|
||||||
if loss_ir:
|
if loss_ir:
|
||||||
out.setdefault("combat_losses_delta", {})["iran"] = loss_ir
|
out.setdefault("combat_losses_delta", {})["iran"] = loss_ir
|
||||||
if "retaliat" in t or "revenge" in t or "报复" in t:
|
if "retaliat" in t or "revenge" in t or "报复" in t or "反击" in t:
|
||||||
out["retaliation"] = {"value": 75, "time": ts}
|
out["retaliation"] = {"value": 75, "time": ts}
|
||||||
if "wall street" in t or " dow " in t or "s&p" in t or "market slump" in t or "stock fall" in t or "美股" in t:
|
if "wall street" in t or " dow " in t or "s&p" in t or "market slump" in t or "stock fall" in t or "美股" in t:
|
||||||
out["wall_street"] = {"time": ts, "value": 55}
|
out["wall_street"] = {"time": ts, "value": 55}
|
||||||
@@ -98,7 +129,7 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
|||||||
if base_attacked:
|
if base_attacked:
|
||||||
updates: list = []
|
updates: list = []
|
||||||
# 常见美军基地关键词 -> name_keywords(用于 db_merge 的 LIKE 匹配)
|
# 常见美军基地关键词 -> name_keywords(用于 db_merge 的 LIKE 匹配)
|
||||||
bases_us = [
|
bases_all = [
|
||||||
("阿萨德|阿因|asad|assad|ain", "us"),
|
("阿萨德|阿因|asad|assad|ain", "us"),
|
||||||
("巴格达|baghdad", "us"),
|
("巴格达|baghdad", "us"),
|
||||||
("乌代德|udeid|卡塔尔|qatar", "us"),
|
("乌代德|udeid|卡塔尔|qatar", "us"),
|
||||||
@@ -113,8 +144,19 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
|||||||
("赛利耶|sayliyah", "us"),
|
("赛利耶|sayliyah", "us"),
|
||||||
("巴林|bahrain", "us"),
|
("巴林|bahrain", "us"),
|
||||||
("科威特|kuwait", "us"),
|
("科威特|kuwait", "us"),
|
||||||
|
# 伊朗基地
|
||||||
|
("阿巴斯港|abbas|bandar abbas", "iran"),
|
||||||
|
("德黑兰|tehran", "iran"),
|
||||||
|
("布什尔|bushehr", "iran"),
|
||||||
|
("伊斯法罕|isfahan|esfahan", "iran"),
|
||||||
|
("纳坦兹|natanz", "iran"),
|
||||||
|
("米纳布|minab", "iran"),
|
||||||
|
("卡拉季|karaj", "iran"),
|
||||||
|
("克尔曼沙赫|kermanshah", "iran"),
|
||||||
|
("大不里士|tabriz", "iran"),
|
||||||
|
("霍尔木兹|hormuz", "iran"),
|
||||||
]
|
]
|
||||||
for kws, side in bases_us:
|
for kws, side in bases_all:
|
||||||
if any(k in t for k in kws.split("|")):
|
if any(k in t for k in kws.split("|")):
|
||||||
updates.append({"name_keywords": kws, "side": side, "status": "attacked", "damage_level": 2})
|
updates.append({"name_keywords": kws, "side": side, "status": "attacked", "damage_level": 2})
|
||||||
if updates:
|
if updates:
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ def _extract_and_merge_panel_data(items: list) -> None:
|
|||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
merged_any = False
|
merged_any = False
|
||||||
# 规则模式可多处理几条(无 Ollama);AI 模式限制 5 条避免调用过多
|
# 规则模式可多处理几条(无 Ollama);AI 模式限制 5 条避免调用过多
|
||||||
limit = 10 if os.environ.get("CLEANER_AI_DISABLED", "0") == "1" else 5
|
limit = 25 if os.environ.get("CLEANER_AI_DISABLED", "0") == "1" else 10
|
||||||
for it in items[:limit]:
|
for it in items[:limit]:
|
||||||
text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "")
|
text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "")
|
||||||
if len(text.strip()) < 20:
|
if len(text.strip()) < 20:
|
||||||
@@ -383,6 +383,40 @@ async def _periodic_fetch() -> None:
|
|||||||
# ==========================
|
# ==========================
|
||||||
# API 接口
|
# API 接口
|
||||||
# ==========================
|
# ==========================
|
||||||
|
@app.post("/crawler/backfill")
|
||||||
|
def crawler_backfill():
|
||||||
|
"""从 situation_update 重新解析并合并战损/报复等数据,用于修复历史数据未提取的情况"""
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
return {"ok": False, "error": "db not found"}
|
||||||
|
try:
|
||||||
|
from db_merge import merge
|
||||||
|
if 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
|
||||||
|
_notify_node()
|
||||||
|
return {"ok": True, "processed": len(rows), "merged": merged}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/crawler/status")
|
@app.get("/crawler/status")
|
||||||
def crawler_status():
|
def crawler_status():
|
||||||
"""爬虫状态:用于排查数据更新链路"""
|
"""爬虫状态:用于排查数据更新链路"""
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"mapbox-gl": "^3.6.0",
|
"mapbox-gl": "^3.6.0",
|
||||||
|
"opencc-js": "^1.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-map-gl": "^7.1.7",
|
"react-map-gl": "^7.1.7",
|
||||||
@@ -3885,6 +3886,11 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opencc-js": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencc-js/-/opencc-js-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg=="
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"mapbox-gl": "^3.6.0",
|
"mapbox-gl": "^3.6.0",
|
||||||
|
"opencc-js": "^1.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-map-gl": "^7.1.7",
|
"react-map-gl": "^7.1.7",
|
||||||
|
|||||||
BIN
server/data.db-shm
Normal file
BIN
server/data.db-shm
Normal file
Binary file not shown.
BIN
server/data.db-wal
Normal file
BIN
server/data.db-wal
Normal file
Binary file not shown.
@@ -134,9 +134,16 @@ function seed() {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
const iranLocs = [
|
const iranLocs = [
|
||||||
['iran', '阿巴斯港', 27.1832, 56.2666, 'Port', '伊朗', null, null],
|
['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Port', '伊朗', 'attacked', 3],
|
||||||
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', null, null],
|
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3],
|
||||||
['iran', '布什尔', 28.9681, 50.838, 'Base', '伊朗', null, null],
|
['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],
|
||||||
]
|
]
|
||||||
iranLocs.forEach((r) => insertLoc.run(...r))
|
iranLocs.forEach((r) => insertLoc.run(...r))
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function getSituation() {
|
|||||||
const assetsUs = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('us')
|
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 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 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 FROM key_location WHERE side = ?').all('iran')
|
const locIr = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran')
|
||||||
const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us')
|
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 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 trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all()
|
||||||
|
|||||||
72
src/components/IranBaseStatusPanel.tsx
Normal file
72
src/components/IranBaseStatusPanel.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { MapPin, AlertTriangle, AlertCircle } from 'lucide-react'
|
||||||
|
import type { MilitarySituation } from '@/data/mockData'
|
||||||
|
|
||||||
|
interface IranBaseStatusPanelProps {
|
||||||
|
keyLocations: MilitarySituation['iranForces']['keyLocations']
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IranBaseStatusPanel({ keyLocations = [], className = '' }: IranBaseStatusPanelProps) {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const bases = (keyLocations || []).filter((loc) => loc.type === 'Base' || loc.type === 'Port' || loc.type === 'Nuclear' || loc.type === 'Missile')
|
||||||
|
let attacked = 0
|
||||||
|
let severe = 0
|
||||||
|
let moderate = 0
|
||||||
|
let light = 0
|
||||||
|
for (const b of bases) {
|
||||||
|
const s = b.status ?? 'operational'
|
||||||
|
if (s === 'attacked') attacked++
|
||||||
|
const lvl = b.damage_level
|
||||||
|
if (lvl === 3) severe++
|
||||||
|
else if (lvl === 2) moderate++
|
||||||
|
else if (lvl === 1) light++
|
||||||
|
}
|
||||||
|
return { total: bases.length, attacked, severe, moderate, light }
|
||||||
|
}, [keyLocations])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border border-military-border bg-military-panel/80 p-3 font-orbitron ${className}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary">
|
||||||
|
<MapPin className="h-3 w-3 shrink-0 text-amber-500" />
|
||||||
|
伊朗基地态势
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 text-xs tabular-nums">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-military-text-secondary">总基地数</span>
|
||||||
|
<strong>{stats.total}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||||
|
<AlertCircle className="h-3 w-3 text-red-400" />
|
||||||
|
被袭击
|
||||||
|
</span>
|
||||||
|
<strong className="text-red-400">{stats.attacked}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||||
|
严重损毁
|
||||||
|
</span>
|
||||||
|
<strong className="text-amber-500">{stats.severe}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-amber-400" />
|
||||||
|
中度损毁
|
||||||
|
</span>
|
||||||
|
<strong className="text-amber-400">{stats.moderate}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-amber-300" />
|
||||||
|
轻度损毁
|
||||||
|
</span>
|
||||||
|
<strong className="text-amber-300">{stats.light}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/components/NewsTicker.tsx
Normal file
56
src/components/NewsTicker.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
|
||||||
|
import { processTickerText } from '@/utils/tickerText'
|
||||||
|
|
||||||
|
interface NewsTickerProps {
|
||||||
|
updates?: SituationUpdate[]
|
||||||
|
conflictEvents?: ConflictEvent[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewsTicker({ updates = [], conflictEvents = [], className = '' }: NewsTickerProps) {
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const list: { id: string; text: string }[] = []
|
||||||
|
for (const e of conflictEvents || []) {
|
||||||
|
const text = processTickerText(e.title || '')
|
||||||
|
if (text) list.push({ id: `ev-${e.event_id}`, text })
|
||||||
|
}
|
||||||
|
for (const u of updates || []) {
|
||||||
|
const text = processTickerText(u.summary || '')
|
||||||
|
if (text) list.push({ id: `up-${u.id}`, text })
|
||||||
|
}
|
||||||
|
return list.slice(0, 30)
|
||||||
|
}, [updates, conflictEvents])
|
||||||
|
|
||||||
|
const baseCls = 'flex items-center overflow-hidden'
|
||||||
|
const defaultCls = 'border-b border-military-border/50 bg-military-panel/60 py-1.5'
|
||||||
|
const wrapperCls = className ? `${baseCls} ${className}` : `${baseCls} ${defaultCls}`
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={wrapperCls}>
|
||||||
|
<span className="shrink-0 pr-2 text-[10px] uppercase text-military-text-secondary">滚动情报</span>
|
||||||
|
<span className="text-[11px] text-military-text-secondary">暂无资讯</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = items.map((i) => i.text).join(' ◆ ')
|
||||||
|
const duration = Math.max(180, Math.min(480, content.length * 0.8))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapperCls}>
|
||||||
|
<span className="shrink-0 pr-2 text-[10px] font-medium uppercase tracking-wider text-cyan-400">滚动情报</span>
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="inline-flex whitespace-nowrap text-[11px] text-military-text-secondary"
|
||||||
|
style={{ animation: `ticker ${duration}s linear infinite` }}
|
||||||
|
>
|
||||||
|
<span className="px-2">{content}</span>
|
||||||
|
<span className="px-2 text-cyan-400/60">◆</span>
|
||||||
|
<span className="px-2">{content}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
||||||
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||||
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
|
import { NewsTicker } from './NewsTicker'
|
||||||
|
|
||||||
function formatTick(iso: string): string {
|
function formatTick(iso: string): string {
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
@@ -14,6 +16,7 @@ function formatTick(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TimelinePanel() {
|
export function TimelinePanel() {
|
||||||
|
const situation = useSituationStore((s) => s.situation)
|
||||||
const {
|
const {
|
||||||
isReplayMode,
|
isReplayMode,
|
||||||
playbackTime,
|
playbackTime,
|
||||||
@@ -75,6 +78,16 @@ export function TimelinePanel() {
|
|||||||
数据回放
|
数据回放
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!isReplayMode && (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<NewsTicker
|
||||||
|
updates={situation.recentUpdates}
|
||||||
|
conflictEvents={situation.conflictEvents}
|
||||||
|
className="!border-0 !bg-transparent !py-0 !px-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isReplayMode && (
|
{isReplayMode && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -53,3 +53,9 @@ body,
|
|||||||
background: rgba(75, 85, 99, 0.5);
|
background: rgba(75, 85, 99, 0.5);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes ticker {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { TimelinePanel } from '@/components/TimelinePanel'
|
|||||||
import { ForcePanel } from '@/components/ForcePanel'
|
import { ForcePanel } from '@/components/ForcePanel'
|
||||||
import { WarMap } from '@/components/WarMap'
|
import { WarMap } from '@/components/WarMap'
|
||||||
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
||||||
import { EventTimelinePanel } from '@/components/EventTimelinePanel'
|
import { IranBaseStatusPanel } from '@/components/IranBaseStatusPanel'
|
||||||
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
||||||
import { PowerChart } from '@/components/PowerChart'
|
import { PowerChart } from '@/components/PowerChart'
|
||||||
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
||||||
@@ -71,7 +71,10 @@ export function Dashboard() {
|
|||||||
civilianTotal={situation.civilianCasualtiesTotal}
|
civilianTotal={situation.civilianCasualtiesTotal}
|
||||||
className="min-w-0 flex-1 py-1"
|
className="min-w-0 flex-1 py-1"
|
||||||
/>
|
/>
|
||||||
<EventTimelinePanel updates={situation.recentUpdates} conflictEvents={situation.conflictEvents} className="min-w-0 shrink-0 min-h-[80px] overflow-hidden lg:min-w-[240px]" />
|
<IranBaseStatusPanel
|
||||||
|
keyLocations={situation.iranForces.keyLocations}
|
||||||
|
className="min-w-0 shrink-0 lg:min-w-[200px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function fetchAndSetSituation(): Promise<void> {
|
|||||||
let disconnectWs: (() => void) | null = null
|
let disconnectWs: (() => void) | null = null
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5000
|
const POLL_INTERVAL_MS = 3000
|
||||||
|
|
||||||
function pollSituation() {
|
function pollSituation() {
|
||||||
fetchSituation()
|
fetchSituation()
|
||||||
|
|||||||
35
src/utils/tickerText.ts
Normal file
35
src/utils/tickerText.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 滚动情报文本处理:转为简体中文,过滤非中文内容
|
||||||
|
*/
|
||||||
|
import { Converter } from 'opencc-js/t2cn'
|
||||||
|
|
||||||
|
const t2s = Converter({ from: 'twp', to: 'cn' })
|
||||||
|
|
||||||
|
/** 简体中文字符范围 */
|
||||||
|
const ZH_REGEX = /[\u4e00-\u9fff]/g
|
||||||
|
|
||||||
|
/** 文本中中文占比是否达标(至少30%) */
|
||||||
|
export function isMostlyChinese(text: string): boolean {
|
||||||
|
if (!text?.trim()) return false
|
||||||
|
const zh = text.match(ZH_REGEX)
|
||||||
|
const zhCount = zh ? zh.length : 0
|
||||||
|
return zhCount / text.length >= 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 繁体转简体 */
|
||||||
|
export function toSimplifiedChinese(text: string): string {
|
||||||
|
if (!text?.trim()) return text
|
||||||
|
try {
|
||||||
|
return t2s(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理滚动情报项:转为简体,非中文为主则过滤 */
|
||||||
|
export function processTickerText(text: string): string | null {
|
||||||
|
const t = (text || '').trim()
|
||||||
|
if (!t) return null
|
||||||
|
if (!isMostlyChinese(t)) return null
|
||||||
|
return toSimplifiedChinese(t)
|
||||||
|
}
|
||||||
@@ -11,9 +11,14 @@ export default {
|
|||||||
'0%': { transform: 'translateY(0)' },
|
'0%': { transform: 'translateY(0)' },
|
||||||
'100%': { transform: 'translateY(-50%)' },
|
'100%': { transform: 'translateY(-50%)' },
|
||||||
},
|
},
|
||||||
|
ticker: {
|
||||||
|
from: { transform: 'translateX(0)' },
|
||||||
|
to: { transform: 'translateX(-50%)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'vert-marquee': 'vert-marquee 25s linear infinite',
|
'vert-marquee': 'vert-marquee 25s linear infinite',
|
||||||
|
ticker: 'ticker var(--ticker-duration, 40s) linear infinite',
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
orbitron: ['Orbitron', 'sans-serif'],
|
orbitron: ['Orbitron', 'sans-serif'],
|
||||||
|
|||||||
Reference in New Issue
Block a user