Merge branch 'master' of https://git.bimwe.com/Daniel/usa
This commit is contained in:
42
.env.example
42
.env.example
@@ -2,45 +2,3 @@
|
||||
# 免费申请: https://account.mapbox.com/access-tokens/
|
||||
# 复制本文件为 .env 并填入你的 token
|
||||
VITE_MAPBOX_ACCESS_TOKEN=your_mapbox_public_token_here
|
||||
27 个基地完整 JSON 数据
|
||||
[
|
||||
{ "id": 1, "name": "Al Udeid Air Base", "country": "Qatar", "lat": 25.117, "lng": 51.314 },
|
||||
{ "id": 2, "name": "Camp As Sayliyah", "country": "Qatar", "lat": 25.275, "lng": 51.520 },
|
||||
|
||||
{ "id": 3, "name": "Naval Support Activity Bahrain", "country": "Bahrain", "lat": 26.236, "lng": 50.608 },
|
||||
|
||||
{ "id": 4, "name": "Camp Arifjan", "country": "Kuwait", "lat": 28.832, "lng": 47.799 },
|
||||
{ "id": 5, "name": "Ali Al Salem Air Base", "country": "Kuwait", "lat": 29.346, "lng": 47.520 },
|
||||
{ "id": 6, "name": "Camp Buehring", "country": "Kuwait", "lat": 29.603, "lng": 47.456 },
|
||||
|
||||
{ "id": 7, "name": "Al Dhafra Air Base", "country": "UAE", "lat": 24.248, "lng": 54.547 },
|
||||
|
||||
{ "id": 8, "name": "Prince Sultan Air Base", "country": "Saudi Arabia", "lat": 24.062, "lng": 47.580 },
|
||||
{ "id": 9, "name": "Eskan Village", "country": "Saudi Arabia", "lat": 24.774, "lng": 46.738 },
|
||||
|
||||
{ "id": 10, "name": "Al Asad Airbase", "country": "Iraq", "lat": 33.785, "lng": 42.441 },
|
||||
{ "id": 11, "name": "Erbil Air Base", "country": "Iraq", "lat": 36.237, "lng": 43.963 },
|
||||
{ "id": 12, "name": "Baghdad Diplomatic Support Center", "country": "Iraq", "lat": 33.315, "lng": 44.366 },
|
||||
{ "id": 13, "name": "Camp Taji", "country": "Iraq", "lat": 33.556, "lng": 44.256 },
|
||||
{ "id": 14, "name": "Ain al-Asad", "country": "Iraq", "lat": 33.800, "lng": 42.450 },
|
||||
|
||||
{ "id": 15, "name": "Al-Tanf Garrison", "country": "Syria", "lat": 33.490, "lng": 38.618 },
|
||||
{ "id": 16, "name": "Rmelan Landing Zone", "country": "Syria", "lat": 37.015, "lng": 41.885 },
|
||||
{ "id": 17, "name": "Shaddadi Base", "country": "Syria", "lat": 36.058, "lng": 40.730 },
|
||||
{ "id": 18, "name": "Conoco Gas Field Base", "country": "Syria", "lat": 35.336, "lng": 40.295 },
|
||||
|
||||
{ "id": 19, "name": "Muwaffaq Salti Air Base", "country": "Jordan", "lat": 32.356, "lng": 36.259 },
|
||||
|
||||
{ "id": 20, "name": "Incirlik Air Base", "country": "Turkey", "lat": 37.002, "lng": 35.425 },
|
||||
{ "id": 21, "name": "Kurecik Radar Station", "country": "Turkey", "lat": 38.354, "lng": 37.794 },
|
||||
|
||||
{ "id": 22, "name": "Nevatim Air Base", "country": "Israel", "lat": 31.208, "lng": 35.012 },
|
||||
{ "id": 23, "name": "Ramon Air Base", "country": "Israel", "lat": 30.776, "lng": 34.666 },
|
||||
|
||||
{ "id": 24, "name": "Thumrait Air Base", "country": "Oman", "lat": 17.666, "lng": 54.024 },
|
||||
{ "id": 25, "name": "Masirah Air Base", "country": "Oman", "lat": 20.675, "lng": 58.890 },
|
||||
|
||||
{ "id": 26, "name": "West Cairo Air Base", "country": "Egypt", "lat": 30.915, "lng": 30.298 },
|
||||
|
||||
{ "id": 27, "name": "Camp Lemonnier", "country": "Djibouti", "lat": 11.547, "lng": 43.159 }
|
||||
]
|
||||
111
DEPLOY.md
Normal file
111
DEPLOY.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Docker 部署到服务器
|
||||
|
||||
将 US-Iran 态势面板打包成 Docker 镜像,便于移植到任意服务器。
|
||||
|
||||
## 架构
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|--------|------|--------------------------|
|
||||
| api | 3001 | 前端静态 + REST API + WebSocket |
|
||||
| crawler| 8000 | RSS 爬虫 + GDELT,内部服务 |
|
||||
|
||||
- 数据库:SQLite,挂载到 `app-data` volume(`/data/data.db`)
|
||||
- 前端与 API 合并到同一镜像,访问 `http://主机:3001` 即可
|
||||
|
||||
## 快速部署
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone <repo> usa-dashboard && cd usa-dashboard
|
||||
|
||||
# 2. 构建并启动(需先配置 Mapbox Token,见下方)
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. 访问
|
||||
# 前端 + API: http://localhost:3001
|
||||
# 爬虫状态: http://localhost:8000/crawler/status
|
||||
```
|
||||
|
||||
## Mapbox Token(地图展示)
|
||||
|
||||
构建时需将 Token 传入前端,否则地图为占位模式:
|
||||
|
||||
```bash
|
||||
# 方式 1:.env 文件
|
||||
echo "VITE_MAPBOX_ACCESS_TOKEN=pk.xxx" > .env
|
||||
docker compose up -d --build
|
||||
|
||||
# 方式 2:环境变量
|
||||
VITE_MAPBOX_ACCESS_TOKEN=pk.xxx docker compose up -d --build
|
||||
```
|
||||
|
||||
## 推送到私有仓库并移植
|
||||
|
||||
```bash
|
||||
# 1. 打标签(替换为你的仓库地址)
|
||||
docker compose build
|
||||
docker tag usa-dashboard-api your-registry/usa-dashboard-api:latest
|
||||
docker tag usa-dashboard-crawler your-registry/usa-dashboard-crawler:latest
|
||||
|
||||
# 2. 推送
|
||||
docker push your-registry/usa-dashboard-api:latest
|
||||
docker push your-registry/usa-dashboard-crawler:latest
|
||||
|
||||
# 3. 在目标服务器拉取并启动
|
||||
docker pull your-registry/usa-dashboard-api:latest
|
||||
docker pull your-registry/usa-dashboard-crawler:latest
|
||||
# 需准备 docker-compose.yml 或等效编排,见下方
|
||||
```
|
||||
|
||||
## 仅用镜像启动(无 compose)
|
||||
|
||||
```bash
|
||||
# 1. 创建网络与数据卷
|
||||
docker network create usa-net
|
||||
docker volume create usa-data
|
||||
|
||||
# 2. 启动 API(前端+接口)
|
||||
docker run -d --name api --network usa-net \
|
||||
-p 3001:3001 \
|
||||
-v usa-data:/data \
|
||||
-e DB_PATH=/data/data.db \
|
||||
usa-dashboard-api
|
||||
|
||||
# 3. 启动爬虫(通过 usa-net 访问 api)
|
||||
docker run -d --name crawler --network usa-net \
|
||||
-v usa-data:/data \
|
||||
-e DB_PATH=/data/data.db \
|
||||
-e API_BASE=http://api:3001 \
|
||||
-e CLEANER_AI_DISABLED=1 \
|
||||
-e GDELT_DISABLED=1 \
|
||||
usa-dashboard-crawler
|
||||
```
|
||||
|
||||
爬虫通过 `API_BASE` 调用 Node 的 `/api/crawler/notify`,两容器需在同一网络内。
|
||||
|
||||
## 国内服务器 / 镜像加速
|
||||
|
||||
拉取 `node`、`python` 等基础镜像慢时:
|
||||
|
||||
1. **Docker 镜像加速**:见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md)
|
||||
2. **构建时使用国内镜像源**:
|
||||
```bash
|
||||
docker compose build --build-arg REGISTRY=docker.m.daocloud.io/library/
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 常用操作
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
docker compose logs -f
|
||||
|
||||
# 重启
|
||||
docker compose restart
|
||||
|
||||
# 停止并删除容器(数据卷保留)
|
||||
docker compose down
|
||||
|
||||
# 回填战损数据(从 situation_update 重新提取)
|
||||
curl -X POST http://localhost:8000/crawler/backfill
|
||||
```
|
||||
@@ -1,5 +1,7 @@
|
||||
# 前端 + API 一体化镜像(使用 DaoCloud 国内镜像源)
|
||||
FROM docker.m.daocloud.io/library/node:20-alpine AS frontend-builder
|
||||
# 前端 + API 一体化镜像
|
||||
# 国内服务器拉取慢时,可加 --build-arg REGISTRY=docker.m.daocloud.io/library
|
||||
ARG REGISTRY=
|
||||
FROM ${REGISTRY}node:20-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
ARG VITE_MAPBOX_ACCESS_TOKEN
|
||||
ENV VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN}
|
||||
@@ -9,7 +11,7 @@ RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM docker.m.daocloud.io/library/node:20-alpine
|
||||
FROM ${REGISTRY}node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Python 爬虫服务(使用 DaoCloud 国内镜像源 + 清华 PyPI 源)
|
||||
FROM docker.m.daocloud.io/library/python:3.11-slim
|
||||
# Python 爬虫服务
|
||||
# 国内服务器可加 --build-arg REGISTRY=docker.m.daocloud.io/library
|
||||
ARG REGISTRY=
|
||||
FROM ${REGISTRY}python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY crawler/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY crawler ./
|
||||
|
||||
ENV DB_PATH=/data/data.db
|
||||
|
||||
@@ -66,6 +66,12 @@ API 会由 Vite 代理到 `/api`,前端通过 `/api/situation` 获取完整态
|
||||
3. 查看爬虫状态:`curl http://localhost:8000/crawler/status`(需爬虫服务已启动)
|
||||
4. 数据库面板 `/db` 每 30 秒自动刷新,可观察 situation_update 条数是否增加
|
||||
|
||||
### 面板数据 / 地图 / 战损不更新时
|
||||
|
||||
- **确保 API 与爬虫共用同一数据库**:本地开发时,Node 默认用 `server/data.db`,爬虫默认用 `../server/data.db`(同文件)。若 Node 在本地、爬虫在 Docker,则数据库不同,面板不会更新。
|
||||
- **Docker 部署**:`GDELT_DISABLED=1` 时,地图冲突点由 RSS 新闻填充;战损与基地状态由规则/AI 提取后写入 `combat_losses` 和 `key_location`。
|
||||
- **排查**:访问 `/db` 看 `situation_update`、`gdelt_events`、`combat_losses` 是否在增长;确认 API 已启动且前端能访问 `/api/situation`。
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -88,6 +94,8 @@ docker compose up -d
|
||||
# 数据库与爬虫共享 volume,首次启动自动 seed
|
||||
```
|
||||
|
||||
**迁移到服务器**:见 [DEPLOY.md](DEPLOY.md)(构建、推送、单机/多机部署说明)
|
||||
|
||||
**拉取镜像超时?** 在 Docker Desktop 配置镜像加速,见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md)
|
||||
|
||||
**开发时无需每次 rebuild**:使用开发模式挂载源码 + 热重载:
|
||||
|
||||
BIN
crawler/__pycache__/db_merge.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/db_merge.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/extractor_rules.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/extractor_rules.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
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 = {}, {}
|
||||
|
||||
# 美军人员伤亡
|
||||
# 美军人员伤亡(中文,优先匹配)
|
||||
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)")
|
||||
if v is not None:
|
||||
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:
|
||||
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)")
|
||||
if v is not None:
|
||||
loss_ir["personnel_killed"] = v
|
||||
@@ -47,24 +75,27 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
||||
if v is not None:
|
||||
loss_ir["personnel_wounded"] = v
|
||||
|
||||
# 平民伤亡(多不区分阵营,计入双方或仅 us 因多为美国基地周边)
|
||||
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:killed|dead)")
|
||||
# 平民伤亡(中英文)
|
||||
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:
|
||||
loss_us["civilian_killed"] = v
|
||||
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:wounded|injured)")
|
||||
if v is not None:
|
||||
loss_us["civilian_wounded"] = v
|
||||
|
||||
# 基地损毁(美方基地居多)
|
||||
v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:destroyed|leveled)")
|
||||
# 基地损毁(美方基地居多)+ 中文
|
||||
v = _first_int(t, r"(\d+)[\s\w]*(?:base|基地)[\s\w]*(?:destroyed|leveled|摧毁|夷平)")
|
||||
if v is not None:
|
||||
loss_us["bases_destroyed"] = v
|
||||
v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:damaged|hit|struck)")
|
||||
v = _first_int(t, r"(\d+)[\s\w]*(?:base|基地)[\s\w]*(?:damaged|hit|struck|受损|袭击)")
|
||||
if v is not None:
|
||||
loss_us["bases_damaged"] = v
|
||||
if "base" in t and ("destroy" in t or "level" in t) and not loss_us.get("bases_destroyed"):
|
||||
if ("base" in t or "基地" in t) and ("destroy" in t or "level" in t or "摧毁" in t or "夷平" in t) and not loss_us.get("bases_destroyed"):
|
||||
loss_us["bases_destroyed"] = 1
|
||||
if "base" in t and ("damage" in t or "hit" in t or "struck" in t or "strike" in t) and not loss_us.get("bases_damaged"):
|
||||
if ("base" in t or "基地" in t) and ("damage" in t or "hit" in t or "struck" in t or "strike" in t or "袭击" in t or "受损" in t) and not loss_us.get("bases_damaged"):
|
||||
loss_us["bases_damaged"] = 1
|
||||
|
||||
# 战机 / 舰船(根据上下文判断阵营)
|
||||
@@ -87,9 +118,48 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
||||
out.setdefault("combat_losses_delta", {})["us"] = loss_us
|
||||
if 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}
|
||||
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}
|
||||
|
||||
# key_location_updates:受袭基地(与 key_location.name 匹配)
|
||||
# 新闻提及基地遭袭时,更新对应基地 status
|
||||
base_attacked = ("base" in t or "基地" in t) and ("attack" in t or "hit" in t or "strike" in t or "damage" in t or "袭击" in t or "打击" in t)
|
||||
if base_attacked:
|
||||
updates: list = []
|
||||
# 常见美军基地关键词 -> name_keywords(用于 db_merge 的 LIKE 匹配)
|
||||
bases_all = [
|
||||
("阿萨德|阿因|asad|assad|ain", "us"),
|
||||
("巴格达|baghdad", "us"),
|
||||
("乌代德|udeid|卡塔尔|qatar", "us"),
|
||||
("阿克罗蒂里|akrotiri|塞浦路斯|cyprus", "us"),
|
||||
("巴格拉姆|bagram|阿富汗|afghanistan", "us"),
|
||||
("埃尔比勒|erbil", "us"),
|
||||
("因吉尔利克|incirlik|土耳其|turkey", "us"),
|
||||
("苏尔坦|sultan|沙特|saudi", "us"),
|
||||
("坦夫|tanf|叙利亚|syria", "us"),
|
||||
("达夫拉|dhafra|阿联酋|uae", "us"),
|
||||
("内瓦提姆|nevatim|拉蒙|ramon|以色列|israel", "us"),
|
||||
("赛利耶|sayliyah", "us"),
|
||||
("巴林|bahrain", "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_all:
|
||||
if any(k in t for k in kws.split("|")):
|
||||
updates.append({"name_keywords": kws, "side": side, "status": "attacked", "damage_level": 2})
|
||||
if updates:
|
||||
out["key_location_updates"] = updates
|
||||
|
||||
return out
|
||||
|
||||
@@ -54,23 +54,56 @@ EVENT_CACHE: List[dict] = []
|
||||
def calculate_impact_score(title: str) -> int:
|
||||
score = 1
|
||||
t = (title or "").lower()
|
||||
if "missile" in t:
|
||||
if "missile" in t or "导弹" in t:
|
||||
score += 3
|
||||
if "strike" in t:
|
||||
if "strike" in t or "袭击" in t or "打击" in t:
|
||||
score += 2
|
||||
if "killed" in t or "death" in t or "casualt" in t:
|
||||
if "killed" in t or "death" in t or "casualt" in t or "死亡" in t or "伤亡" in t:
|
||||
score += 4
|
||||
if "troops" in t or "soldier" in t:
|
||||
if "troops" in t or "soldier" in t or "士兵" in t or "军人" in t:
|
||||
score += 2
|
||||
if "attack" in t or "attacked" in t:
|
||||
if "attack" in t or "attacked" in t or "攻击" in t:
|
||||
score += 3
|
||||
if "nuclear" in t or "核" in t:
|
||||
score += 4
|
||||
if "explosion" in t or "blast" in t or "bomb" in t:
|
||||
if "explosion" in t or "blast" in t or "bomb" in t or "爆炸" in t:
|
||||
score += 2
|
||||
return min(score, 10)
|
||||
|
||||
|
||||
# 根据 severity 映射到 impact_score
|
||||
def _severity_to_score(sev: str) -> int:
|
||||
m = {"critical": 9, "high": 7, "medium": 5, "low": 2}
|
||||
return m.get((sev or "").lower(), 5)
|
||||
|
||||
|
||||
# 根据文本推断坐标 [lng, lat],用于 GDELT 禁用时 RSS→gdelt_events
|
||||
_LOC_COORDS = [
|
||||
(["阿克罗蒂里", "akrotiri", "塞浦路斯", "cyprus"], (32.98, 34.58)),
|
||||
(["巴格拉姆", "bagram", "阿富汗", "afghanistan"], (69.26, 34.95)),
|
||||
(["巴格达", "baghdad", "伊拉克", "iraq"], (44.37, 33.31)),
|
||||
(["贝鲁特", "beirut", "黎巴嫩", "lebanon"], (35.49, 33.89)),
|
||||
(["耶路撒冷", "jerusalem", "特拉维夫", "tel aviv", "以色列", "israel"], (35.21, 31.77)),
|
||||
(["阿巴斯港", "bandar abbas", "霍尔木兹", "hormuz"], (56.27, 27.18)),
|
||||
(["米纳布", "minab"], (57.08, 27.13)),
|
||||
(["德黑兰", "tehran", "伊朗", "iran"], (51.389, 35.689)),
|
||||
(["大马士革", "damascus", "叙利亚", "syria"], (36.28, 33.50)),
|
||||
(["迪拜", "dubai", "阿联酋", "uae"], (55.27, 25.20)),
|
||||
(["沙特", "saudi"], (46.73, 24.71)),
|
||||
(["巴基斯坦", "pakistan"], (73.06, 33.72)),
|
||||
(["奥斯汀", "austin"], (-97.74, 30.27)),
|
||||
]
|
||||
|
||||
|
||||
def _infer_coords(text: str) -> tuple:
|
||||
t = (text or "").lower()
|
||||
for kws, (lng, lat) in _LOC_COORDS:
|
||||
for k in kws:
|
||||
if k in t:
|
||||
return (lng, lat)
|
||||
return (IRAN_COORD[0], IRAN_COORD[1])
|
||||
|
||||
|
||||
# ==========================
|
||||
# 获取 GDELT 实时事件
|
||||
# ==========================
|
||||
@@ -216,6 +249,39 @@ def _notify_node() -> None:
|
||||
print(f" [warn] notify API: {e}")
|
||||
|
||||
|
||||
def _rss_to_gdelt_fallback() -> None:
|
||||
"""GDELT 禁用时,将 situation_update 同步到 gdelt_events,使地图有冲突点"""
|
||||
if not GDELT_DISABLED or not os.path.exists(DB_PATH):
|
||||
return
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
rows = conn.execute(
|
||||
"SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT 50"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
events = []
|
||||
for r in rows:
|
||||
uid, ts, cat, summary, sev = r
|
||||
lng, lat = _infer_coords((summary or "")[:300])
|
||||
impact = _severity_to_score(sev)
|
||||
events.append({
|
||||
"event_id": f"rss_{uid}",
|
||||
"event_time": ts,
|
||||
"title": (summary or "")[:500],
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"impact_score": impact,
|
||||
"url": "",
|
||||
})
|
||||
if events:
|
||||
global EVENT_CACHE
|
||||
EVENT_CACHE = events
|
||||
_write_to_db(events)
|
||||
_notify_node()
|
||||
except Exception as e:
|
||||
print(f" [warn] RSS→gdelt fallback: {e}")
|
||||
|
||||
|
||||
# ==========================
|
||||
# RSS 新闻抓取(补充 situation_update + AI 提取面板数据)
|
||||
# ==========================
|
||||
@@ -243,6 +309,9 @@ def fetch_news() -> None:
|
||||
LAST_FETCH["inserted"] = n
|
||||
if items:
|
||||
_extract_and_merge_panel_data(items)
|
||||
# GDELT 禁用时用 RSS 填充 gdelt_events,使地图有冲突点
|
||||
if GDELT_DISABLED:
|
||||
_rss_to_gdelt_fallback()
|
||||
# 每次抓取完成都通知 Node 更新时间戳,便于「实时更新」显示
|
||||
_notify_node()
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] RSS 抓取 {len(items)} 条,新增入库 {n} 条")
|
||||
@@ -264,7 +333,7 @@ def _extract_and_merge_panel_data(items: list) -> None:
|
||||
from datetime import timezone
|
||||
merged_any = False
|
||||
# 规则模式可多处理几条(无 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]:
|
||||
text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "")
|
||||
if len(text.strip()) < 20:
|
||||
@@ -314,6 +383,40 @@ async def _periodic_fetch() -> None:
|
||||
# ==========================
|
||||
# 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")
|
||||
def crawler_status():
|
||||
"""爬虫状态:用于排查数据更新链路"""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
services:
|
||||
api:
|
||||
image: usa-dashboard-api:latest
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
@@ -14,6 +15,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
crawler:
|
||||
image: usa-dashboard-crawler:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.crawler
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"express": "^4.21.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"mapbox-gl": "^3.6.0",
|
||||
"opencc-js": "^1.0.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-map-gl": "^7.1.7",
|
||||
@@ -3885,6 +3886,11 @@
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"express": "^4.21.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"mapbox-gl": "^3.6.0",
|
||||
"opencc-js": "^1.0.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-map-gl": "^7.1.7",
|
||||
|
||||
BIN
server/data.db
BIN
server/data.db
Binary file not shown.
Binary file not shown.
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)
|
||||
}
|
||||
const iranLocs = [
|
||||
['iran', '阿巴斯港', 27.1832, 56.2666, 'Port', '伊朗', null, null],
|
||||
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', null, null],
|
||||
['iran', '布什尔', 28.9681, 50.838, 'Base', '伊朗', null, null],
|
||||
['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],
|
||||
]
|
||||
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 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 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 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()
|
||||
|
||||
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,9 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
||||
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { NewsTicker } from './NewsTicker'
|
||||
import { config } from '@/config'
|
||||
|
||||
function formatTick(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
@@ -14,6 +17,7 @@ function formatTick(iso: string): string {
|
||||
}
|
||||
|
||||
export function TimelinePanel() {
|
||||
const situation = useSituationStore((s) => s.situation)
|
||||
const {
|
||||
isReplayMode,
|
||||
playbackTime,
|
||||
@@ -75,6 +79,16 @@ export function TimelinePanel() {
|
||||
数据回放
|
||||
</button>
|
||||
|
||||
{!isReplayMode && config.showNewsTicker && (
|
||||
<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 && (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MapRef } from 'react-map-gl'
|
||||
import type { Map as MapboxMap } from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||
import { config } from '@/config'
|
||||
import {
|
||||
ATTACKED_TARGETS,
|
||||
ALLIED_STRIKE_LOCATIONS,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
ISRAEL_STRIKE_TARGETS,
|
||||
} from '@/data/mapLocations'
|
||||
|
||||
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN || ''
|
||||
const MAPBOX_TOKEN = config.mapboxAccessToken || ''
|
||||
|
||||
// 相关区域 bbox:伊朗、以色列、胡塞区 (minLng, minLat, maxLng, maxLat),覆盖红蓝区域
|
||||
const THEATER_BBOX = [22, 11, 64, 41] as const
|
||||
|
||||
10
src/config.ts
Normal file
10
src/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 应用配置(不依赖 .env)
|
||||
*/
|
||||
export const config = {
|
||||
/** Mapbox 地图令牌 */
|
||||
mapboxAccessToken:
|
||||
'pk.eyJ1IjoiZDI5cTAiLCJhIjoiY21oaGRmcTkzMGltZzJscHR1N2FhZnY5dCJ9.7ueF2lS6-C9Mm_xon7NnIA',
|
||||
/** 是否显示滚动情报 */
|
||||
showNewsTicker: false,
|
||||
}
|
||||
@@ -53,3 +53,9 @@ body,
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
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 { WarMap } from '@/components/WarMap'
|
||||
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
||||
import { EventTimelinePanel } from '@/components/EventTimelinePanel'
|
||||
import { IranBaseStatusPanel } from '@/components/IranBaseStatusPanel'
|
||||
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
||||
import { PowerChart } from '@/components/PowerChart'
|
||||
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
||||
@@ -71,7 +71,10 @@ export function Dashboard() {
|
||||
civilianTotal={situation.civilianCasualtiesTotal}
|
||||
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>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export function fetchAndSetSituation(): Promise<void> {
|
||||
let disconnectWs: (() => void) | null = null
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const POLL_INTERVAL_MS = 5000
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
function pollSituation() {
|
||||
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)' },
|
||||
'100%': { transform: 'translateY(-50%)' },
|
||||
},
|
||||
ticker: {
|
||||
from: { transform: 'translateX(0)' },
|
||||
to: { transform: 'translateX(-50%)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'vert-marquee': 'vert-marquee 25s linear infinite',
|
||||
ticker: 'ticker var(--ticker-duration, 40s) linear infinite',
|
||||
},
|
||||
fontFamily: {
|
||||
orbitron: ['Orbitron', 'sans-serif'],
|
||||
|
||||
Reference in New Issue
Block a user