Compare commits
10 Commits
v0.27.1
...
feature/ue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c97dd83f3e | ||
|
|
f8b43a995b | ||
|
|
a4e6ce7489 | ||
|
|
7ffc8537e4 | ||
|
|
4dd396ea65 | ||
|
|
1e6f4b338b | ||
|
|
d9adaf4134 | ||
|
|
40e51d5b20 | ||
|
|
93c1c1e550 | ||
|
|
48eb13b993 |
@@ -15,6 +15,7 @@ from app.api.v1 import (
|
|||||||
bgp,
|
bgp,
|
||||||
system_control,
|
system_control,
|
||||||
tv,
|
tv,
|
||||||
|
ue_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
|
|||||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||||
|
api_router.include_router(ue_data.router, prefix="/ue", tags=["ue-client"])
|
||||||
|
|||||||
351
backend/app/api/v1/ue_data.py
Normal file
351
backend/app/api/v1/ue_data.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"""UE Client Data API
|
||||||
|
|
||||||
|
Flat JSON endpoints designed for easy parsing in Unreal Engine C++/Blueprint.
|
||||||
|
Avoids GeoJSON nesting — every field is at the top level of each item.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from app.core.collected_data_fields import get_record_field
|
||||||
|
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||||
|
from app.core.time import to_iso8601_utc
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.collected_data import CollectedData
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _current_stmt(source: str, limit: Optional[int] = None):
|
||||||
|
stmt = (
|
||||||
|
select(CollectedData)
|
||||||
|
.where(CollectedData.source == source)
|
||||||
|
.where(CollectedData.is_current.is_(True))
|
||||||
|
.order_by(CollectedData.id.desc())
|
||||||
|
)
|
||||||
|
if limit:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch(db: AsyncSession, source: str, limit: Optional[int] = None) -> List[CollectedData]:
|
||||||
|
result = await db.execute(_current_stmt(source, limit))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
v = float(value)
|
||||||
|
return v if v == v else None # reject NaN
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def ue_status(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Quick health-check + data counts for the UE client."""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
async def count_source(source: str) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(CollectedData)
|
||||||
|
.where(CollectedData.source == source)
|
||||||
|
.where(CollectedData.is_current.is_(True))
|
||||||
|
)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"server_time": to_iso8601_utc(datetime.now(UTC)),
|
||||||
|
"compute_points_count": await count_source("top500"),
|
||||||
|
"cables_count": await count_source("telegeography_cables"),
|
||||||
|
"landing_points_count": await count_source("arcgis_landing"),
|
||||||
|
"satellites_count": await count_source("celestrak"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Compute points (TOP500 supercomputers)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/compute-points")
|
||||||
|
async def ue_compute_points(
|
||||||
|
limit: int = Query(default=500, ge=1, le=2000),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns TOP500 supercomputer data as a flat JSON array.
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
{
|
||||||
|
"count": 500,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "top500_1",
|
||||||
|
"name": "Frontier",
|
||||||
|
"latitude": 36.01,
|
||||||
|
"longitude": -84.26,
|
||||||
|
"country": "United States",
|
||||||
|
"city": "Oak Ridge",
|
||||||
|
"rank": 1,
|
||||||
|
"rmax_tflops": 1194000.0,
|
||||||
|
"rpeak_tflops": 1679616.0,
|
||||||
|
"cores": 8730112,
|
||||||
|
"power_kw": 22703.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
records = await _fetch(db, "top500", limit)
|
||||||
|
items = []
|
||||||
|
for record in records:
|
||||||
|
meta = record.extra_data or {}
|
||||||
|
lat = _safe_float(get_record_field(record, "latitude"))
|
||||||
|
lon = _safe_float(get_record_field(record, "longitude"))
|
||||||
|
if lat is None or lon is None:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"id": f"top500_{record.id}",
|
||||||
|
"name": record.name or "Unknown",
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"country": get_record_field(record, "country") or "",
|
||||||
|
"city": get_record_field(record, "city") or "",
|
||||||
|
"rank": meta.get("rank"),
|
||||||
|
"rmax_tflops": _safe_float(get_record_field(record, "rmax")),
|
||||||
|
"rpeak_tflops": _safe_float(get_record_field(record, "rpeak")),
|
||||||
|
"cores": meta.get("cores"),
|
||||||
|
"power_kw": _safe_float(get_record_field(record, "power")),
|
||||||
|
})
|
||||||
|
return {"count": len(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cable landing points
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/landing-points")
|
||||||
|
async def ue_landing_points(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns cable landing points as a flat JSON array.
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
{
|
||||||
|
"count": 1200,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "lp_42",
|
||||||
|
"name": "Shoreham",
|
||||||
|
"latitude": 50.83,
|
||||||
|
"longitude": -0.28,
|
||||||
|
"country": "United Kingdom",
|
||||||
|
"city": "Shoreham-by-Sea",
|
||||||
|
"cable_names": ["FLAG", "TAT-14"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Load landing points
|
||||||
|
lp_records = await _fetch(db, "arcgis_landing")
|
||||||
|
|
||||||
|
# Load relation + cable data for cable_names mapping
|
||||||
|
rel_result = await db.execute(
|
||||||
|
select(CollectedData)
|
||||||
|
.where(CollectedData.source == "arcgis_relation")
|
||||||
|
.where(CollectedData.is_current.is_(True))
|
||||||
|
)
|
||||||
|
rel_records = list(rel_result.scalars().all())
|
||||||
|
|
||||||
|
cable_result = await db.execute(
|
||||||
|
select(CollectedData)
|
||||||
|
.where(CollectedData.source == "telegeography_cables")
|
||||||
|
.where(CollectedData.is_current.is_(True))
|
||||||
|
)
|
||||||
|
cable_records = list(cable_result.scalars().all())
|
||||||
|
|
||||||
|
# Build mapping: city_id → list of cable names
|
||||||
|
city_to_cable_ids: Dict[int, List[int]] = {}
|
||||||
|
for r in rel_records:
|
||||||
|
meta = r.extra_data or {}
|
||||||
|
city_id = meta.get("city_id")
|
||||||
|
cable_id = meta.get("cable_id")
|
||||||
|
if city_id is not None and cable_id is not None:
|
||||||
|
city_to_cable_ids.setdefault(city_id, [])
|
||||||
|
if cable_id not in city_to_cable_ids[city_id]:
|
||||||
|
city_to_cable_ids[city_id].append(cable_id)
|
||||||
|
|
||||||
|
cable_id_to_name: Dict[int, str] = {}
|
||||||
|
for r in cable_records:
|
||||||
|
meta = r.extra_data or {}
|
||||||
|
cable_id = meta.get("cable_id")
|
||||||
|
if cable_id and r.name:
|
||||||
|
cable_id_to_name[cable_id] = r.name
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for record in lp_records:
|
||||||
|
lat = _safe_float(get_record_field(record, "latitude"))
|
||||||
|
lon = _safe_float(get_record_field(record, "longitude"))
|
||||||
|
if lat is None or lon is None:
|
||||||
|
continue
|
||||||
|
meta = record.extra_data or {}
|
||||||
|
city_id = meta.get("city_id")
|
||||||
|
cable_names = []
|
||||||
|
if city_id in city_to_cable_ids:
|
||||||
|
cable_names = [
|
||||||
|
cable_id_to_name[cid]
|
||||||
|
for cid in city_to_cable_ids[city_id]
|
||||||
|
if cid in cable_id_to_name
|
||||||
|
]
|
||||||
|
items.append({
|
||||||
|
"id": f"lp_{record.id}",
|
||||||
|
"name": record.name or "Unknown",
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"country": get_record_field(record, "country") or "",
|
||||||
|
"city": get_record_field(record, "city") or "",
|
||||||
|
"cable_names": cable_names,
|
||||||
|
})
|
||||||
|
return {"count": len(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cables (route geometry)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/cables")
|
||||||
|
async def ue_cables(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns cable route geometry.
|
||||||
|
|
||||||
|
Each segment is a flat array of [lon, lat] pairs.
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
{
|
||||||
|
"count": 100,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "cable_42",
|
||||||
|
"cable_id": "flag",
|
||||||
|
"name": "FLAG",
|
||||||
|
"status": "active",
|
||||||
|
"length_km": 28000,
|
||||||
|
"segments": [
|
||||||
|
[[lon, lat], [lon, lat], ...]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
records = await _fetch(db, "telegeography_cables")
|
||||||
|
items = []
|
||||||
|
for record in records:
|
||||||
|
meta = record.extra_data or {}
|
||||||
|
route_coords = meta.get("route_coordinates", [])
|
||||||
|
segments: List[List[List[float]]] = []
|
||||||
|
|
||||||
|
if route_coords:
|
||||||
|
# Support both flat [lon,lat] array and array-of-arrays
|
||||||
|
if route_coords and isinstance(route_coords[0][0], list):
|
||||||
|
raw_lines = route_coords
|
||||||
|
else:
|
||||||
|
raw_lines = [route_coords]
|
||||||
|
|
||||||
|
for raw_line in raw_lines:
|
||||||
|
line = []
|
||||||
|
for pt in raw_line:
|
||||||
|
try:
|
||||||
|
line.append([float(pt[0]), float(pt[1])])
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
if len(line) >= 2:
|
||||||
|
segments.append(line)
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": f"cable_{record.id}",
|
||||||
|
"cable_id": record.source_id or record.name or "",
|
||||||
|
"name": record.name or "Unknown",
|
||||||
|
"status": meta.get("status", "active"),
|
||||||
|
"length_km": _safe_float(get_record_field(record, "value")),
|
||||||
|
"owners": meta.get("owners") or [],
|
||||||
|
"rfs": meta.get("rfs"),
|
||||||
|
"color": meta.get("color"),
|
||||||
|
"segments": segments,
|
||||||
|
})
|
||||||
|
return {"count": len(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Satellites (TLE data)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/satellites")
|
||||||
|
async def ue_satellites(
|
||||||
|
limit: int = Query(default=200, ge=1, le=5000),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns satellite TLE data for orbit propagation in UE.
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
{
|
||||||
|
"count": 200,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "sat_42",
|
||||||
|
"norad_id": "25544",
|
||||||
|
"name": "ISS (ZARYA)",
|
||||||
|
"tle_line1": "1 25544U ...",
|
||||||
|
"tle_line2": "2 25544 ...",
|
||||||
|
"epoch": "2026-04-14T00:00:00Z",
|
||||||
|
"inclination": 51.6,
|
||||||
|
"mean_motion": 15.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
records = await _fetch(db, "celestrak", limit)
|
||||||
|
items = []
|
||||||
|
for record in records:
|
||||||
|
meta = record.extra_data or {}
|
||||||
|
norad_id = meta.get("norad_cat_id")
|
||||||
|
if not norad_id:
|
||||||
|
continue
|
||||||
|
tle1 = meta.get("tle_line1")
|
||||||
|
tle2 = meta.get("tle_line2")
|
||||||
|
if not tle1 or not tle2:
|
||||||
|
tle1, tle2 = build_tle_lines_from_elements(
|
||||||
|
norad_cat_id=norad_id,
|
||||||
|
epoch=meta.get("epoch"),
|
||||||
|
inclination=meta.get("inclination"),
|
||||||
|
raan=meta.get("raan"),
|
||||||
|
eccentricity=meta.get("eccentricity"),
|
||||||
|
arg_of_perigee=meta.get("arg_of_perigee"),
|
||||||
|
mean_anomaly=meta.get("mean_anomaly"),
|
||||||
|
mean_motion=meta.get("mean_motion"),
|
||||||
|
)
|
||||||
|
items.append({
|
||||||
|
"id": f"sat_{record.id}",
|
||||||
|
"norad_id": str(norad_id),
|
||||||
|
"name": record.name or "Unknown",
|
||||||
|
"tle_line1": tle1 or "",
|
||||||
|
"tle_line2": tle2 or "",
|
||||||
|
"epoch": meta.get("epoch") or "",
|
||||||
|
"inclination": _safe_float(meta.get("inclination")),
|
||||||
|
"raan": _safe_float(meta.get("raan")),
|
||||||
|
"eccentricity": _safe_float(meta.get("eccentricity")),
|
||||||
|
"mean_motion": _safe_float(meta.get("mean_motion")),
|
||||||
|
})
|
||||||
|
return {"count": len(items), "items": items}
|
||||||
@@ -8,6 +8,59 @@ This project follows the repository versioning rule:
|
|||||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||||
- `bugfix` -> `+0.0.1`
|
- `bugfix` -> `+0.0.1`
|
||||||
|
|
||||||
|
## [0.27.6] — 2026-04-15
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- BGP 告警页表格纵向 overflow 修复:补全 flex 布局链,tabs content-holder 正确撑满剩余高度
|
||||||
|
- 用户管理表格横向滚动修复:采用 flex-fill 方案替换 `height: auto !important`,自定义滚动条 X 轨道位置对齐表格底部
|
||||||
|
- Playground 宽布局隐藏"服务状态"按钮:侧边栏可见时不显示冗余入口
|
||||||
|
- AI Chatbox 输入框失焦收起为单行,聚焦或有内容时展开完整 composer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.27.4] — 2026-04-14
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- info-card 改为懒加载动态挂载:页面初始 DOM 不再含隐藏的 `#info-panel` 节点,仅首次点击交互元素时创建
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.27.5] — 2026-04-14
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 统一控制台多页面滚动体验:BGP、alerts、采集数据、用户管理、任务、设置、Playground 等区域接入自定义滚动条与表格滚动容器
|
||||||
|
- 优化 BGP 与 alerts 页响应式布局:顶部概览卡在窄宽度下优先重排,必要时才启用横向滚动,避免卡片裁切和全局滚动条接管
|
||||||
|
- 调整 `situational alerts` 布局策略:统计卡按宽度在单行、两列和横滚之间切换,下方详情卡保持单行高度优先
|
||||||
|
- 实时采集进度优化:一键采集完成后在未刷新页面时保留 100% 完成态,不再错误归零
|
||||||
|
- 补充 UE5 MVP 融合方案文档,完善后续集成规划沉淀
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复 BGP summary 与 alerts 顶部卡片在无真实溢出时误出现横向滚动的问题
|
||||||
|
- 修复 alerts 页面缩窄后外层全局竖向滚动被接管的问题,恢复“一屏内、内部滚动”的布局逻辑
|
||||||
|
- 修复 `situational alerts` 在两排布局下详情卡竖向溢出的问题,改为更稳定的分区响应式排版
|
||||||
|
- 修复自定义滚动条交互反馈,悬停、聚焦、拖拽时颜色加深但不再显示多余外圈
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.27.3] — 2026-04-14
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- TV panel meta 折叠展开方向稳定:底部锚定时向上生长,拖拽后(顶部锚定)通过 JS 补偿 top 保持播放器底部位置不变
|
||||||
|
- 修复 TV panel 展开/折叠时视频区域跳动问题:移除面板 min-height,使播放器高度在两种状态下保持一致
|
||||||
|
- 修正 TV panel meta toggle 箭头方向:展开朝下,折叠朝上
|
||||||
|
- 修复图例面板折叠按钮失效(legend-bar-btn 补充进拖拽排除列表)
|
||||||
|
- 调整图层搜索框图标尺寸为 20px,BR 缩放角标改为直角 L 形
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.27.2] — 2026-04-14
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 修复 brand copy 宽度不随内容收缩的问题,现在与 title 图片宽度保持一致
|
||||||
|
- 提取 `--brand-copy-width` CSS 自定义属性,消除 160px / 172px 魔法数字重复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.27.1] — 2026-04-14
|
## [0.27.1] — 2026-04-14
|
||||||
|
|
||||||
### 🔧 Improvements
|
### 🔧 Improvements
|
||||||
|
|||||||
144
docs/ue5_led_context.md
Normal file
144
docs/ue5_led_context.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# 项目背景:UE5 + 3D LED 大屏展示系统
|
||||||
|
|
||||||
|
> 供其他 AI 快速了解项目背景和当前状态。
|
||||||
|
> 最后更新:2026-04-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目定位
|
||||||
|
|
||||||
|
这不是一个普通的桌面地球应用,而是一套**领导演示用的 3D 沉浸式展示系统**。
|
||||||
|
|
||||||
|
核心逻辑:
|
||||||
|
- 态势感知数据(超算、海缆、卫星)是内容
|
||||||
|
- 3D LED 大屏 + 实时渲染 + 动捕交互是"醋"——没有它,内容再好也只是普通屏幕
|
||||||
|
- 主要受众:领导/决策层,注重视觉冲击力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、硬件配置
|
||||||
|
|
||||||
|
| 硬件 | 规格 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 3D LED 大屏 | 5.12m × 2.88m,P1.538mm,被动偏振式 | 观众戴无源 3D 眼镜(≤18g),60Hz 即可出稳定 3D |
|
||||||
|
| 渲染工作站 | 双路国产 X86 CPU + RTX 5090 32GB | 驱动 UE5 实时渲染 |
|
||||||
|
| 视频处理器 | 随屏配套(品牌待确认) | 接收 GPU 信号,驱动 LED 墙 |
|
||||||
|
| 动捕摄像头 | RGB 摄像头 ×2,4K,直连电脑 | 无穿戴姿态识别 |
|
||||||
|
| 音响 | 解码功放 + 吸顶喇叭 ×5 + 低音炮 | 配套 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、软件架构
|
||||||
|
|
||||||
|
```
|
||||||
|
摄像头(×2,直连)
|
||||||
|
↓ 动捕插件(供应商提供 UE5 插件)
|
||||||
|
UE5 主程序(我们开发)
|
||||||
|
├── Cesium 地球(实时渲染)
|
||||||
|
├── 超算数据点(可交互)
|
||||||
|
├── 其他可交互物件
|
||||||
|
└── 立体渲染输出
|
||||||
|
↓ 视频信号(格式待确认)
|
||||||
|
视频处理器(随屏配套)
|
||||||
|
↓ LED 驱动信号(行偏振)
|
||||||
|
5m 被动偏振 3D LED 大屏
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、分工
|
||||||
|
|
||||||
|
| 部分 | 谁做 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| LED 屏体 + 视频处理器 | 屏幕供应商 | 采购中 |
|
||||||
|
| 动捕插件(UE5 插件形式) | 动捕供应商 | 待交付 |
|
||||||
|
| UE5 基础工程(关卡+角色+动捕绑定) | 动捕供应商 | 待交付 |
|
||||||
|
| 地球场景 + 数据可视化 + 交互逻辑 | 我们(本项目) | 开发中 |
|
||||||
|
| 后端数据接口 | 我们(本项目) | 已完成 |
|
||||||
|
| 其他 9 个定制 3D 资产和动画 | 3D 内容供应商 | 采购中 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、动捕交互方式
|
||||||
|
|
||||||
|
**交付形式(已确认)**:
|
||||||
|
- 供应商给我们**完整 UE5 工程**,包含:
|
||||||
|
- 视频动捕插件
|
||||||
|
- 已配好绑定和重定向的 3D 角色
|
||||||
|
- 3D 模型和动画资产
|
||||||
|
- 接两台摄像头即可直接运行
|
||||||
|
- **我们的任务**:把他们工程的内容(插件 + 角色 + 资产)**迁移进我们的 `ue_client/` 工程**,然后把角色动作映射到地球操作
|
||||||
|
|
||||||
|
**待确认**:角色动作的触发点是什么形式?
|
||||||
|
- 蓝图 Custom Event(如 `OnGestureRotate`)?
|
||||||
|
- AnimNotify?
|
||||||
|
- 需要我们自己判断骨骼姿态?
|
||||||
|
|
||||||
|
**交互目标(一期)**:
|
||||||
|
1. 手势旋转地球
|
||||||
|
2. 手势缩放地球
|
||||||
|
3. 手势指向/确认 → 选中数据点,弹出信息卡
|
||||||
|
4. 鼠标作为备用输入(始终可用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、立体渲染
|
||||||
|
|
||||||
|
**待确认**:
|
||||||
|
1. 供应商基础工程里是否已配好立体渲染输出?
|
||||||
|
2. 如果没有:视频处理器接受什么格式?(Side-by-Side / Top-Bottom / 其他)
|
||||||
|
3. 给供应商的文件形式:UE5 工程 / .exe / 视频文件 / 直连实时输出?
|
||||||
|
|
||||||
|
**已准备**:`StereoRenderingManager.h/.cpp` 支持运行时切换 SbS/TbB,格式确认后直接启用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、已完成的代码
|
||||||
|
|
||||||
|
### 后端 (`backend/app/api/v1/ue_data.py`)
|
||||||
|
- `GET /api/v1/ue/status` — 健康检查
|
||||||
|
- `GET /api/v1/ue/compute-points` — TOP500 超算(平铺 JSON)
|
||||||
|
- `GET /api/v1/ue/landing-points` — 海缆登陆点
|
||||||
|
- `GET /api/v1/ue/cables` — 海缆路由几何
|
||||||
|
- `GET /api/v1/ue/satellites` — 卫星 TLE 数据
|
||||||
|
|
||||||
|
### UE5 C++ (`ue_client/Source/PlanetClient/`)
|
||||||
|
|
||||||
|
| 文件 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `PlanetDataTypes.h` | FComputePoint 等数据结构 |
|
||||||
|
| `PlanetDataManager` | HTTP 拉取 + mock 数据 + 生成 Actor |
|
||||||
|
| `ComputePointActor` | 超算点 Actor,三态材质(正常/悬停/选中)|
|
||||||
|
| `InteractiveObjectBase` | 所有可交互物件的基类 |
|
||||||
|
| `GlobeInteractionComponent` | 拖拽旋转地球(改 Cesium 经纬度原点)+ 缩放,带惯性 |
|
||||||
|
| `StereoRenderingManager` | 立体渲染开关,SbS/TbB,IPD 可调 |
|
||||||
|
| `MotionCaptureInterface.h` | 动捕接口抽象(待用插件 API 替换实现)|
|
||||||
|
| `PlanetPlayerController` | 统一处理鼠标 + 动捕输入 |
|
||||||
|
| `PlanetGameMode` | 场景入口 |
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `docs/ue_client_setup_guide.md` — 编辑器操作 step-by-step 指南
|
||||||
|
- `docs/ue_todo.md` — 待供应商回复的 TODO
|
||||||
|
- `docs/ue5_mvp_fused_plan.md` — 完整实施方案(v3.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、交付时间线
|
||||||
|
|
||||||
|
| 时间 | 内容 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 本周末前 | 动捕供应商:含动捕插件 + 3D 角色(绑定/重定向已配好)的基础 UE5 工程 | 等待中 |
|
||||||
|
| 之后尽快 | 动捕供应商:动画资产(复制进 Content/ 即可直接调用) | 等待中 |
|
||||||
|
| TBD | LED 屏供应商:视频处理器接受的 3D 信号格式(或直接技术支持对接) | 等待中 |
|
||||||
|
|
||||||
|
**拿到基础工程后可立即做**:迁移插件和角色,接摄像头做动捕调试,然后对接动作→地球操作映射。
|
||||||
|
|
||||||
|
**双目 3D 显示**:供应商可提供技术支持,等视频处理器到位后直接对接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、当前阻塞项
|
||||||
|
|
||||||
|
1. **动捕基础工程**(本周末前到)→ 迁移内容,确认动作触发方式,完成交互对接
|
||||||
|
2. **动画资产**(尽快)→ 复制入 Content/,在场景中引用
|
||||||
|
3. **3D 显示格式**(有供应商技术支持)→ 配置 `StereoRenderingManager`
|
||||||
152
docs/ue5_mvp_fused_plan.md
Normal file
152
docs/ue5_mvp_fused_plan.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# 智能星球 UE5 客户端实施方案(LED 大屏版)
|
||||||
|
|
||||||
|
> 版本:v3.0
|
||||||
|
> 日期:2026-04-14
|
||||||
|
> 背景更新:目标从"普通桌面地球"升级为"5m 被动偏振 3D LED 大屏 + 动捕交互演示系统"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、真实场景描述
|
||||||
|
|
||||||
|
```
|
||||||
|
领导进入展示间
|
||||||
|
↓
|
||||||
|
5.12m × 2.88m 被动偏振 3D LED 大屏开机
|
||||||
|
↓
|
||||||
|
UE5 实时渲染的 3D 地球从屏幕"飞出"
|
||||||
|
(配合被动 3D 眼镜,数据点有真实景深)
|
||||||
|
↓
|
||||||
|
演示者做手势(无穿戴动捕摄像头捕捉)
|
||||||
|
→ 地球旋转、缩放
|
||||||
|
→ 指向数据点 → 高亮
|
||||||
|
→ 确认手势 → 信息卡弹出(漂浮在屏幕前方)
|
||||||
|
↓
|
||||||
|
鼠标/触控作为备用输入
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、系统架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ RTX 5090 渲染工作站 │
|
||||||
|
│ │
|
||||||
|
│ Planet 后端(FastAPI) │
|
||||||
|
│ ↕ /api/v1/ue/* │
|
||||||
|
│ UE5 主程序(本项目) │
|
||||||
|
│ ├── Cesium 地球 │
|
||||||
|
│ ├── 超算数据点 │
|
||||||
|
│ ├── 其他可交互物件 │
|
||||||
|
│ └── 立体渲染输出 (Side-by-Side / Top-Bottom) │
|
||||||
|
│ │
|
||||||
|
└─────────────┬───────────────────────────────────┘
|
||||||
|
│ HDMI/DP 视频信号
|
||||||
|
▼
|
||||||
|
视频处理器(随屏配套)
|
||||||
|
│ LED 驱动信号(行偏振)
|
||||||
|
▼
|
||||||
|
5.12m × 2.88m 被动偏振 3D LED 大屏
|
||||||
|
↑
|
||||||
|
观众戴无源 3D 眼镜(≤18g,无需充电)
|
||||||
|
|
||||||
|
动捕摄像头(×2)
|
||||||
|
↓ 手势识别(无穿戴)
|
||||||
|
动捕中间件 ─→ UE5(Live Link / OSC,待确认)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、一期交付目标(6 项)
|
||||||
|
|
||||||
|
1. ✅ 地球在 3D 大屏上正确显示,有景深效果
|
||||||
|
2. ✅ 超算数据点散布在地球上,可悬停高亮
|
||||||
|
3. ✅ 点击/手势确认 → 弹出数据点信息卡
|
||||||
|
4. ✅ 鼠标拖拽/手势 → 地球旋转(带惯性)
|
||||||
|
5. ✅ 滚轮/手势 → 缩放
|
||||||
|
6. ⏳ 立体渲染格式配置(等视频处理器格式答复)
|
||||||
|
7. ⏳ 动捕手势接入(等中间件协议答复)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、已完成的代码
|
||||||
|
|
||||||
|
### 后端(`backend/app/api/v1/ue_data.py`)
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /api/v1/ue/status` | 健康检查 |
|
||||||
|
| `GET /api/v1/ue/compute-points` | 超算数据(平铺 JSON)|
|
||||||
|
| `GET /api/v1/ue/landing-points` | 海缆登陆点 |
|
||||||
|
| `GET /api/v1/ue/cables` | 海缆几何 |
|
||||||
|
| `GET /api/v1/ue/satellites` | 卫星 TLE |
|
||||||
|
|
||||||
|
### UE5 C++ 源码(`ue_client/Source/PlanetClient/`)
|
||||||
|
|
||||||
|
| 文件 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `PlanetDataTypes.h` | 数据结构(FComputePoint 等)|
|
||||||
|
| `PlanetDataManager.h/.cpp` | HTTP 拉取 + 本地 mock 数据 + 生成 Actor |
|
||||||
|
| `ComputePointActor.h/.cpp` | 单个超算点 Actor,三态材质 |
|
||||||
|
| `InteractiveObjectBase.h/.cpp` | 所有可交互物件的基类 |
|
||||||
|
| `GlobeInteractionComponent.h/.cpp` | 地球旋转/缩放,带惯性 |
|
||||||
|
| `StereoRenderingManager.h/.cpp` | 立体渲染框架,运行时切换模式 |
|
||||||
|
| `MotionCaptureInterface.h` | 动捕接口定义,协议无关 |
|
||||||
|
| `PlanetPlayerController.h/.cpp` | 统一处理鼠标 + 动捕输入 |
|
||||||
|
| `PlanetGameMode.h/.cpp` | 场景入口,自动初始化管理器 |
|
||||||
|
| `PlanetClient.Build.cs` | 模块依赖(含 TODO 注释)|
|
||||||
|
|
||||||
|
### 数据和配置
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `Content/Data/mock_compute_points.json` | 10 个真实超算的 mock 数据 |
|
||||||
|
| `Config/DefaultGame.ini` | GameMode 配置 |
|
||||||
|
| `Config/DefaultEngine.ini` | 渲染设置 |
|
||||||
|
| `Config/DefaultInput.ini` | 键鼠输入绑定 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、你需要在编辑器里做的操作
|
||||||
|
|
||||||
|
> 完整步骤见 `docs/ue_client_setup_guide.md`
|
||||||
|
|
||||||
|
**最小操作清单(Phase A 本地演示):**
|
||||||
|
|
||||||
|
1. 安装 UE5.3 + Cesium for Unreal 插件
|
||||||
|
2. 打开 `ue_client/PlanetClient.uproject`,等待编译
|
||||||
|
3. 创建空关卡 `EarthMap`,通过 Cesium 菜单添加地球
|
||||||
|
4. 拖入 CesiumDynamicPawn,设置 Auto Possess Player 0
|
||||||
|
5. World Settings → GameMode → PlanetGameMode
|
||||||
|
6. 创建 `BP_ComputePointActor`(父类 `AComputePointActor`),配置球体网格 + 三色材质
|
||||||
|
7. 创建 `BP_PlanetDataManager`,拖入场景,绑定 `ComputePointClass`
|
||||||
|
8. Play → 看到 10 个橙色球体,可悬停 + 点击
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、TODO 项(等供应商答复)
|
||||||
|
|
||||||
|
> 详细对照表见 `docs/ue_todo.md`
|
||||||
|
|
||||||
|
### TODO-1:立体渲染格式
|
||||||
|
- **等待**:视频处理器接受什么 3D 输入格式(SbS / TbB / 行交错)
|
||||||
|
- **代码位置**:`StereoRenderingManager.cpp`
|
||||||
|
- **工作量**:0.5 天
|
||||||
|
|
||||||
|
### TODO-2:动捕协议
|
||||||
|
- **等待**:中间件使用 Live Link / OSC / 私有 SDK
|
||||||
|
- **代码位置**:`MotionCaptureInterface.h`,`PlanetPlayerController.cpp`
|
||||||
|
- **工作量**:Live Link=2h,OSC=1天,私有SDK=1-3天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、后续阶段规划
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| Phase A | 本地 mock 数据,鼠标交互,单目 2D | ✅ 代码就绪 |
|
||||||
|
| Phase B | 接入真实后端 `/api/v1/ue/*` | ✅ 代码就绪 |
|
||||||
|
| Phase C | 立体 3D 输出 | ⏳ 等格式确认 |
|
||||||
|
| Phase D | 动捕手势交互 | ⏳ 等协议确认 |
|
||||||
|
| Phase E | 海缆 Spline 渲染 | 待开发 |
|
||||||
|
| Phase F | 其他可交互物件(基类已就绪) | 待定义 |
|
||||||
319
docs/ue_client_setup_guide.md
Normal file
319
docs/ue_client_setup_guide.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# UE5 客户端手动操作指南
|
||||||
|
|
||||||
|
> 本指南对应 `ue_client/` 目录下已生成的所有代码和配置。
|
||||||
|
> 代码已写好,你只需要做编辑器里的点击操作。
|
||||||
|
> 遇到红色错误先看文末"常见问题"章节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
| 软件 | 版本 | 下载地址 |
|
||||||
|
|------|------|---------|
|
||||||
|
| Unreal Engine | **5.3** | Epic Games Launcher → Library → 5.3 |
|
||||||
|
| Cesium for Unreal | 最新 | 直接在下一步从 Marketplace 安装 |
|
||||||
|
| Visual Studio | 2022 Community | visualstudio.microsoft.com(安装 C++ 游戏开发工作负载) |
|
||||||
|
|
||||||
|
> **注意**:Cesium for Unreal 必须先安装,否则代码会编译失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一阶段(Phase A):本地演示版(不需要后端)
|
||||||
|
|
||||||
|
### 步骤 1:安装 Cesium for Unreal
|
||||||
|
|
||||||
|
1. 打开 **Epic Games Launcher**
|
||||||
|
2. 顶部切换到 **Unreal Engine** 选项卡
|
||||||
|
3. 左侧点击 **Fab**(原 Marketplace,已改名)→ 搜索 `Cesium for Unreal`
|
||||||
|
4. 点击 **免费获取**(Free),然后点击 **安装到引擎** → 选择 5.3
|
||||||
|
5. 等待安装完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 2:打开项目
|
||||||
|
|
||||||
|
1. 打开 **Epic Games Launcher** → **Unreal Engine** → **Library**
|
||||||
|
2. 找到 5.3,点击右侧 **Launch** 旁边的下拉箭头 → **Browse**
|
||||||
|
3. 导航到 `planet/ue_client/`,选择 `PlanetClient.uproject`,点击打开
|
||||||
|
4. UE 会提示"缺少模块,需要重新编译" → 点击 **Yes**
|
||||||
|
5. 等待编译完成(首次约 5-10 分钟)
|
||||||
|
|
||||||
|
> 如果编译报错:见文末"常见问题 → 编译错误"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 3:创建新关卡
|
||||||
|
|
||||||
|
1. 菜单栏 → **File** → **New Level**
|
||||||
|
2. 选择 **Empty Level**(空关卡)
|
||||||
|
3. 保存:**File** → **Save Current Level As**
|
||||||
|
路径:`Content/Maps/`,名称:`EarthMap`
|
||||||
|
4. 点击 **Save**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 4:添加 Cesium 地球
|
||||||
|
|
||||||
|
1. 顶部菜单栏 → **Cesium**(如果没有此菜单,说明插件未激活:Edit → Plugins → 搜索 Cesium → 勾选 Enable → 重启)
|
||||||
|
2. 在 Cesium 面板里点击 **Add Blank 3D Tiles Tileset** — 这会自动在场景里添加:
|
||||||
|
- `CesiumGeoreference` Actor
|
||||||
|
- `Cesium3DTileset` Actor(地球瓦片)
|
||||||
|
3. 再点击 **Add Cesium ion Bing Maps Aerial** 添加卫星影像底图(需要免费 Cesium ion 账号)
|
||||||
|
|
||||||
|
> 如果没有 Cesium ion 账号:Cesium 菜单 → Connect to Cesium ion → 注册免费账号
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 5:添加相机 Pawn
|
||||||
|
|
||||||
|
1. 菜单栏 → **Cesium** → 找到 **Dynamic Pawn**(名称可能是 `CesiumFlyToComponent` 相关的 Blueprint)
|
||||||
|
**或者**:Content Browser → 顶部搜索框输入 `DynamicPawn` → 找到插件内容里的 `DynamicPawn` → 拖入场景
|
||||||
|
2. 在 **Outliner** 面板里点击刚拖入的 `DynamicPawn`
|
||||||
|
3. 在 **Details** 面板里,找到 **Auto Possess Player** → 改为 **Player 0**
|
||||||
|
|
||||||
|
> 这样游戏启动时摄像机会自动使用这个可飞行的地球相机。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 6:配置 GameMode
|
||||||
|
|
||||||
|
1. 菜单栏 → **Window** → **World Settings**(如果没有,也可以在 Details Panel 里找)
|
||||||
|
2. 在 **World Settings** 面板里找到 **Game Mode Override**
|
||||||
|
3. 点击下拉框 → 搜索 `PlanetGameMode` → 选择它
|
||||||
|
4. 找到 **Default Pawn Class** → 改为上一步拖入的 `DynamicPawn`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 7:创建 ComputePoint Blueprint
|
||||||
|
|
||||||
|
这步把我写的 C++ 类包装成可以在编辑器里配置材质的 Blueprint。
|
||||||
|
|
||||||
|
1. **Content Browser** → 空白处右键 → **Blueprint Class**
|
||||||
|
2. 搜索父类:输入 `ComputePointActor` → 找到 `AComputePointActor` → 点击 **Select**
|
||||||
|
3. 命名为 `BP_ComputePointActor`,保存到 `Content/Blueprints/`
|
||||||
|
|
||||||
|
**配置 BP_ComputePointActor 的网格和材质:**
|
||||||
|
|
||||||
|
4. 双击打开 `BP_ComputePointActor`
|
||||||
|
5. 在左侧 **Components** 面板里点击 `SphereMesh`
|
||||||
|
6. 在右侧 **Details** 面板里找到 **Static Mesh** → 点击下拉 → 搜索 `Sphere` → 选择 **Engine/BasicShapes/Sphere**
|
||||||
|
7. 找到 **Material** → 点击下拉 → 搜索 `M_Basic_Wall` 或者创建新材质(见下方)
|
||||||
|
|
||||||
|
**创建三种状态的材质(颜色点即可):**
|
||||||
|
|
||||||
|
8. Content Browser → 右键 → **Material** → 命名 `M_PointNormal`
|
||||||
|
- 双击打开 → 右键空白区域 → 搜索 `Constant3Vector` → 连接到 `Base Color`
|
||||||
|
- 颜色设为橙色:`(1.0, 0.4, 0.0)`
|
||||||
|
- 保存
|
||||||
|
|
||||||
|
9. 同样方式创建 `M_PointHovered`(颜色白色 `1,1,1`)和 `M_PointSelected`(颜色青色 `0,1,1`)
|
||||||
|
|
||||||
|
**回到 BP_ComputePointActor:**
|
||||||
|
|
||||||
|
10. 在 **Details** 面板里:
|
||||||
|
- **Normal Material** → 选 `M_PointNormal`
|
||||||
|
- **Hovered Material** → 选 `M_PointHovered`
|
||||||
|
- **Selected Material** → 选 `M_PointSelected`
|
||||||
|
- **Point Scale** → `80000`(根据实际效果调整)
|
||||||
|
11. 点击左上角 **Compile** → **Save**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 8:放置 DataManager 并配置
|
||||||
|
|
||||||
|
1. **Content Browser** → 右键 → **Blueprint Class** → 父类搜索 `PlanetDataManager` → 选择 `APlanetDataManager`
|
||||||
|
2. 命名为 `BP_PlanetDataManager`,保存到 `Content/Blueprints/`
|
||||||
|
3. 将 `BP_PlanetDataManager` **拖入场景**(Outliner 里会出现它)
|
||||||
|
4. 在 Outliner 里点击它 → 在 **Details** 面板里配置:
|
||||||
|
- **Use Local Mock Data** → ✅ **勾选**(Phase A 不需要后端)
|
||||||
|
- **Mock Data Path** → 留空(代码会自动找 `Content/Data/mock_compute_points.json`)
|
||||||
|
- **Compute Point Class** → 选择 `BP_ComputePointActor`
|
||||||
|
- **Point Altitude Meters** → `50000`(海拔 50km,可调)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 9:测试 Phase A
|
||||||
|
|
||||||
|
1. 点击顶部工具栏绿色 **Play** 按钮(或 Alt+P)
|
||||||
|
2. 地球应该加载卫星影像
|
||||||
|
3. 应该看到 10 个橙色球体分布在地球上(对应 mock JSON 里的 TOP500 超算)
|
||||||
|
4. 鼠标移到球体上 → 变白色(Hover)
|
||||||
|
5. 点击球体 → 变青色(Selected)
|
||||||
|
|
||||||
|
**验证通过标准:**
|
||||||
|
- [x] 地球可见
|
||||||
|
- [x] 橙色球体出现在正确位置(美国、日本、荷兰、芬兰等)
|
||||||
|
- [x] 悬停变色
|
||||||
|
- [x] 点击变色
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二阶段(Phase B):连接真实后端
|
||||||
|
|
||||||
|
### 步骤 10:确认后端新接口可用
|
||||||
|
|
||||||
|
后端代码已添加新路由,先验证它已经运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 WSL 或终端里
|
||||||
|
curl http://localhost:8000/api/v1/ue/status
|
||||||
|
```
|
||||||
|
|
||||||
|
应该返回类似:
|
||||||
|
```json
|
||||||
|
{"ok": true, "server_time": "...", "compute_points_count": 500, ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 curl 失败:
|
||||||
|
- 检查 `planet.sh` 是否在运行(`./planet.sh start`)
|
||||||
|
- 检查 WSL2 → Windows 的网络:在 UE 里使用 `172.x.x.x`(WSL 网关地址)而不是 `localhost`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 11:获取 WSL2 → Windows 的正确 IP
|
||||||
|
|
||||||
|
在 WSL 终端里运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /etc/resolv.conf | grep nameserver | awk '{print $2}'
|
||||||
|
```
|
||||||
|
|
||||||
|
记下这个 IP(例如 `172.22.32.1`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 12:切换 DataManager 到实时模式
|
||||||
|
|
||||||
|
1. 在 Outliner 里点击 `BP_PlanetDataManager`
|
||||||
|
2. Details 面板里:
|
||||||
|
- **Use Local Mock Data** → **取消勾选**
|
||||||
|
- **Backend Base URL** → 填入 `http://172.22.32.1:8000`(你的实际 WSL IP)
|
||||||
|
3. 重新 Play → DataManager 会通过 HTTP 拉取真实数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 13:绑定点击事件显示信息卡(可选,需要 UMG)
|
||||||
|
|
||||||
|
这步是可选的,需要创建一个 Widget Blueprint 来显示选中点的信息。
|
||||||
|
|
||||||
|
1. Content Browser → 右键 → **User Interface** → **Widget Blueprint**
|
||||||
|
2. 命名为 `WBP_PointInfo`
|
||||||
|
|
||||||
|
**设计 Widget 布局:**
|
||||||
|
|
||||||
|
3. 双击打开 `WBP_PointInfo`
|
||||||
|
4. 从左侧 **Palette** 拖入以下控件到画布:
|
||||||
|
- `Canvas Panel`(容器,设置为全屏)
|
||||||
|
- `Border`(右下角定位,用作信息卡背景,宽 300,高 200)
|
||||||
|
- `Text Block` × 4(名称、排名、算力、国家)
|
||||||
|
|
||||||
|
**绑定事件(Blueprint 里操作):**
|
||||||
|
|
||||||
|
5. 打开 `BP_PlanetDataManager` 的 Event Graph
|
||||||
|
6. 找到 **BeginPlay** 节点
|
||||||
|
7. 拖出线 → 搜索 **Bind Event to On Point Selected**(这是我在 PlayerController 里定义的委托)
|
||||||
|
|
||||||
|
> 具体蓝图连线:从 PlayerController 获取 OnPointSelected → Bind → 在回调里 Create Widget WBP_PointInfo → Add to Viewport → Set 各个文本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
ue_client/
|
||||||
|
PlanetClient.uproject ← UE 项目入口
|
||||||
|
Config/
|
||||||
|
DefaultGame.ini ← GameMode 配置
|
||||||
|
DefaultEngine.ini ← 渲染/引擎设置
|
||||||
|
DefaultInput.ini ← 键鼠输入绑定
|
||||||
|
Content/
|
||||||
|
Data/
|
||||||
|
mock_compute_points.json ← Phase A 本地测试数据(10个超算)
|
||||||
|
Maps/
|
||||||
|
EarthMap.umap ← 你在步骤3创建的关卡
|
||||||
|
Blueprints/
|
||||||
|
BP_ComputePointActor ← 步骤7创建
|
||||||
|
BP_PlanetDataManager ← 步骤8创建
|
||||||
|
Source/
|
||||||
|
PlanetClient/
|
||||||
|
PlanetClient.Build.cs ← 模块依赖(HTTP、JSON、Cesium)
|
||||||
|
PlanetClient.h/.cpp ← 模块入口
|
||||||
|
PlanetDataTypes.h ← 数据结构定义(FComputePoint 等)
|
||||||
|
PlanetDataManager.h/.cpp ← HTTP 拉取 + 生成 Actor
|
||||||
|
ComputePointActor.h/.cpp ← 单个超算点的可视化 Actor
|
||||||
|
PlanetPlayerController.h/.cpp ← 鼠标点击、悬停、相机控制
|
||||||
|
PlanetGameMode.h/.cpp ← GameMode 入口
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后端新接口一览
|
||||||
|
|
||||||
|
后端已新增以下接口(无需认证,直接访问):
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /api/v1/ue/status` | 健康检查 + 各数据源数量 |
|
||||||
|
| `GET /api/v1/ue/compute-points` | TOP500 超算数据(平铺 JSON) |
|
||||||
|
| `GET /api/v1/ue/landing-points` | 海缆登陆点(平铺 JSON) |
|
||||||
|
| `GET /api/v1/ue/cables` | 海缆路由几何(segments 数组) |
|
||||||
|
| `GET /api/v1/ue/satellites` | 卫星 TLE 数据 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 编译报错 "Cannot open include file: CesiumGeoreference.h"
|
||||||
|
**A:** Cesium for Unreal 没有正确安装,或者没有在 `.uproject` 里启用。检查:
|
||||||
|
1. Epic Launcher → 插件是否安装到 5.3
|
||||||
|
2. `PlanetClient.uproject` 里 `Plugins` 数组是否有 `CesiumForUnreal: true`
|
||||||
|
3. UE 编辑器 → Edit → Plugins → 搜索 Cesium → 确认已勾选 Enabled
|
||||||
|
|
||||||
|
### Q: Play 之后没有看到橙色球体
|
||||||
|
**A:** 按以下顺序排查:
|
||||||
|
1. Output Log(Window → Output Log)里搜索 `PlanetDataManager` — 查看是否有报错
|
||||||
|
2. 检查 `BP_PlanetDataManager` 的 Details → **Compute Point Class** 是否已设置为 `BP_ComputePointActor`
|
||||||
|
3. 检查 **Mock Data Path** 是否正确(留空则自动用 `Content/Data/mock_compute_points.json`)
|
||||||
|
4. 检查 **Use Local Mock Data** 是否已勾选
|
||||||
|
|
||||||
|
### Q: 地球是灰色的没有卫星影像
|
||||||
|
**A:** 需要 Cesium ion 账号:
|
||||||
|
1. Cesium 菜单 → Connect to Cesium ion
|
||||||
|
2. 注册免费账号并授权
|
||||||
|
3. 重新添加 **Cesium ion Bing Maps Aerial** tileset
|
||||||
|
|
||||||
|
### Q: WSL2 里的后端 UE 无法访问(Phase B)
|
||||||
|
**A:** WSL2 和 Windows 是不同网络命名空间。方法:
|
||||||
|
1. 在 WSL 里运行 `ip route show default | awk '{print $3}'` — 这是 Windows 主机的 IP
|
||||||
|
2. 后端绑定到 `0.0.0.0:8000`(检查 `uvicorn` 启动参数,应已如此配置)
|
||||||
|
3. 在 UE 的 DataManager 里填写这个 IP 而不是 `localhost`
|
||||||
|
|
||||||
|
### Q: TransformLongitudeLatitudeHeightPositionToUnreal 不存在
|
||||||
|
**A:** Cesium for Unreal API 在不同版本有变化。如果编译报此错,将 `PlanetDataManager.cpp` 里的调用改为:
|
||||||
|
```cpp
|
||||||
|
// Cesium for Unreal v1.x 的旧 API:
|
||||||
|
FVector WorldPos = Georeference->TransformLongitudeLatitudeHeightToUnreal(
|
||||||
|
Pt.Longitude, Pt.Latitude, PointAltitudeMeters);
|
||||||
|
|
||||||
|
// 或者通过 GeoTransforms:
|
||||||
|
#include "CesiumGlobeAnchorComponent.h"
|
||||||
|
// ... 见 Cesium 文档
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 点击球体没有反应
|
||||||
|
**A:**
|
||||||
|
1. 确认 `SphereMesh` 上 **Collision Presets** 不是 `NoCollision`
|
||||||
|
- 打开 `BP_ComputePointActor` → 点击 `SphereMesh` → Details → Collision → 改为 `BlockAllDynamic`
|
||||||
|
2. 确认 PlayerController 的 `bEnableClickEvents = true`(代码里已设置)
|
||||||
|
3. Output Log 里搜索是否有输入相关报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步(一期完成后)
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 海缆路径渲染 | `/api/v1/ue/cables` 已就绪,需要在 UE 里用 Spline 绘制 |
|
||||||
|
| 信息卡 UMG | 创建 Widget Blueprint 并在 PlayerController OnPointSelected 里显示 |
|
||||||
|
| WebSocket 实时更新 | 后端已有 WebSocket,UE 端需要使用 WebSockets 插件 |
|
||||||
|
| 卫星轨迹 | TLE 数据已就绪,需要在 UE 里做轨道传播计算 |
|
||||||
90
docs/ue_todo.md
Normal file
90
docs/ue_todo.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# UE5 客户端 — 待供应商回复的 TODO
|
||||||
|
|
||||||
|
> 以下两个问题答复后,对应代码可在一天内完成。
|
||||||
|
> 其余所有代码均已写好,不依赖这两个答案。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO-1:视频处理器输入格式
|
||||||
|
|
||||||
|
**等待信息**:LED 屏配套视频处理器(如诺瓦星云)接受什么 3D 信号格式?
|
||||||
|
|
||||||
|
| 可能答案 | 对应操作 |
|
||||||
|
|---------|---------|
|
||||||
|
| Side-by-Side(左右并排) | `StereoRenderingManager.cpp` 里 `EnableStereo` 的 `SideBySide` 分支已写好,直接启用 |
|
||||||
|
| Top-Bottom(上下叠加) | 同上,切换到 `TopBottom` 分支 |
|
||||||
|
| 行交错(行偏振直驱) | 需要新写一个 PostProcess Material,把左右眼奇偶行合并输出 |
|
||||||
|
| 私有协议 | 需要供应商提供 UE5 插件或信号格式文档 |
|
||||||
|
|
||||||
|
**代码位置**:
|
||||||
|
```
|
||||||
|
ue_client/Source/PlanetClient/StereoRenderingManager.h — 第 8-18 行 TODO 注释
|
||||||
|
ue_client/Source/PlanetClient/StereoRenderingManager.cpp — EnableStereo() 函数
|
||||||
|
```
|
||||||
|
|
||||||
|
**需要同时确认**:
|
||||||
|
- 视频处理器品牌和型号
|
||||||
|
- 屏幕物理分辨率(用于配置 UE5 输出分辨率)
|
||||||
|
- 3D 启用时是否需要特殊信号时序(如 3D Frame Packing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO-2:动捕角色接入
|
||||||
|
|
||||||
|
**已确认**:
|
||||||
|
- **本周末前**:供应商交付含动捕插件 + 3D 角色(已配好绑定和重定向)的基础 UE5 工程
|
||||||
|
- 接入两台摄像头,4080 以上显卡即可直接运行动捕调试
|
||||||
|
- **之后尽快**:动画资产单独交付,复制到工程 Content/ 目录即可直接调用
|
||||||
|
- **我们的任务**:把他们工程内容迁移进 `ue_client/`,把角色动作映射到地球操作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 A:拿到基础工程后(本周末)
|
||||||
|
|
||||||
|
**迁移步骤**:
|
||||||
|
|
||||||
|
1. **迁移动捕插件**
|
||||||
|
- 从供应商工程 `Plugins/` 拷到 `ue_client/Plugins/`
|
||||||
|
- `PlanetClient.uproject` 的 `Plugins` 数组添加插件条目(`Enabled: true`)
|
||||||
|
- `PlanetClient.Build.cs` 的 `PublicDependencyModuleNames` 加插件模块名
|
||||||
|
|
||||||
|
2. **迁移角色**
|
||||||
|
- 把角色 Blueprint、动画、骨骼网格从供应商 `Content/` 拷到 `ue_client/Content/`
|
||||||
|
- 在关卡里放置角色 Actor,确认摄像头接入后能正常驱动
|
||||||
|
|
||||||
|
3. **接入动作触发(关键,看到工程后确认方式)**:
|
||||||
|
|
||||||
|
| 需要确认的内容 | 用途 |
|
||||||
|
|-------------|------|
|
||||||
|
| 角色动作怎么暴露给外部? | Blueprint Custom Event / AnimNotify / 骨骼姿态变量? |
|
||||||
|
| 手势集合有哪些? | 填写 `EMotionGesture` 枚举,配置 `FMotionActionMapping` |
|
||||||
|
| 是否持续输出旋转增量? | 旋转地球用"持续增量"还是"离散手势触发" |
|
||||||
|
| 插件模块名称(`ModuleName`)| 加入 `Build.cs` 依赖 |
|
||||||
|
|
||||||
|
**拿到工程后(我来做)**:
|
||||||
|
- 用插件实际 API 实现 `UMotionCaptureReceiver` 子类
|
||||||
|
- 在 `PlanetPlayerController::BeginPlay` 里取消注释 `BindMotionCaptureEvents()`
|
||||||
|
|
||||||
|
**代码位置(接口已预留)**:
|
||||||
|
```
|
||||||
|
ue_client/Source/PlanetClient/MotionCaptureInterface.h — 基类和手势枚举
|
||||||
|
ue_client/Source/PlanetClient/PlanetPlayerController.cpp — BindMotionCaptureEvents()
|
||||||
|
ue_client/Source/PlanetClient/PlanetClient.Build.cs — TODO 注释处加插件模块名
|
||||||
|
ue_client/PlanetClient.uproject — Plugins 数组加插件条目
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 B:动画资产到位后
|
||||||
|
|
||||||
|
- 把供应商提供的动画资产直接复制到 `ue_client/Content/` 对应目录
|
||||||
|
- 在 `PlanetDataManager` / 场景 Actor 里引用这些资产即可调用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 答复到位后的操作清单
|
||||||
|
|
||||||
|
拿到答案后告诉我,我来:
|
||||||
|
|
||||||
|
1. **视频处理器格式** → 配置 `StereoRenderingManager`,把 `bAutoEnableOnPlay` 改为 `true`,写进 setup guide
|
||||||
|
2. **动捕插件** → 插件放入 `ue_client/Plugins/`,实现 `UMotionCaptureReceiver` 子类对接插件 API,更新 `Build.cs` 和 `.uproject`
|
||||||
@@ -16,12 +16,17 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.27.1`
|
- `dev` 当前开发分支历史推导到:`0.27.6`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| Version | Type | Branch | Commit | Summary |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复,Playground 响应式按钮与输入框收起优化 |
|
||||||
|
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
|
||||||
|
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |
|
||||||
|
| `0.27.3` | improvement | `dev` | — | TV panel 折叠方向稳定、视频跳动修复、图例折叠按钮修复、搜索图标调整 |
|
||||||
|
| `0.27.2` | improvement | `dev` | — | 修复 brand copy 宽度问题,提取 --brand-copy-width CSS 变量 |
|
||||||
| `0.27.1` | improvement | `dev` | — | HUD 面板拖拽 L 形边界约束、brand 组件整体缩放、图层面板宽度优化、搜索叉叉修复 |
|
| `0.27.1` | improvement | `dev` | — | HUD 面板拖拽 L 形边界约束、brand 组件整体缩放、图层面板宽度优化、搜索叉叉修复 |
|
||||||
| `0.27.0` | feature | `dev` | — | Earth HUD 重构:图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 |
|
| `0.27.0` | feature | `dev` | — | Earth HUD 重构:图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 |
|
||||||
| `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit |
|
| `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.27.1",
|
"version": "0.27.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
|
|
||||||
.hud-panel-header .hud-panel-title {
|
.hud-panel-header .hud-panel-title {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
color: var(--hud-text-soft);
|
||||||
|
font-size: calc(0.82rem * var(--hud-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hud-panel-drag-handle {
|
.hud-panel-drag-handle {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
.hud-panel-brand {
|
.hud-panel-brand {
|
||||||
--brand-scale: 0.88;
|
--brand-scale: 0.88;
|
||||||
|
--brand-copy-width: 160px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
|
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -51,16 +52,16 @@
|
|||||||
|
|
||||||
.hud-panel-brand .earth-brand__copy {
|
.hud-panel-brand .earth-brand__copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: calc(5px * var(--hud-scale) * var(--brand-scale));
|
gap: calc(5px * var(--hud-scale) * var(--brand-scale));
|
||||||
|
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hud-panel-brand .earth-brand__title {
|
.hud-panel-brand .earth-brand__title {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(100%, calc(160px * var(--hud-scale) * var(--brand-scale)));
|
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: calc(20px * var(--hud-scale) * var(--brand-scale));
|
min-height: calc(20px * var(--hud-scale) * var(--brand-scale));
|
||||||
@@ -80,9 +81,6 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
/* Prevent text from pushing brand wider than logo column */
|
|
||||||
width: fit-content;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -93,15 +91,21 @@
|
|||||||
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
|
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
width: fit-content;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hud-panel-brand .earth-brand--en {
|
||||||
|
--brand-copy-width: 172px;
|
||||||
|
}
|
||||||
|
|
||||||
.hud-panel-brand .earth-brand--en .earth-brand__title {
|
.hud-panel-brand .earth-brand--en .earth-brand__title {
|
||||||
width: min(100%, calc(172px * var(--hud-scale) * var(--brand-scale)));
|
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-panel-brand .earth-brand--en .earth-brand__copy {
|
||||||
|
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hud-panel-brand .earth-brand--en .earth-brand__subtitle,
|
.hud-panel-brand .earth-brand--en .earth-brand__subtitle,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
.layer-panel-title {
|
.layer-panel-title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--hud-title);
|
color: var(--hud-text-soft);
|
||||||
font-size: calc(0.82rem * var(--hud-scale));
|
font-size: calc(0.82rem * var(--hud-scale));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
@@ -76,30 +76,51 @@
|
|||||||
transition: transform 0.22s ease;
|
transition: transform 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chevron rotates when collapsed */
|
/* Chevron:展开时朝上(可折叠),折叠时朝下(可展开) */
|
||||||
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
|
.layer-panel-btn .material-symbols-rounded {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Search bar ───────────────────────────────────────────────── */
|
/* ── Search bar ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
.layer-panel-search {
|
.layer-panel-search {
|
||||||
|
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||||
|
border-bottom: 1px solid var(--hud-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-panel-search-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: calc(5px * var(--hud-scale));
|
gap: calc(5px * var(--hud-scale));
|
||||||
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
padding: calc(5px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||||
border-bottom: 1px solid var(--hud-line);
|
border: 1px solid rgba(201, 225, 247, 0.14);
|
||||||
|
border-radius: calc(8px * var(--hud-scale));
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
transition: border-color 0.18s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: calc(38px * var(--hud-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-panel-search-box:focus-within {
|
||||||
|
border-color: rgba(201, 225, 247, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-panel-search-icon {
|
.layer-panel-search-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: calc(14px * var(--hud-scale));
|
|
||||||
color: var(--hud-text-soft);
|
color: var(--hud-text-soft);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layer-panel-search-icon.material-symbols-rounded {
|
||||||
|
font-size: calc(20px * var(--hud-scale));
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||||
|
}
|
||||||
|
|
||||||
.layer-panel-search-input {
|
.layer-panel-search-input {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -6,17 +6,34 @@
|
|||||||
width: calc(420px * var(--hud-scale));
|
width: calc(420px * var(--hud-scale));
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
min-width: calc(300px * var(--hud-scale));
|
min-width: calc(300px * var(--hud-scale));
|
||||||
min-height: calc(340px * var(--hud-scale));
|
padding: calc(10px * var(--hud-scale));
|
||||||
padding: calc(18px * var(--hud-scale));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--hud-gap-sm);
|
gap: var(--hud-gap-sm);
|
||||||
z-index: 18;
|
z-index: 18;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-header-copy {
|
/* header 内嵌 select + actions */
|
||||||
display: grid;
|
.hud-panel-tv .hud-panel-header {
|
||||||
gap: calc(3px * var(--hud-scale));
|
align-items: center;
|
||||||
|
gap: var(--hud-gap-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-panel-tv .hud-panel-header .hud-panel-close {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-panel-header-title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* select / action 在 drag-handle 内,恢复正常指针 */
|
||||||
|
.hud-panel-tv .hud-panel-header .tv-panel-select,
|
||||||
|
.hud-panel-tv .hud-panel-header .tv-panel-action {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-status {
|
.tv-panel-status {
|
||||||
@@ -26,13 +43,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--hud-gap-sm);
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tv-panel-select {
|
.tv-panel-select {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -59,28 +69,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-action {
|
.tv-panel-action {
|
||||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
border: 1px solid transparent;
|
||||||
border-radius: calc(12px * var(--hud-scale));
|
border-radius: calc(4px * var(--hud-scale));
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: transparent;
|
||||||
color: var(--hud-text);
|
color: var(--hud-text-muted);
|
||||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
padding: calc(7px * var(--hud-scale));
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
white-space: nowrap;
|
|
||||||
font-size: calc(0.84rem * var(--hud-scale));
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
transition:
|
||||||
}
|
background 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
.tv-panel-action--icon {
|
color 0.18s ease,
|
||||||
padding: calc(9px * var(--hud-scale));
|
transform 0.18s ease,
|
||||||
border-radius: calc(10px * var(--hud-scale));
|
opacity 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-action--icon .material-symbols-rounded {
|
.tv-panel-action--icon .material-symbols-rounded {
|
||||||
font-size: calc(18px * var(--hud-scale));
|
font-size: calc(16px * var(--hud-scale));
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -88,7 +95,7 @@
|
|||||||
|
|
||||||
.tv-panel-action:hover:not(:disabled) {
|
.tv-panel-action:hover:not(:disabled) {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border-color: rgba(225, 239, 255, 0.2);
|
border-color: rgba(225, 239, 255, 0.14);
|
||||||
color: var(--hud-accent-strong);
|
color: var(--hud-accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +104,32 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* toggle 箭头:展开时朝下,折叠时朝上 */
|
||||||
|
.tv-panel-meta-toggle .material-symbols-rounded {
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-panel-meta-toggle.is-collapsed .material-symbols-rounded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* meta wrap:折叠时用负 margin 抵消 flex gap,无死区 */
|
||||||
|
.tv-panel-meta-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: calc(120px * var(--hud-scale));
|
||||||
|
opacity: 1;
|
||||||
|
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-panel-meta-wrap.is-collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
margin-top: calc(-1 * var(--hud-gap-sm));
|
||||||
|
margin-bottom: calc(-1 * var(--hud-gap-sm));
|
||||||
|
}
|
||||||
|
|
||||||
.tv-panel-meta {
|
.tv-panel-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: calc(4px * var(--hud-scale));
|
gap: calc(4px * var(--hud-scale));
|
||||||
@@ -129,7 +162,7 @@
|
|||||||
|
|
||||||
.tv-panel-player {
|
.tv-panel-player {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
flex: 1 0 auto;
|
||||||
min-height: calc(220px * var(--hud-scale));
|
min-height: calc(220px * var(--hud-scale));
|
||||||
border-radius: calc(16px * var(--hud-scale));
|
border-radius: calc(16px * var(--hud-scale));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -163,33 +196,65 @@
|
|||||||
background: #050a14;
|
background: #050a14;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-resize-handle {
|
/* ── Multi-edge resize handles ───────────────────────────────── */
|
||||||
|
|
||||||
|
.tv-panel-edge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: calc(8px * var(--hud-scale));
|
z-index: 10;
|
||||||
bottom: calc(8px * var(--hud-scale));
|
|
||||||
width: calc(18px * var(--hud-scale));
|
|
||||||
height: calc(18px * var(--hud-scale));
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: nwse-resize;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-resize-handle::before {
|
.tv-panel-edge[data-edge="r"] {
|
||||||
|
right: 0;
|
||||||
|
top: calc(12px * var(--hud-scale));
|
||||||
|
bottom: calc(12px * var(--hud-scale));
|
||||||
|
width: calc(6px * var(--hud-scale));
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-panel-edge[data-edge="b"] {
|
||||||
|
bottom: 0;
|
||||||
|
left: calc(12px * var(--hud-scale));
|
||||||
|
right: calc(12px * var(--hud-scale));
|
||||||
|
height: calc(6px * var(--hud-scale));
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-panel-edge[data-edge="l"] {
|
||||||
|
left: 0;
|
||||||
|
top: calc(12px * var(--hud-scale));
|
||||||
|
bottom: calc(12px * var(--hud-scale));
|
||||||
|
width: calc(6px * var(--hud-scale));
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-panel-edge[data-edge="br"] {
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: calc(20px * var(--hud-scale));
|
||||||
|
height: calc(20px * var(--hud-scale));
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-panel-edge[data-edge="bl"] {
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: calc(20px * var(--hud-scale));
|
||||||
|
height: calc(20px * var(--hud-scale));
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右下角视觉标记 */
|
||||||
|
.tv-panel-edge[data-edge="br"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: calc(4px * var(--hud-scale));
|
||||||
border-right: 2px solid rgba(223, 235, 248, 0.46);
|
border-right: 2px solid rgba(223, 235, 248, 0.4);
|
||||||
border-bottom: 2px solid rgba(223, 235, 248, 0.46);
|
border-bottom: 2px solid rgba(223, 235, 248, 0.4);
|
||||||
border-bottom-right-radius: calc(10px * var(--hud-scale));
|
transition: border-color 0.18s ease;
|
||||||
opacity: 0.78;
|
|
||||||
transition: opacity 0.18s ease, border-color 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-resize-handle:hover::before {
|
.tv-panel-edge[data-edge="br"]:hover::before {
|
||||||
opacity: 1;
|
border-color: rgba(244, 249, 255, 0.75);
|
||||||
border-color: rgba(244, 249, 255, 0.78);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hud-panel-tv.is-resizing {
|
.hud-panel-tv.is-resizing {
|
||||||
@@ -197,6 +262,25 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Whole panel is draggable; player overrides back to default */
|
||||||
|
.hud-panel-tv:not(.is-resizing) {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-panel-tv.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-panel-tv .tv-panel-player {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable iframe/video pointer capture while dragging so mouse events pass through */
|
||||||
|
.hud-panel-tv.is-dragging .tv-panel-iframe,
|
||||||
|
.hud-panel-tv.is-dragging .tv-panel-video {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
|
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
|
||||||
bottom: var(--hud-offset);
|
bottom: var(--hud-offset);
|
||||||
right: var(--hud-offset);
|
right: var(--hud-offset);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
<div id="container" class="earth-app">
|
<div id="container" class="earth-app">
|
||||||
<div class="earth-left-column">
|
<div id="left-column" class="earth-left-column">
|
||||||
<div id="brand-panel" class="hud-panel hud-panel-brand">
|
<div id="brand-panel" class="hud-panel hud-panel-brand">
|
||||||
<div id="brand-root"></div>
|
<div id="brand-root"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
<div class="layer-panel-body" id="layer-panel-body">
|
<div class="layer-panel-body" id="layer-panel-body">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="layer-panel-search">
|
<div class="layer-panel-search">
|
||||||
|
<div class="layer-panel-search-box">
|
||||||
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
<span class="material-symbols-rounded">close</span>
|
<span class="material-symbols-rounded">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Layer rows -->
|
<!-- Layer rows -->
|
||||||
<div class="layer-panel-list" id="layer-panel-list">
|
<div class="layer-panel-list" id="layer-panel-list">
|
||||||
@@ -143,20 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating detail panel — positioned near click by JS -->
|
|
||||||
<div id="info-panel" class="hud-panel hud-panel-info hud-panel-draggable" aria-live="polite">
|
|
||||||
<div id="info-card" class="info-card">
|
|
||||||
<div class="info-card-header hud-panel-drag-handle">
|
|
||||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
|
||||||
<h3 id="info-card-title">详情</h3>
|
|
||||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
|
||||||
<span class="material-symbols-rounded">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="info-card-content" class="info-card-content"></div>
|
|
||||||
</div>
|
|
||||||
<div id="error-message" class="hud-error-message"></div>
|
<div id="error-message" class="hud-error-message"></div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||||
<div id="control-toolbar" class="earth-toolbar">
|
<div id="control-toolbar" class="earth-toolbar">
|
||||||
@@ -299,17 +288,9 @@
|
|||||||
<span id="camera-distance" hidden></span>
|
<span id="camera-distance" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel">
|
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel" data-drag-self="true">
|
||||||
<div class="hud-panel-header hud-panel-drag-handle">
|
<div class="hud-panel-header hud-panel-drag-handle">
|
||||||
<div class="tv-panel-header-copy">
|
<span class="hud-panel-title tv-panel-header-title">新闻直播</span>
|
||||||
<h3 class="hud-panel-title">新闻直播</h3>
|
|
||||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
|
||||||
</div>
|
|
||||||
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
|
|
||||||
<span class="material-symbols-rounded">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tv-panel-controls">
|
|
||||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||||
<div class="tv-panel-actions">
|
<div class="tv-panel-actions">
|
||||||
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
|
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||||
@@ -318,14 +299,23 @@
|
|||||||
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
|
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
|
||||||
<span class="material-symbols-rounded">open_in_new</span>
|
<span class="material-symbols-rounded">open_in_new</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="tv-meta-toggle" class="tv-panel-action tv-panel-action--icon tv-panel-meta-toggle" type="button" title="频道信息" aria-label="频道信息">
|
||||||
|
<span class="material-symbols-rounded">expand_more</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tv-panel-meta">
|
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
|
||||||
|
<div class="tv-panel-meta" id="tv-panel-meta">
|
||||||
|
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="tv-panel-player">
|
<div class="tv-panel-player">
|
||||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||||
<iframe
|
<iframe
|
||||||
@@ -338,13 +328,11 @@
|
|||||||
></iframe>
|
></iframe>
|
||||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="tv-panel-edge" data-edge="r"></div>
|
||||||
id="tv-resize-handle"
|
<div class="tv-panel-edge" data-edge="b"></div>
|
||||||
class="tv-panel-resize-handle"
|
<div class="tv-panel-edge" data-edge="l"></div>
|
||||||
type="button"
|
<div class="tv-panel-edge" data-edge="br"></div>
|
||||||
aria-label="调整电视直播窗口大小"
|
<div class="tv-panel-edge" data-edge="bl"></div>
|
||||||
title="调整大小"
|
|
||||||
></button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="loading" class="earth-loading">
|
<div id="loading" class="earth-loading">
|
||||||
|
|||||||
25
frontend/public/earth/js/controls.js
vendored
25
frontend/public/earth/js/controls.js
vendored
@@ -174,7 +174,9 @@ function setupDraggableHudPanels() {
|
|||||||
if (!app || draggablePanels.length === 0) return;
|
if (!app || draggablePanels.length === 0) return;
|
||||||
|
|
||||||
draggablePanels.forEach((panel) => {
|
draggablePanels.forEach((panel) => {
|
||||||
const handle = panel.querySelector(".hud-panel-drag-handle");
|
const handle = panel.dataset.dragSelf === "true"
|
||||||
|
? panel
|
||||||
|
: panel.querySelector(".hud-panel-drag-handle");
|
||||||
if (!handle) return;
|
if (!handle) return;
|
||||||
|
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
@@ -228,7 +230,7 @@ function setupDraggableHudPanels() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
bindListener(handle, "pointerdown", (event) => {
|
bindListener(handle, "pointerdown", (event) => {
|
||||||
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close")) return;
|
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-action, .tv-panel-player, .tv-panel-edge, .legend-bar-btn")) return;
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
startPointerX = event.clientX;
|
startPointerX = event.clientX;
|
||||||
startPointerY = event.clientY;
|
startPointerY = event.clientY;
|
||||||
@@ -238,6 +240,8 @@ function setupDraggableHudPanels() {
|
|||||||
// If panel is inside a flow container (not a direct child of app), reparent
|
// If panel is inside a flow container (not a direct child of app), reparent
|
||||||
// it so absolute positioning is relative to the app container.
|
// it so absolute positioning is relative to the app container.
|
||||||
if (panel.parentElement !== app) {
|
if (panel.parentElement !== app) {
|
||||||
|
panel.dataset.originalParentId = panel.parentElement?.id || "";
|
||||||
|
panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || "";
|
||||||
const capturedWidth = panelRect.width;
|
const capturedWidth = panelRect.width;
|
||||||
panel.style.position = "absolute";
|
panel.style.position = "absolute";
|
||||||
panel.style.width = `${capturedWidth}px`;
|
panel.style.width = `${capturedWidth}px`;
|
||||||
@@ -929,11 +933,28 @@ function updateLayoutUI(container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetPanelInlineLayout(panel) {
|
function resetPanelInlineLayout(panel) {
|
||||||
|
const originalParentId = panel.dataset.originalParentId;
|
||||||
|
if (originalParentId) {
|
||||||
|
const originalParent = document.getElementById(originalParentId);
|
||||||
|
if (originalParent) {
|
||||||
|
const nextId = panel.dataset.originalNextSiblingId;
|
||||||
|
const nextSibling = nextId ? document.getElementById(nextId) : null;
|
||||||
|
if (nextSibling) {
|
||||||
|
originalParent.insertBefore(panel, nextSibling);
|
||||||
|
} else {
|
||||||
|
originalParent.appendChild(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete panel.dataset.originalParentId;
|
||||||
|
delete panel.dataset.originalNextSiblingId;
|
||||||
|
}
|
||||||
panel.style.left = "";
|
panel.style.left = "";
|
||||||
panel.style.top = "";
|
panel.style.top = "";
|
||||||
panel.style.right = "";
|
panel.style.right = "";
|
||||||
panel.style.bottom = "";
|
panel.style.bottom = "";
|
||||||
panel.style.transform = "";
|
panel.style.transform = "";
|
||||||
|
panel.style.position = "";
|
||||||
|
panel.style.width = "";
|
||||||
delete panel.dataset.dragged;
|
delete panel.dataset.dragged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { showStatusMessage } from './ui.js';
|
import { showStatusMessage } from './ui.js';
|
||||||
|
|
||||||
let currentType = null;
|
let currentType = null;
|
||||||
|
let cardMounted = false;
|
||||||
|
|
||||||
const CARD_CONFIG = {
|
const CARD_CONFIG = {
|
||||||
cable: {
|
cable: {
|
||||||
@@ -105,6 +106,138 @@ function getPanel() {
|
|||||||
return document.getElementById('info-panel');
|
return document.getElementById('info-panel');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupInfoCardDrag(panel) {
|
||||||
|
const app = document.getElementById('container');
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
const handle = panel.querySelector('.hud-panel-drag-handle');
|
||||||
|
if (!handle) return;
|
||||||
|
|
||||||
|
let isDragging = false;
|
||||||
|
let startPointerX = 0;
|
||||||
|
let startPointerY = 0;
|
||||||
|
let startLeft = 0;
|
||||||
|
let startTop = 0;
|
||||||
|
|
||||||
|
const stopDragging = () => {
|
||||||
|
isDragging = false;
|
||||||
|
panel.classList.remove('is-dragging');
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (event) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const appRect = app.getBoundingClientRect();
|
||||||
|
const panelRect = panel.getBoundingClientRect();
|
||||||
|
const nextLeft = Math.min(
|
||||||
|
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||||||
|
appRect.width - panelRect.width,
|
||||||
|
);
|
||||||
|
const nextTop = Math.min(
|
||||||
|
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||||||
|
appRect.height - panelRect.height,
|
||||||
|
);
|
||||||
|
panel.style.left = `${nextLeft}px`;
|
||||||
|
panel.style.top = `${nextTop}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
handle.addEventListener('pointerdown', (event) => {
|
||||||
|
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||||||
|
isDragging = true;
|
||||||
|
startPointerX = event.clientX;
|
||||||
|
startPointerY = event.clientY;
|
||||||
|
const appRect = app.getBoundingClientRect();
|
||||||
|
const panelRect = panel.getBoundingClientRect();
|
||||||
|
startLeft = panelRect.left - appRect.left;
|
||||||
|
startTop = panelRect.top - appRect.top;
|
||||||
|
panel.style.left = `${startLeft}px`;
|
||||||
|
panel.style.top = `${startTop}px`;
|
||||||
|
panel.style.right = 'auto';
|
||||||
|
panel.style.bottom = 'auto';
|
||||||
|
panel.classList.add('is-dragging');
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
handle.setPointerCapture?.(event.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', stopDragging);
|
||||||
|
handle.addEventListener('pointercancel', stopDragging);
|
||||||
|
handle.addEventListener('lostpointercapture', stopDragging);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountCard() {
|
||||||
|
if (cardMounted) return;
|
||||||
|
|
||||||
|
const container = document.getElementById('container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.id = 'info-panel';
|
||||||
|
panel.className = 'hud-panel hud-panel-info';
|
||||||
|
panel.setAttribute('aria-live', 'polite');
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div id="info-card" class="info-card">
|
||||||
|
<div class="info-card-header hud-panel-drag-handle">
|
||||||
|
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||||
|
<h3 id="info-card-title">详情</h3>
|
||||||
|
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||||
|
<span class="material-symbols-rounded">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="info-card-content" class="info-card-content"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(panel);
|
||||||
|
|
||||||
|
const card = panel.querySelector('#info-card');
|
||||||
|
const content = panel.querySelector('#info-card-content');
|
||||||
|
|
||||||
|
// Prevent pointer events from reaching the earth canvas
|
||||||
|
const stopEvent = (event) => { event.stopPropagation(); };
|
||||||
|
[
|
||||||
|
'mousemove', 'mousedown', 'mouseup', 'click', 'dblclick', 'wheel',
|
||||||
|
'pointerdown', 'pointerup', 'pointermove',
|
||||||
|
'touchstart', 'touchmove', 'touchend',
|
||||||
|
].forEach((evt) => card.addEventListener(evt, stopEvent, { passive: false }));
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
const closeBtn = card.querySelector('.info-card-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
hideInfoCard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy value on label click
|
||||||
|
content.addEventListener('click', async (event) => {
|
||||||
|
const label = event.target.closest('.info-card-label');
|
||||||
|
if (!label) return;
|
||||||
|
|
||||||
|
const property = label.closest('.info-card-property');
|
||||||
|
const valueEl = property?.querySelector('.info-card-value');
|
||||||
|
const value = valueEl?.textContent?.trim();
|
||||||
|
|
||||||
|
if (!value || value === '-') {
|
||||||
|
showStatusMessage('无可复制内容', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
showStatusMessage(`已复制${label.textContent}:${value}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy failed:', error);
|
||||||
|
showStatusMessage('复制失败', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setupInfoCardDrag(panel);
|
||||||
|
|
||||||
|
cardMounted = true;
|
||||||
|
}
|
||||||
|
|
||||||
function positionPanel(panel, x, y) {
|
function positionPanel(panel, x, y) {
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const margin = 12;
|
const margin = 12;
|
||||||
@@ -142,71 +275,8 @@ function hidePanel() {
|
|||||||
if (panel) panel.classList.remove('is-visible');
|
if (panel) panel.classList.remove('is-visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initInfoCard() {
|
// No-op: event binding now happens lazily in mountCard()
|
||||||
const card = document.getElementById('info-card');
|
export function initInfoCard() {}
|
||||||
const content = document.getElementById('info-card-content');
|
|
||||||
if (!card || !content) return;
|
|
||||||
|
|
||||||
if (card.dataset.interactionBound !== 'true') {
|
|
||||||
const stopEvent = (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
[
|
|
||||||
'mousemove',
|
|
||||||
'mousedown',
|
|
||||||
'mouseup',
|
|
||||||
'click',
|
|
||||||
'dblclick',
|
|
||||||
'wheel',
|
|
||||||
'pointerdown',
|
|
||||||
'pointerup',
|
|
||||||
'pointermove',
|
|
||||||
'touchstart',
|
|
||||||
'touchmove',
|
|
||||||
'touchend',
|
|
||||||
].forEach((eventName) => {
|
|
||||||
card.addEventListener(eventName, stopEvent, { passive: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close button wires the panel hide
|
|
||||||
const closeBtn = card.querySelector('.info-card-close');
|
|
||||||
if (closeBtn) {
|
|
||||||
closeBtn.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
hideInfoCard();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
card.dataset.interactionBound = 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.dataset.copyBound === 'true') return;
|
|
||||||
|
|
||||||
content.addEventListener('click', async (event) => {
|
|
||||||
const label = event.target.closest('.info-card-label');
|
|
||||||
if (!label) return;
|
|
||||||
|
|
||||||
const property = label.closest('.info-card-property');
|
|
||||||
const valueEl = property?.querySelector('.info-card-value');
|
|
||||||
const value = valueEl?.textContent?.trim();
|
|
||||||
|
|
||||||
if (!value || value === '-') {
|
|
||||||
showStatusMessage('无可复制内容', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
showStatusMessage(`已复制${label.textContent}:${value}`, 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Copy failed:', error);
|
|
||||||
showStatusMessage('复制失败', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
content.dataset.copyBound = 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setInfoCardNoBorder(noBorder = true) {
|
export function setInfoCardNoBorder(noBorder = true) {
|
||||||
const card = document.getElementById('info-card');
|
const card = document.getElementById('info-card');
|
||||||
@@ -222,6 +292,8 @@ export function showInfoCard(type, data, options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mountCard();
|
||||||
|
|
||||||
currentType = type;
|
currentType = type;
|
||||||
const card = document.getElementById('info-card');
|
const card = document.getElementById('info-card');
|
||||||
const icon = document.getElementById('info-card-icon');
|
const icon = document.getElementById('info-card-icon');
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ let initialized = false;
|
|||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
let hlsPlayer = null;
|
let hlsPlayer = null;
|
||||||
let hlsRecoveryAttempts = 0;
|
let hlsRecoveryAttempts = 0;
|
||||||
|
let metaAutoCollapseTimer = null;
|
||||||
|
|
||||||
|
const META_AUTO_COLLAPSE_DELAY = 2500;
|
||||||
|
|
||||||
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
|
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
|
||||||
const HLS_RETRY_CONFIG = {
|
const HLS_RETRY_CONFIG = {
|
||||||
@@ -33,7 +36,7 @@ function getElements() {
|
|||||||
return {
|
return {
|
||||||
panel: document.getElementById("tv-panel"),
|
panel: document.getElementById("tv-panel"),
|
||||||
toggleBtn: document.getElementById("toggle-tv"),
|
toggleBtn: document.getElementById("toggle-tv"),
|
||||||
resizeHandle: document.getElementById("tv-resize-handle"),
|
resizeHandle: null,
|
||||||
select: document.getElementById("tv-source-select"),
|
select: document.getElementById("tv-source-select"),
|
||||||
title: document.getElementById("tv-source-title"),
|
title: document.getElementById("tv-source-title"),
|
||||||
meta: document.getElementById("tv-source-meta"),
|
meta: document.getElementById("tv-source-meta"),
|
||||||
@@ -45,9 +48,51 @@ function getElements() {
|
|||||||
empty: document.getElementById("tv-empty-state"),
|
empty: document.getElementById("tv-empty-state"),
|
||||||
refreshBtn: document.getElementById("tv-refresh"),
|
refreshBtn: document.getElementById("tv-refresh"),
|
||||||
openBtn: document.getElementById("tv-open-external"),
|
openBtn: document.getElementById("tv-open-external"),
|
||||||
|
metaPanel: document.getElementById("tv-panel-meta"),
|
||||||
|
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||||
|
metaToggle: document.getElementById("tv-meta-toggle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMetaCollapsed(collapsed) {
|
||||||
|
const { metaWrap, metaToggle, panel } = getElements();
|
||||||
|
if (!metaWrap) return;
|
||||||
|
|
||||||
|
const isDragged = panel?.dataset.dragged === "true" && panel.style.top;
|
||||||
|
|
||||||
|
if (isDragged) {
|
||||||
|
// Top-anchored panel: toggle instantly and compensate top so the player
|
||||||
|
// (panel bottom) stays visually fixed.
|
||||||
|
const bottomBefore = panel.getBoundingClientRect().bottom;
|
||||||
|
|
||||||
|
metaWrap.style.transition = "none";
|
||||||
|
metaWrap.classList.toggle("is-collapsed", collapsed);
|
||||||
|
metaToggle?.classList.toggle("is-collapsed", collapsed);
|
||||||
|
|
||||||
|
// Force synchronous reflow to get updated panel height
|
||||||
|
void panel.offsetHeight;
|
||||||
|
|
||||||
|
const delta = panel.getBoundingClientRect().bottom - bottomBefore;
|
||||||
|
if (delta !== 0) {
|
||||||
|
panel.style.top = `${parseFloat(panel.style.top) - delta}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore CSS transition after this paint
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
metaWrap.style.transition = "";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
metaWrap.classList.toggle("is-collapsed", collapsed);
|
||||||
|
metaToggle?.classList.toggle("is-collapsed", collapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoExpandMeta() {
|
||||||
|
clearTimeout(metaAutoCollapseTimer);
|
||||||
|
setMetaCollapsed(false);
|
||||||
|
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
function clearPanelPositioningForResize(panel) {
|
function clearPanelPositioningForResize(panel) {
|
||||||
panel.style.left = `${panel.offsetLeft}px`;
|
panel.style.left = `${panel.offsetLeft}px`;
|
||||||
panel.style.top = `${panel.offsetTop}px`;
|
panel.style.top = `${panel.offsetTop}px`;
|
||||||
@@ -65,30 +110,57 @@ function getHudScale() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupResizeHandle() {
|
function setupResizeHandle() {
|
||||||
const { panel, resizeHandle } = getElements();
|
const { panel } = getElements();
|
||||||
const container = document.getElementById("container");
|
const container = document.getElementById("container");
|
||||||
if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) {
|
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resizing = false;
|
let resizing = false;
|
||||||
|
let activeEdge = "";
|
||||||
let startX = 0;
|
let startX = 0;
|
||||||
let startY = 0;
|
let startY = 0;
|
||||||
let startWidth = 0;
|
let startWidth = 0;
|
||||||
let startHeight = 0;
|
let startHeight = 0;
|
||||||
|
let startLeft = 0;
|
||||||
|
let startTop = 0;
|
||||||
|
|
||||||
const stopResize = () => {
|
const stopResize = () => {
|
||||||
resizing = false;
|
resizing = false;
|
||||||
|
activeEdge = "";
|
||||||
panel.classList.remove("is-resizing");
|
panel.classList.remove("is-resizing");
|
||||||
document.body.style.userSelect = "";
|
document.body.style.userSelect = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
resizeHandle.addEventListener("pointerdown", (event) => {
|
const onMove = (event) => {
|
||||||
if (document.getElementById("container")?.classList.contains("layout-expanded")) {
|
if (!resizing) return;
|
||||||
return;
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const hudScale = getHudScale();
|
||||||
|
const minWidth = Math.max(320, Math.round(360 * hudScale));
|
||||||
|
const minHeight = Math.max(260, Math.round(340 * hudScale));
|
||||||
|
const dx = event.clientX - startX;
|
||||||
|
const dy = event.clientY - startY;
|
||||||
|
|
||||||
|
if (activeEdge.includes("r")) {
|
||||||
|
const maxW = containerRect.width - startLeft - 12;
|
||||||
|
panel.style.width = `${Math.min(maxW, Math.max(minWidth, startWidth + dx))}px`;
|
||||||
}
|
}
|
||||||
|
if (activeEdge.includes("l")) {
|
||||||
|
const newW = Math.max(minWidth, startWidth - dx);
|
||||||
|
panel.style.width = `${newW}px`;
|
||||||
|
panel.style.left = `${Math.max(0, startLeft + startWidth - newW)}px`;
|
||||||
|
}
|
||||||
|
if (activeEdge.includes("b")) {
|
||||||
|
const maxH = containerRect.height - startTop - 12;
|
||||||
|
panel.style.minHeight = `${Math.min(maxH, Math.max(minHeight, startHeight + dy))}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
panel.querySelectorAll(".tv-panel-edge[data-edge]").forEach((edgeEl) => {
|
||||||
|
edgeEl.addEventListener("pointerdown", (event) => {
|
||||||
|
if (container.classList.contains("layout-expanded")) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
activeEdge = edgeEl.dataset.edge ?? "";
|
||||||
resizing = true;
|
resizing = true;
|
||||||
startX = event.clientX;
|
startX = event.clientX;
|
||||||
startY = event.clientY;
|
startY = event.clientY;
|
||||||
@@ -96,40 +168,22 @@ function setupResizeHandle() {
|
|||||||
clearPanelPositioningForResize(panel);
|
clearPanelPositioningForResize(panel);
|
||||||
|
|
||||||
const rect = panel.getBoundingClientRect();
|
const rect = panel.getBoundingClientRect();
|
||||||
|
const cRect = container.getBoundingClientRect();
|
||||||
startWidth = rect.width;
|
startWidth = rect.width;
|
||||||
startHeight = rect.height;
|
startHeight = rect.height;
|
||||||
|
startLeft = rect.left - cRect.left;
|
||||||
|
startTop = rect.top - cRect.top;
|
||||||
|
|
||||||
panel.classList.add("is-resizing");
|
panel.classList.add("is-resizing");
|
||||||
document.body.style.userSelect = "none";
|
document.body.style.userSelect = "none";
|
||||||
resizeHandle.setPointerCapture?.(event.pointerId);
|
edgeEl.setPointerCapture?.(event.pointerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeHandle.addEventListener("pointermove", (event) => {
|
edgeEl.addEventListener("pointermove", onMove);
|
||||||
if (!resizing) return;
|
edgeEl.addEventListener("pointerup", stopResize);
|
||||||
const containerRect = container.getBoundingClientRect();
|
edgeEl.addEventListener("pointercancel", stopResize);
|
||||||
const panelRect = panel.getBoundingClientRect();
|
edgeEl.addEventListener("lostpointercapture", stopResize);
|
||||||
const currentLeft = panelRect.left - containerRect.left;
|
|
||||||
const currentTop = panelRect.top - containerRect.top;
|
|
||||||
const hudScale = getHudScale();
|
|
||||||
const minWidth = Math.max(320, Math.round(360 * hudScale));
|
|
||||||
const minHeight = Math.max(260, Math.round(340 * hudScale));
|
|
||||||
const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12);
|
|
||||||
const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12);
|
|
||||||
const nextWidth = Math.min(
|
|
||||||
maxWidth,
|
|
||||||
Math.max(minWidth, startWidth + (event.clientX - startX)),
|
|
||||||
);
|
|
||||||
const nextHeight = Math.min(
|
|
||||||
maxHeight,
|
|
||||||
Math.max(minHeight, startHeight + (event.clientY - startY)),
|
|
||||||
);
|
|
||||||
|
|
||||||
panel.style.width = `${nextWidth}px`;
|
|
||||||
panel.style.minHeight = `${nextHeight}px`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeHandle.addEventListener("pointerup", stopResize);
|
|
||||||
resizeHandle.addEventListener("pointercancel", stopResize);
|
|
||||||
resizeHandle.addEventListener("lostpointercapture", stopResize);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateToggleButton(visible) {
|
function updateToggleButton(visible) {
|
||||||
@@ -502,6 +556,7 @@ function renderSource(source) {
|
|||||||
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
|
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
|
||||||
);
|
);
|
||||||
updateOpenButton(source);
|
updateOpenButton(source);
|
||||||
|
autoExpandMeta();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInitialSourceId() {
|
function resolveInitialSourceId() {
|
||||||
@@ -592,6 +647,13 @@ export function initTVPanel() {
|
|||||||
renderSource(findSourceById(currentSourceId));
|
renderSource(findSourceById(currentSourceId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { metaToggle } = getElements();
|
||||||
|
metaToggle?.addEventListener("click", () => {
|
||||||
|
clearTimeout(metaAutoCollapseTimer);
|
||||||
|
const isNowCollapsed = !metaToggle.classList.contains("is-collapsed");
|
||||||
|
setMetaCollapsed(isNowCollapsed);
|
||||||
|
});
|
||||||
|
|
||||||
refreshBtn?.addEventListener("click", () => {
|
refreshBtn?.addEventListener("click", () => {
|
||||||
refreshTVPanel();
|
refreshTVPanel();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ function Scrollbar({
|
|||||||
{scrollbar.y.visible ? (
|
{scrollbar.y.visible ? (
|
||||||
<div
|
<div
|
||||||
className="scrollbar__thumb scrollbar__thumb--y"
|
className="scrollbar__thumb scrollbar__thumb--y"
|
||||||
|
tabIndex={0}
|
||||||
style={{
|
style={{
|
||||||
height: `${scrollbar.y.thumbSize}px`,
|
height: `${scrollbar.y.thumbSize}px`,
|
||||||
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
|
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
|
||||||
@@ -284,6 +285,7 @@ function Scrollbar({
|
|||||||
{scrollbar.x.visible ? (
|
{scrollbar.x.visible ? (
|
||||||
<div
|
<div
|
||||||
className="scrollbar__thumb scrollbar__thumb--x"
|
className="scrollbar__thumb scrollbar__thumb--x"
|
||||||
|
tabIndex={0}
|
||||||
style={{
|
style={{
|
||||||
width: `${scrollbar.x.thumbSize}px`,
|
width: `${scrollbar.x.thumbSize}px`,
|
||||||
transform: `translateX(${scrollbar.x.thumbOffset}px)`,
|
transform: `translateX(${scrollbar.x.thumbOffset}px)`,
|
||||||
|
|||||||
37
frontend/src/components/Scrollbar/TableScrollRegion.tsx
Normal file
37
frontend/src/components/Scrollbar/TableScrollRegion.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import ScrollbarOverlay from './ScrollbarOverlay'
|
||||||
|
|
||||||
|
interface TableScrollRegionProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
targetSelector?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableScrollRegion = forwardRef<HTMLDivElement, TableScrollRegionProps>(function TableScrollRegion(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
targetSelector = '.ant-table-body',
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={['table-scroll-region', className].filter(Boolean).join(' ')}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ScrollbarOverlay containerRef={containerRef} targetSelector={targetSelector} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default TableScrollRegion
|
||||||
@@ -140,7 +140,8 @@ body {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transition: opacity 0.18s ease, background 0.18s ease;
|
outline: none;
|
||||||
|
transition: opacity 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar__thumb::after {
|
.scrollbar__thumb::after {
|
||||||
@@ -173,6 +174,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
|
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
|
||||||
|
.scrollbar:focus-within .scrollbar__track--visible .scrollbar__thumb,
|
||||||
.scrollbar__track--visible .scrollbar__thumb,
|
.scrollbar__track--visible .scrollbar__thumb,
|
||||||
.scrollbar__track--dragging .scrollbar__thumb {
|
.scrollbar__track--dragging .scrollbar__thumb {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -182,12 +184,15 @@ body {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar__thumb:hover {
|
.scrollbar__thumb:hover,
|
||||||
background: rgba(216, 226, 240, 0.68);
|
.scrollbar__thumb:focus-visible {
|
||||||
|
background: rgba(125, 146, 174, 0.82);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar__track--dragging .scrollbar__thumb {
|
.scrollbar__track--dragging .scrollbar__thumb {
|
||||||
background: rgba(226, 235, 246, 0.82);
|
background: rgba(92, 115, 146, 0.9);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-brand {
|
.dashboard-brand {
|
||||||
@@ -385,6 +390,10 @@ body {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-chat__service-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-card__icon-button:hover {
|
.playground-card__icon-button:hover {
|
||||||
color: #1677ff !important;
|
color: #1677ff !important;
|
||||||
background: rgba(22, 119, 255, 0.08) !important;
|
background: rgba(22, 119, 255, 0.08) !important;
|
||||||
@@ -398,9 +407,11 @@ body {
|
|||||||
|
|
||||||
.playground-card__scroll {
|
.playground-card__scroll {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-card__scroll .scrollbar__viewport {
|
||||||
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playground-card__scroll::-webkit-scrollbar,
|
.playground-card__scroll::-webkit-scrollbar,
|
||||||
@@ -527,6 +538,21 @@ body {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-chat__input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__input-row .playground-chat__input.ant-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__input-wrap--expanded .playground-chat__input-row .playground-chat__send-button.ant-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-chat__input.ant-input {
|
.playground-chat__input.ant-input {
|
||||||
border: 0;
|
border: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -536,6 +562,15 @@ body {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-chat__input-wrap--expanded .playground-chat__input.ant-input {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__input-wrap:not(.playground-chat__input-wrap--expanded) .playground-chat__input.ant-input {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-chat__input.ant-input:focus,
|
.playground-chat__input.ant-input:focus,
|
||||||
.playground-chat__input.ant-input-focused {
|
.playground-chat__input.ant-input-focused {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -1251,25 +1286,21 @@ body {
|
|||||||
.playground-result-modal__content {
|
.playground-result-modal__content {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playground-result-modal__content::-webkit-scrollbar {
|
.playground-result-modal__content .scrollbar__viewport {
|
||||||
width: 8px;
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playground-result-modal__content::-webkit-scrollbar-thumb {
|
.playground-result__blocks-scroll .scrollbar__viewport {
|
||||||
background: rgba(148, 163, 184, 0.82);
|
height: 100%;
|
||||||
border-radius: 999px;
|
overflow: auto;
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playground-result-modal__content::-webkit-scrollbar-track {
|
.playground-result__blocks-scroll.scrollbar {
|
||||||
background: transparent;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
@@ -1285,6 +1316,10 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-chat__service-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-shell__sidebar {
|
.playground-shell__sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1490,10 +1525,26 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-table-region .ant-table-body {
|
/* users table: flex-fill approach so overlay x-track aligns with table bottom */
|
||||||
height: auto !important;
|
.users-table-region .ant-table-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.users-table-region .ant-table-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table-region .ant-table-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.data-source-table-region .ant-table-wrapper,
|
.data-source-table-region .ant-table-wrapper,
|
||||||
.data-source-table-region .ant-spin-nested-loading,
|
.data-source-table-region .ant-spin-nested-loading,
|
||||||
.data-source-table-region .ant-spin-container {
|
.data-source-table-region .ant-spin-container {
|
||||||
@@ -1583,14 +1634,14 @@ body {
|
|||||||
padding: 10px 12px !important;
|
padding: 10px 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-source-table-region .ant-table-body,
|
.table-scroll-region .ant-table-body,
|
||||||
.data-source-table-region .ant-table-content {
|
.table-scroll-region .ant-table-content {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-source-table-region .ant-table-body::-webkit-scrollbar,
|
.table-scroll-region .ant-table-body::-webkit-scrollbar,
|
||||||
.data-source-table-region .ant-table-content::-webkit-scrollbar {
|
.table-scroll-region .ant-table-content::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
@@ -1757,7 +1808,10 @@ body {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||||
@@ -1836,10 +1890,11 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
max-height: calc(100vh - 180px);
|
max-height: calc(100vh - 180px);
|
||||||
overflow: auto;
|
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
scrollbar-width: thin;
|
}
|
||||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
|
||||||
|
.bgp-page__brief-modal-body .scrollbar__viewport {
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bgp-page__brief-evidence {
|
.bgp-page__brief-evidence {
|
||||||
@@ -1857,6 +1912,10 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alerts-brief-drawer .scrollbar__viewport {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.alerts-brief-drawer__loading {
|
.alerts-brief-drawer__loading {
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1887,8 +1946,25 @@ body {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-tab-panel > .ant-space-item,
|
||||||
|
.system-alerts-page__stack > .ant-space-item,
|
||||||
|
.bgp-alerts-page__stack > .ant-space-item,
|
||||||
|
.situational-alerts-page__stack > .ant-space-item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-tab-panel > .ant-space-item:last-child,
|
||||||
|
.system-alerts-page__stack > .ant-space-item:last-child,
|
||||||
|
.bgp-alerts-page__stack > .ant-space-item:last-child,
|
||||||
|
.situational-alerts-page__stack > .ant-space-item:last-child {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-alerts-page__table-card,
|
.system-alerts-page__table-card,
|
||||||
@@ -1915,8 +1991,22 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bgp-alerts-page__tabs,
|
.bgp-alerts-page__tabs {
|
||||||
.bgp-alerts-page__tabs .ant-tabs-content-holder,
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgp-alerts-page__tabs .ant-tabs-content-holder {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.bgp-alerts-page__tabs .ant-tabs-content,
|
.bgp-alerts-page__tabs .ant-tabs-content,
|
||||||
.bgp-alerts-page__tabs .ant-tabs-tabpane {
|
.bgp-alerts-page__tabs .ant-tabs-tabpane {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -2128,29 +2218,71 @@ body {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bgp-page__summary-grid--compact {
|
.bgp-page__summary-scroll.scrollbar,
|
||||||
flex-wrap: nowrap !important;
|
.alerts-summary-scroll.scrollbar {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgp-page__summary-scroll .scrollbar__viewport,
|
||||||
|
.alerts-summary-scroll .scrollbar__viewport {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bgp-page__summary-grid--compact::-webkit-scrollbar {
|
.bgp-page__summary-grid,
|
||||||
width: 8px;
|
.alerts-summary-grid {
|
||||||
height: 8px;
|
display: flex;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bgp-page__summary-grid--compact::-webkit-scrollbar-thumb {
|
.bgp-page__summary-cell,
|
||||||
background: rgba(148, 163, 184, 0.82);
|
.alerts-summary-cell {
|
||||||
border-radius: 999px;
|
flex: 0 0 auto;
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bgp-page__summary-grid--compact::-webkit-scrollbar-track {
|
.situational-alerts-page__summary-grid {
|
||||||
background: transparent;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.situational-alerts-page__panels-grid {
|
||||||
|
display: flex;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.situational-alerts-page__panels-scroll .scrollbar__viewport {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.situational-alerts-page__summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.situational-alerts-page__summary-grid .alerts-summary-cell {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.situational-alerts-page__summary-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: auto;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.situational-alerts-page__summary-grid .alerts-summary-cell {
|
||||||
|
width: 220px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bgp-page__table-card,
|
.bgp-page__table-card,
|
||||||
@@ -2408,25 +2540,21 @@ body {
|
|||||||
.settings-panel-scroll {
|
.settings-panel-scroll {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-scroll.scrollbar {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-scroll .scrollbar__viewport {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding-right: 6px;
|
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-panel-scroll::-webkit-scrollbar {
|
.settings-panel-scroll .scrollbar__viewport > * {
|
||||||
width: 10px;
|
min-width: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.settings-panel-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(148, 163, 184, 0.8);
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-panel-scroll::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-pane .data-source-table-region .ant-table-container {
|
.settings-pane .data-source-table-region .ant-table-container {
|
||||||
@@ -3273,7 +3401,6 @@ body {
|
|||||||
|
|
||||||
.dashboard-restart-log {
|
.dashboard-restart-log {
|
||||||
max-height: 180px;
|
max-height: 180px;
|
||||||
overflow-y: auto;
|
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
@@ -3281,16 +3408,11 @@ body {
|
|||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-restart-log::-webkit-scrollbar {
|
.dashboard-restart-log .scrollbar__viewport {
|
||||||
width: 8px;
|
overflow-y: auto;
|
||||||
}
|
max-height: 180px;
|
||||||
|
|
||||||
.dashboard-restart-log::-webkit-scrollbar-thumb {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(148, 163, 184, 0.55);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Modal,
|
Modal,
|
||||||
Row,
|
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
@@ -21,6 +19,8 @@ import {
|
|||||||
} from 'antd'
|
} from 'antd'
|
||||||
|
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
|
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||||
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
|
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
|
||||||
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
|
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
@@ -171,20 +171,22 @@ export function BGPAlertsPanel() {
|
|||||||
|
|
||||||
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
|
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
|
||||||
|
|
||||||
<Row gutter={[12, 12]}>
|
<Scrollbar className="alerts-summary-scroll bgp-alerts-page__summary-scroll">
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
|
||||||
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
|
|
||||||
<Card className="bgp-alerts-page__table-card">
|
<Card className="bgp-alerts-page__table-card">
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -194,7 +196,7 @@ export function BGPAlertsPanel() {
|
|||||||
key: 'incidents',
|
key: 'incidents',
|
||||||
label: 'BGP 事件',
|
label: 'BGP 事件',
|
||||||
children: (
|
children: (
|
||||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
<TableScrollRegion className="bgp-alerts-page__table-region">
|
||||||
<Table<BGPIncident>
|
<Table<BGPIncident>
|
||||||
columns={incidentColumns}
|
columns={incidentColumns}
|
||||||
dataSource={incidents}
|
dataSource={incidents}
|
||||||
@@ -204,14 +206,14 @@ export function BGPAlertsPanel() {
|
|||||||
scroll={{ x: 1200, y: 480 }}
|
scroll={{ x: 1200, y: 480 }}
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'anomalies',
|
key: 'anomalies',
|
||||||
label: 'BGP 异常',
|
label: 'BGP 异常',
|
||||||
children: (
|
children: (
|
||||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
<TableScrollRegion className="bgp-alerts-page__table-region">
|
||||||
<Table<BGPAnomaly>
|
<Table<BGPAnomaly>
|
||||||
columns={anomalyColumns}
|
columns={anomalyColumns}
|
||||||
dataSource={anomalies}
|
dataSource={anomalies}
|
||||||
@@ -221,7 +223,7 @@ export function BGPAlertsPanel() {
|
|||||||
scroll={{ x: 1100, y: 480 }}
|
scroll={{ x: 1100, y: 480 }}
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -244,14 +246,14 @@ export function BGPAlertsPanel() {
|
|||||||
<Spin tip="正在生成 BGP AI 简报..." />
|
<Spin tip="正在生成 BGP AI 简报..." />
|
||||||
</div>
|
</div>
|
||||||
) : brief ? (
|
) : brief ? (
|
||||||
<div className="bgp-page__brief-modal-body">
|
<Scrollbar className="bgp-page__brief-modal-body">
|
||||||
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
|
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
|
||||||
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
|
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
|
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
|
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
|
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
|
||||||
</div>
|
</Scrollbar>
|
||||||
) : (
|
) : (
|
||||||
<Text type="secondary">当前没有可查看的 BGP 简报。</Text>
|
<Text type="secondary">当前没有可查看的 BGP 简报。</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Row,
|
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
@@ -18,6 +16,7 @@ import {
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
|
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
|
||||||
import {
|
import {
|
||||||
getSituationalAwarenessGateway,
|
getSituationalAwarenessGateway,
|
||||||
@@ -119,23 +118,26 @@ export function SituationalAlertsPanel() {
|
|||||||
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
|
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Row gutter={[12, 12]}>
|
<Scrollbar className="alerts-summary-scroll situational-alerts-page__summary-scroll">
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<div className="alerts-summary-grid situational-alerts-page__summary-grid" style={{ gap: '12px' }}>
|
||||||
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
|
|
||||||
<Row gutter={[12, 12]}>
|
<Scrollbar className="alerts-summary-scroll situational-alerts-page__panels-scroll">
|
||||||
<Col xs={24} lg={12}>
|
<div className="alerts-summary-grid situational-alerts-page__panels-grid" style={{ gap: '12px' }}>
|
||||||
|
<div className="alerts-summary-cell" style={{ width: '360px' }}>
|
||||||
<Card title="系统告警侧">
|
<Card title="系统告警侧">
|
||||||
<Descriptions size="small" column={1}>
|
<Descriptions size="small" column={1}>
|
||||||
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||||
@@ -143,8 +145,8 @@ export function SituationalAlertsPanel() {
|
|||||||
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} lg={12}>
|
<div className="alerts-summary-cell" style={{ width: '360px' }}>
|
||||||
<Card title="BGP 风险侧">
|
<Card title="BGP 风险侧">
|
||||||
<Descriptions size="small" column={1}>
|
<Descriptions size="small" column={1}>
|
||||||
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||||
@@ -153,8 +155,9 @@ export function SituationalAlertsPanel() {
|
|||||||
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Modal,
|
Modal,
|
||||||
Row,
|
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
@@ -22,6 +20,8 @@ import {
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
|
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||||
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
|
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
|
|
||||||
@@ -216,20 +216,22 @@ export function SystemAlertsPanel() {
|
|||||||
|
|
||||||
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
|
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
|
||||||
|
|
||||||
<Row gutter={[12, 12]}>
|
<Scrollbar className="alerts-summary-scroll system-alerts-page__summary-scroll">
|
||||||
<Col xs={24} sm={8}>
|
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
|
||||||
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={8}>
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={8}>
|
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||||
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
|
|
||||||
<Card className="system-alerts-page__table-card" title="系统告警列表">
|
<Card className="system-alerts-page__table-card" title="系统告警列表">
|
||||||
<div className="table-scroll-region system-alerts-page__table-region">
|
<TableScrollRegion className="system-alerts-page__table-region">
|
||||||
<Table<AlertRecord>
|
<Table<AlertRecord>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={alerts}
|
dataSource={alerts}
|
||||||
@@ -239,7 +241,7 @@ export function SystemAlertsPanel() {
|
|||||||
scroll={{ x: 1100, y: 480 }}
|
scroll={{ x: 1100, y: 480 }}
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -260,7 +262,7 @@ export function SystemAlertsPanel() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||||
<div className="alerts-brief-drawer">
|
<Scrollbar className="alerts-brief-drawer">
|
||||||
{briefLoading ? (
|
{briefLoading ? (
|
||||||
<div className="alerts-brief-drawer__loading">
|
<div className="alerts-brief-drawer__loading">
|
||||||
<Spin tip="正在汇总系统告警事实并生成简报..." />
|
<Spin tip="正在汇总系统告警事实并生成简报..." />
|
||||||
@@ -293,7 +295,7 @@ export function SystemAlertsPanel() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</Scrollbar>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Modal,
|
Modal,
|
||||||
Row,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
@@ -22,6 +20,8 @@ import {
|
|||||||
} from 'antd'
|
} from 'antd'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
|
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
import {
|
import {
|
||||||
type BGPAnomaly,
|
type BGPAnomaly,
|
||||||
@@ -708,37 +708,36 @@ function BGP() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="bgp-page__summary-card">
|
<Card className="bgp-page__summary-card">
|
||||||
<Row
|
<Scrollbar className="bgp-page__summary-scroll">
|
||||||
gutter={[compactViewport ? 8 : 12, compactViewport ? 8 : 12]}
|
<div
|
||||||
className={`bgp-page__summary-grid${compactViewport ? ' bgp-page__summary-grid--compact' : ''}`}
|
className="bgp-page__summary-grid"
|
||||||
wrap={!compactViewport}
|
style={{ gap: `${compactViewport ? 8 : 12}px` }}
|
||||||
>
|
>
|
||||||
{summaryItems.map((item) => (
|
{summaryItems.map((item) => (
|
||||||
<Col
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
xs={24}
|
className="bgp-page__summary-cell"
|
||||||
sm={12}
|
style={{ width: compactViewport ? '180px' : '220px' }}
|
||||||
md={8}
|
|
||||||
flex={compactViewport ? '180px' : undefined}
|
|
||||||
>
|
>
|
||||||
<div className="bgp-page__summary-item">
|
<div className="bgp-page__summary-item">
|
||||||
<div className="bgp-page__summary-label">{item.label}</div>
|
<div className="bgp-page__summary-label">{item.label}</div>
|
||||||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bgp-page__table-card">
|
<Card className="bgp-page__table-card">
|
||||||
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
|
<TableScrollRegion ref={tableRegionRef} className="bgp-page__table-region">
|
||||||
<Tabs
|
<Tabs
|
||||||
className="bgp-page__tabs"
|
className="bgp-page__tabs"
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
items={tabItems}
|
items={tabItems}
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,7 +754,7 @@ function BGP() {
|
|||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
{brief ? (
|
{brief ? (
|
||||||
<div className="bgp-page__brief-modal-body">
|
<Scrollbar className="bgp-page__brief-modal-body">
|
||||||
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
|
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
|
||||||
<div className="bgp-page__brief-evidence">
|
<div className="bgp-page__brief-evidence">
|
||||||
{brief.facts.length > 0 ? (
|
{brief.facts.length > 0 ? (
|
||||||
@@ -785,7 +784,7 @@ function BGP() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<MarkdownRenderer markdown={brief.content_markdown} />
|
<MarkdownRenderer markdown={brief.content_markdown} />
|
||||||
</div>
|
</Scrollbar>
|
||||||
) : (
|
) : (
|
||||||
<Text type="secondary">当前没有可查看的简报内容。</Text>
|
<Text type="secondary">当前没有可查看的简报内容。</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
|
|
||||||
@@ -532,11 +533,11 @@ function Dashboard() {
|
|||||||
|
|
||||||
<div className="dashboard-restart-section">
|
<div className="dashboard-restart-section">
|
||||||
<Text className="dashboard-restart-section__label">终端输出</Text>
|
<Text className="dashboard-restart-section__label">终端输出</Text>
|
||||||
<div className="dashboard-restart-log">
|
<Scrollbar className="dashboard-restart-log">
|
||||||
{restartLogs.length > 0 ? restartLogs.map((line, index) => (
|
{restartLogs.length > 0 ? restartLogs.map((line, index) => (
|
||||||
<div key={`${line}-${index}`}>{line}</div>
|
<div key={`${line}-${index}`}>{line}</div>
|
||||||
)) : <div>等待操作</div>}
|
)) : <div>等待操作</div>}
|
||||||
</div>
|
</Scrollbar>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||||
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
|
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@@ -939,7 +940,7 @@ function DataList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-scroll-region data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
|
<TableScrollRegion className="data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
@@ -960,7 +961,7 @@ function DataList() {
|
|||||||
showTotal: (count) => `共 ${count} 条`,
|
showTotal: (count) => `共 ${count} 条`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,18 +116,39 @@ function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgres
|
|||||||
if (!batch || batch.sourceIds.length === 0) {
|
if (!batch || batch.sourceIds.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRunningItem = batch.sourceIds.some((sourceId) => batch.items[sourceId]?.is_running)
|
|
||||||
if (hasRunningItem) {
|
|
||||||
return batch
|
return batch
|
||||||
}
|
}
|
||||||
|
|
||||||
const allFinished = batch.sourceIds.every((sourceId) => {
|
function resolveTerminalBatchItem(
|
||||||
const status = batch.items[sourceId]?.status
|
sourceId: number,
|
||||||
return Boolean(status && status !== 'running')
|
batch: BulkProgressBatch,
|
||||||
})
|
builtInSources: BuiltInDataSource[],
|
||||||
|
taskProgress: Record<number, TaskTrackerState>,
|
||||||
|
): BulkProgressItem | null {
|
||||||
|
const currentItem = batch.items[sourceId]
|
||||||
|
const source = builtInSources.find((item) => item.id === sourceId)
|
||||||
|
const trackedTask = taskProgress[sourceId]
|
||||||
|
|
||||||
return allFinished ? null : batch
|
const isRunning = trackedTask?.is_running ?? source?.is_running ?? currentItem?.is_running ?? false
|
||||||
|
if (isRunning) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = trackedTask?.status ?? source?.last_status ?? currentItem?.status ?? null
|
||||||
|
if (!status || status === 'running') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
task_id: trackedTask?.task_id ?? currentItem?.task_id ?? source?.task_id ?? null,
|
||||||
|
progress:
|
||||||
|
status === 'success'
|
||||||
|
? 100
|
||||||
|
: trackedTask?.progress ?? source?.progress ?? currentItem?.progress ?? 0,
|
||||||
|
is_running: false,
|
||||||
|
phase: trackedTask?.phase ?? source?.phase ?? currentItem?.phase ?? null,
|
||||||
|
status,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSocketTaskMessage {
|
interface WebSocketTaskMessage {
|
||||||
@@ -436,6 +457,41 @@ function DataSources() {
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bulkProgressBatch) return
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
const nextItems = { ...bulkProgressBatch.items }
|
||||||
|
|
||||||
|
for (const sourceId of bulkProgressBatch.sourceIds) {
|
||||||
|
const nextItem = resolveTerminalBatchItem(sourceId, bulkProgressBatch, builtInSources, taskProgress)
|
||||||
|
if (!nextItem) continue
|
||||||
|
|
||||||
|
const previousItem = bulkProgressBatch.items[sourceId]
|
||||||
|
if (
|
||||||
|
previousItem?.status === nextItem.status &&
|
||||||
|
previousItem?.is_running === nextItem.is_running &&
|
||||||
|
previousItem?.progress === nextItem.progress &&
|
||||||
|
previousItem?.phase === nextItem.phase
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nextItems[sourceId] = nextItem
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
setBulkProgressBatch((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return finalizeBulkProgressBatch({
|
||||||
|
...prev,
|
||||||
|
items: nextItems,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [bulkProgressBatch, builtInSources, taskProgress])
|
||||||
|
|
||||||
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
|
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
|
||||||
const force = options?.force ?? false
|
const force = options?.force ?? false
|
||||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
|
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import axios from 'axios'
|
|||||||
|
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
|
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography
|
const { Title, Text, Paragraph } = Typography
|
||||||
@@ -237,8 +239,10 @@ function Playground() {
|
|||||||
const [editingContent, setEditingContent] = useState('')
|
const [editingContent, setEditingContent] = useState('')
|
||||||
const [editSaving, setEditSaving] = useState(false)
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||||
|
const [composerFocused, setComposerFocused] = useState(false)
|
||||||
const pollTimerRef = useRef<number | null>(null)
|
const pollTimerRef = useRef<number | null>(null)
|
||||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
|
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const messagesShellRef = useRef<HTMLDivElement | null>(null)
|
||||||
const forceScrollToBottomRef = useRef(true)
|
const forceScrollToBottomRef = useRef(true)
|
||||||
|
|
||||||
const selectedPreset = useMemo(
|
const selectedPreset = useMemo(
|
||||||
@@ -575,7 +579,7 @@ function Playground() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="playground-card__scroll">
|
<Scrollbar className="playground-card__scroll">
|
||||||
<Spin spinning={statusLoading}>
|
<Spin spinning={statusLoading}>
|
||||||
{providerStatus ? (
|
{providerStatus ? (
|
||||||
<div className="playground-provider-panel">
|
<div className="playground-provider-panel">
|
||||||
@@ -617,7 +621,7 @@ function Playground() {
|
|||||||
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
|
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</Scrollbar>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -678,7 +682,7 @@ function Playground() {
|
|||||||
title="AI Chatbox"
|
title="AI Chatbox"
|
||||||
extra={(
|
extra={(
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Tooltip title="服务状态">
|
<Tooltip title="服务状态" className="playground-chat__service-btn">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
@@ -699,7 +703,7 @@ function Playground() {
|
|||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="playground-chat__messages-shell">
|
<div ref={messagesShellRef} className="playground-chat__messages-shell">
|
||||||
<div className="playground-chat__messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
|
<div className="playground-chat__messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
|
||||||
{messages.map((entry) => (
|
{messages.map((entry) => (
|
||||||
<div key={entry.id} className={`playground-message playground-message--${entry.role}`}>
|
<div key={entry.id} className={`playground-message playground-message--${entry.role}`}>
|
||||||
@@ -761,6 +765,7 @@ function Playground() {
|
|||||||
发送
|
发送
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollbarOverlay containerRef={messagesShellRef} targetSelector=".playground-chat__messages" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? (
|
) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? (
|
||||||
@@ -845,7 +850,8 @@ function Playground() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="playground-chat__composer">
|
<div className="playground-chat__composer">
|
||||||
<div className="playground-chat__input-wrap">
|
<div className={`playground-chat__input-wrap${composerFocused || !!inputValue ? ' playground-chat__input-wrap--expanded' : ''}`}>
|
||||||
|
{(composerFocused || !!inputValue) && (
|
||||||
<div className="playground-preset-strip__actions">
|
<div className="playground-preset-strip__actions">
|
||||||
{PLAYGROUND_PRESETS.map((preset) => (
|
{PLAYGROUND_PRESETS.map((preset) => (
|
||||||
<Tag.CheckableTag
|
<Tag.CheckableTag
|
||||||
@@ -857,12 +863,16 @@ function Playground() {
|
|||||||
</Tag.CheckableTag>
|
</Tag.CheckableTag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="playground-chat__input-row">
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(event) => setInputValue(event.target.value)}
|
onChange={(event) => setInputValue(event.target.value)}
|
||||||
autoSize={{ minRows: 4, maxRows: 10 }}
|
autoSize={composerFocused || !!inputValue ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
|
||||||
placeholder="在这里输入本次分析请求。你可以写观察、问题、目标,或者直接贴一段待分析事实。"
|
placeholder="在这里输入本次分析请求..."
|
||||||
className="playground-chat__input"
|
className="playground-chat__input"
|
||||||
|
onFocus={() => setComposerFocused(true)}
|
||||||
|
onBlur={() => setComposerFocused(false)}
|
||||||
onPressEnter={(event) => {
|
onPressEnter={(event) => {
|
||||||
if (!event.shiftKey) {
|
if (!event.shiftKey) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -870,6 +880,20 @@ function Playground() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{!(composerFocused || !!inputValue) && (
|
||||||
|
<Tooltip title={sendButtonTooltip}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
className={`playground-chat__send-button${requestPending || streaming ? ' playground-chat__send-button--stop' : ''}`}
|
||||||
|
icon={requestPending || streaming ? <BorderOutlined /> : <ArrowUpOutlined />}
|
||||||
|
onClick={requestPending || streaming ? handleStop : () => void handleSend()}
|
||||||
|
aria-label={sendButtonTooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(composerFocused || !!inputValue) && (
|
||||||
<div className="playground-chat__actions">
|
<div className="playground-chat__actions">
|
||||||
<div className="playground-chat__hints">
|
<div className="playground-chat__hints">
|
||||||
<Tag>{title}</Tag>
|
<Tag>{title}</Tag>
|
||||||
@@ -886,6 +910,7 @@ function Playground() {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1000,42 +1025,42 @@ function Playground() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="playground-result-modal__content">
|
<Scrollbar className="playground-result-modal__content">
|
||||||
<Space direction="vertical" size={16} className="playground-result__stack" style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} className="playground-result__stack" style={{ width: '100%' }}>
|
||||||
{analysis.text_blocks.length ? (
|
{analysis.text_blocks.length ? (
|
||||||
<div className="playground-result__blocks">
|
<div className="playground-result__blocks">
|
||||||
<Text strong>文本块</Text>
|
<Text strong>文本块</Text>
|
||||||
<div className="playground-result__blocks-scroll">
|
<Scrollbar className="playground-result__blocks-scroll">
|
||||||
{analysis.text_blocks.map((block, index) => (
|
{analysis.text_blocks.map((block, index) => (
|
||||||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||||||
<pre>{block}</pre>
|
<pre>{block}</pre>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Scrollbar>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{analysis.thinking_blocks.length ? (
|
{analysis.thinking_blocks.length ? (
|
||||||
<div className="playground-result__blocks">
|
<div className="playground-result__blocks">
|
||||||
<Text strong>Thinking Blocks</Text>
|
<Text strong>Thinking Blocks</Text>
|
||||||
<div className="playground-result__blocks-scroll">
|
<Scrollbar className="playground-result__blocks-scroll">
|
||||||
{analysis.thinking_blocks.map((block, index) => (
|
{analysis.thinking_blocks.map((block, index) => (
|
||||||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||||||
<pre>{block}</pre>
|
<pre>{block}</pre>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Scrollbar>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="playground-result__blocks">
|
<div className="playground-result__blocks">
|
||||||
<Text strong>Raw Response</Text>
|
<Text strong>Raw Response</Text>
|
||||||
<div className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
|
<Scrollbar className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre>
|
<pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</Scrollbar>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</Scrollbar>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from 'antd'
|
} from 'antd'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
|
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@@ -91,7 +93,7 @@ function SettingsPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="settings-pane">
|
<div className="settings-pane">
|
||||||
<Card className="settings-panel-card" loading={loading}>
|
<Card className="settings-panel-card" loading={loading}>
|
||||||
<div className="settings-panel-scroll">{children}</div>
|
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -642,7 +644,7 @@ function Settings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-scroll-region data-source-table-region">
|
<TableScrollRegion className="data-source-table-region">
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={tvSourceColumns}
|
columns={tvSourceColumns}
|
||||||
@@ -652,7 +654,7 @@ function Settings() {
|
|||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,7 +670,7 @@ function Settings() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
>
|
>
|
||||||
<div ref={collectorTableRegionRef} className="table-scroll-region data-source-table-region">
|
<TableScrollRegion ref={collectorTableRegionRef} className="data-source-table-region">
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={collectorColumns}
|
columns={collectorColumns}
|
||||||
@@ -678,7 +680,7 @@ function Settings() {
|
|||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
|
|||||||
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
@@ -146,9 +147,9 @@ function Tasks() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="table-scroll-region">
|
<TableScrollRegion>
|
||||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
</Card>
|
</Card>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
|
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number
|
id: number
|
||||||
@@ -18,8 +19,6 @@ function Users() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [modalVisible, setModalVisible] = useState(false)
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const [tableHeight, setTableHeight] = useState(360)
|
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
@@ -36,24 +35,6 @@ function Users() {
|
|||||||
fetchUsers()
|
fetchUsers()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateTableHeight = () => {
|
|
||||||
const regionHeight = tableRegionRef.current?.offsetHeight || 0
|
|
||||||
setTableHeight(Math.max(220, regionHeight - 56))
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTableHeight()
|
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateTableHeight)
|
|
||||||
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
|
|
||||||
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [users.length])
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
setEditingUser(null)
|
setEditingUser(null)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
@@ -144,16 +125,17 @@ function Users() {
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-shell__body">
|
<div className="page-shell__body">
|
||||||
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region users-table-region" style={{ height: '100%' }}>
|
<TableScrollRegion className="data-source-table-region users-table-region">
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={users}
|
dataSource={users}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 'max-content', y: tableHeight }}
|
scroll={{ x: 960, y: 10000 }}
|
||||||
|
pagination={false}
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableScrollRegion>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.27.1"
|
version = "0.27.6"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
10
ue_client/Config/DefaultEngine.ini
Normal file
10
ue_client/Config/DefaultEngine.ini
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[/Script/Engine.RendererSettings]
|
||||||
|
r.DefaultFeature.AutoExposure.ExtendDefaultLuminanceRange=True
|
||||||
|
r.AntiAliasingMethod=4
|
||||||
|
|
||||||
|
[/Script/Engine.Engine]
|
||||||
|
NearClipPlane=1.0
|
||||||
|
|
||||||
|
[Core.System]
|
||||||
|
Paths=../../../Engine/Content
|
||||||
|
Paths=%GAMEDIR%Content
|
||||||
3
ue_client/Config/DefaultGame.ini
Normal file
3
ue_client/Config/DefaultGame.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[/Script/EngineSettings.GameMapsSettings]
|
||||||
|
GlobalDefaultGameMode=/Script/PlanetClient.PlanetGameMode
|
||||||
|
GameDefaultMap=/Game/Maps/EarthMap
|
||||||
6
ue_client/Config/DefaultInput.ini
Normal file
6
ue_client/Config/DefaultInput.ini
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[/Script/Engine.InputSettings]
|
||||||
|
+ActionMappings=(ActionName="SelectPoint",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=LeftMouseButton)
|
||||||
|
+ActionMappings=(ActionName="RightMouseDown",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=RightMouseButton)
|
||||||
|
+AxisMappings=(AxisName="ZoomCamera",Scale=-1.0,Key=MouseWheelAxis)
|
||||||
|
+AxisMappings=(AxisName="RotateYaw",Scale=1.0,Key=MouseX)
|
||||||
|
+AxisMappings=(AxisName="RotatePitch",Scale=-1.0,Key=MouseY)
|
||||||
135
ue_client/Content/Data/mock_compute_points.json
Normal file
135
ue_client/Content/Data/mock_compute_points.json
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"count": 10,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "top500_1",
|
||||||
|
"name": "Frontier",
|
||||||
|
"latitude": 36.01,
|
||||||
|
"longitude": -84.26,
|
||||||
|
"country": "United States",
|
||||||
|
"city": "Oak Ridge",
|
||||||
|
"rank": 1,
|
||||||
|
"rmax_tflops": 1194000.0,
|
||||||
|
"rpeak_tflops": 1679616.0,
|
||||||
|
"cores": 8730112,
|
||||||
|
"power_kw": 22703.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_2",
|
||||||
|
"name": "Aurora",
|
||||||
|
"latitude": 41.71,
|
||||||
|
"longitude": -87.98,
|
||||||
|
"country": "United States",
|
||||||
|
"city": "Lemont",
|
||||||
|
"rank": 2,
|
||||||
|
"rmax_tflops": 1012000.0,
|
||||||
|
"rpeak_tflops": 1321000.0,
|
||||||
|
"cores": 9264128,
|
||||||
|
"power_kw": 38698.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_3",
|
||||||
|
"name": "Eagle",
|
||||||
|
"latitude": 52.36,
|
||||||
|
"longitude": 4.90,
|
||||||
|
"country": "Netherlands",
|
||||||
|
"city": "Amsterdam",
|
||||||
|
"rank": 3,
|
||||||
|
"rmax_tflops": 561200.0,
|
||||||
|
"rpeak_tflops": 736000.0,
|
||||||
|
"cores": 4620800,
|
||||||
|
"power_kw": 12410.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_4",
|
||||||
|
"name": "Fugaku",
|
||||||
|
"latitude": 34.66,
|
||||||
|
"longitude": 135.22,
|
||||||
|
"country": "Japan",
|
||||||
|
"city": "Kobe",
|
||||||
|
"rank": 4,
|
||||||
|
"rmax_tflops": 442010.0,
|
||||||
|
"rpeak_tflops": 537212.0,
|
||||||
|
"cores": 7630848,
|
||||||
|
"power_kw": 29899.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_5",
|
||||||
|
"name": "LUMI",
|
||||||
|
"latitude": 65.01,
|
||||||
|
"longitude": 25.46,
|
||||||
|
"country": "Finland",
|
||||||
|
"city": "Kajaani",
|
||||||
|
"rank": 5,
|
||||||
|
"rmax_tflops": 379700.0,
|
||||||
|
"rpeak_tflops": 531000.0,
|
||||||
|
"cores": 2748384,
|
||||||
|
"power_kw": 6016.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_6",
|
||||||
|
"name": "MareNostrum 5",
|
||||||
|
"latitude": 41.39,
|
||||||
|
"longitude": 2.11,
|
||||||
|
"country": "Spain",
|
||||||
|
"city": "Barcelona",
|
||||||
|
"rank": 6,
|
||||||
|
"rmax_tflops": 138200.0,
|
||||||
|
"rpeak_tflops": 314000.0,
|
||||||
|
"cores": 2764800,
|
||||||
|
"power_kw": 10000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_7",
|
||||||
|
"name": "Summit",
|
||||||
|
"latitude": 35.93,
|
||||||
|
"longitude": -84.31,
|
||||||
|
"country": "United States",
|
||||||
|
"city": "Oak Ridge",
|
||||||
|
"rank": 7,
|
||||||
|
"rmax_tflops": 148600.0,
|
||||||
|
"rpeak_tflops": 200795.0,
|
||||||
|
"cores": 2414592,
|
||||||
|
"power_kw": 10096.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_8",
|
||||||
|
"name": "Tianhe-2A",
|
||||||
|
"latitude": 23.13,
|
||||||
|
"longitude": 113.27,
|
||||||
|
"country": "China",
|
||||||
|
"city": "Guangzhou",
|
||||||
|
"rank": 8,
|
||||||
|
"rmax_tflops": 100679.0,
|
||||||
|
"rpeak_tflops": 100679.0,
|
||||||
|
"cores": 4981760,
|
||||||
|
"power_kw": 18482.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_9",
|
||||||
|
"name": "Selene",
|
||||||
|
"latitude": 40.82,
|
||||||
|
"longitude": -74.17,
|
||||||
|
"country": "United States",
|
||||||
|
"city": "Santa Clara",
|
||||||
|
"rank": 9,
|
||||||
|
"rmax_tflops": 63460.0,
|
||||||
|
"rpeak_tflops": 79200.0,
|
||||||
|
"cores": 555520,
|
||||||
|
"power_kw": 2646.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top500_10",
|
||||||
|
"name": "Perlmutter",
|
||||||
|
"latitude": 37.88,
|
||||||
|
"longitude": -122.25,
|
||||||
|
"country": "United States",
|
||||||
|
"city": "Berkeley",
|
||||||
|
"rank": 10,
|
||||||
|
"rmax_tflops": 93750.0,
|
||||||
|
"rpeak_tflops": 120000.0,
|
||||||
|
"cores": 761856,
|
||||||
|
"power_kw": 3060.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
53
ue_client/Source/PlanetClient/ComputePointActor.cpp
Normal file
53
ue_client/Source/PlanetClient/ComputePointActor.cpp
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#include "ComputePointActor.h"
|
||||||
|
#include "Components/StaticMeshComponent.h"
|
||||||
|
|
||||||
|
AComputePointActor::AComputePointActor()
|
||||||
|
{
|
||||||
|
PrimaryActorTick.bCanEverTick = false;
|
||||||
|
|
||||||
|
SphereMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("SphereMesh"));
|
||||||
|
RootComponent = SphereMesh;
|
||||||
|
|
||||||
|
// Enable mouse-over events so the PlayerController can detect hover
|
||||||
|
SphereMesh->bReceivesDecals = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AComputePointActor::BeginPlay()
|
||||||
|
{
|
||||||
|
Super::BeginPlay();
|
||||||
|
SetActorScale3D(FVector(PointScale));
|
||||||
|
ApplyMaterial();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AComputePointActor::SetPointData(const FComputePoint& Data)
|
||||||
|
{
|
||||||
|
PointData = Data;
|
||||||
|
|
||||||
|
// Optional: name the actor in the Outliner for easy debugging
|
||||||
|
SetActorLabel(FString::Printf(TEXT("[%d] %s"), Data.Rank, *Data.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AComputePointActor::SetVisualState(EPointVisualState NewState)
|
||||||
|
{
|
||||||
|
if (VisualState == NewState) return;
|
||||||
|
VisualState = NewState;
|
||||||
|
ApplyMaterial();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AComputePointActor::ApplyMaterial()
|
||||||
|
{
|
||||||
|
if (!SphereMesh) return;
|
||||||
|
|
||||||
|
UMaterialInterface* Mat = nullptr;
|
||||||
|
switch (VisualState)
|
||||||
|
{
|
||||||
|
case EPointVisualState::Hovered: Mat = HoveredMaterial; break;
|
||||||
|
case EPointVisualState::Selected: Mat = SelectedMaterial; break;
|
||||||
|
default: Mat = NormalMaterial; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Mat)
|
||||||
|
{
|
||||||
|
SphereMesh->SetMaterial(0, Mat);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
ue_client/Source/PlanetClient/ComputePointActor.h
Normal file
95
ue_client/Source/PlanetClient/ComputePointActor.h
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "GameFramework/Actor.h"
|
||||||
|
#include "PlanetDataTypes.h"
|
||||||
|
#include "ComputePointActor.generated.h"
|
||||||
|
|
||||||
|
class UStaticMeshComponent;
|
||||||
|
class UBillboardComponent;
|
||||||
|
|
||||||
|
UENUM(BlueprintType)
|
||||||
|
enum class EPointVisualState : uint8
|
||||||
|
{
|
||||||
|
Normal UMETA(DisplayName="Normal"),
|
||||||
|
Hovered UMETA(DisplayName="Hovered"),
|
||||||
|
Selected UMETA(DisplayName="Selected"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AComputePointActor
|
||||||
|
*
|
||||||
|
* Represents one supercomputer data point on the Cesium globe.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Create a Blueprint subclass: Content Browser → New Blueprint →
|
||||||
|
* search "AComputePointActor" as parent class → name it BP_ComputePointActor
|
||||||
|
* 2. In the Blueprint, assign a sphere mesh to SphereMesh (Engine/BasicShapes/Sphere)
|
||||||
|
* 3. Assign a material to NormalMaterial / HoveredMaterial / SelectedMaterial
|
||||||
|
* 4. Set this Blueprint class in APlanetDataManager → ComputePointClass
|
||||||
|
*/
|
||||||
|
UCLASS(BlueprintType, Blueprintable)
|
||||||
|
class PLANETCLIENT_API AComputePointActor : public AActor
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
AComputePointActor();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Components
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The visible sphere. Assign a sphere mesh in your Blueprint subclass.
|
||||||
|
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Planet|Components")
|
||||||
|
UStaticMeshComponent* SphereMesh;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Materials (assign in Blueprint subclass)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
|
||||||
|
UMaterialInterface* NormalMaterial;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
|
||||||
|
UMaterialInterface* HoveredMaterial;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
|
||||||
|
UMaterialInterface* SelectedMaterial;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Point scale (world units). Tweak this in the Details Panel.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
|
||||||
|
float PointScale = 80000.f;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Data (set by APlanetDataManager after spawn)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FComputePoint PointData;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
EPointVisualState VisualState = EPointVisualState::Normal;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Called by APlanetDataManager immediately after spawning.
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||||
|
void SetPointData(const FComputePoint& Data);
|
||||||
|
|
||||||
|
// Called by PlanetPlayerController on hover/select.
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||||
|
void SetVisualState(EPointVisualState NewState);
|
||||||
|
|
||||||
|
// Convenience: is this point currently selected?
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet")
|
||||||
|
bool IsSelected() const { return VisualState == EPointVisualState::Selected; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void BeginPlay() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void ApplyMaterial();
|
||||||
|
};
|
||||||
122
ue_client/Source/PlanetClient/GlobeInteractionComponent.cpp
Normal file
122
ue_client/Source/PlanetClient/GlobeInteractionComponent.cpp
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#include "GlobeInteractionComponent.h"
|
||||||
|
#include "CesiumGeoreference.h"
|
||||||
|
#include "Engine/World.h"
|
||||||
|
#include "GameFramework/Pawn.h"
|
||||||
|
#include "GameFramework/PlayerController.h"
|
||||||
|
#include "Kismet/GameplayStatics.h"
|
||||||
|
|
||||||
|
UGlobeInteractionComponent::UGlobeInteractionComponent()
|
||||||
|
{
|
||||||
|
PrimaryComponentTick.bCanEverTick = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UGlobeInteractionComponent::BeginPlay()
|
||||||
|
{
|
||||||
|
Super::BeginPlay();
|
||||||
|
FindGeoreference();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UGlobeInteractionComponent::FindGeoreference()
|
||||||
|
{
|
||||||
|
Georeference = ACesiumGeoreference::GetDefaultGeoreference(GetWorld());
|
||||||
|
if (!Georeference)
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error,
|
||||||
|
TEXT("GlobeInteractionComponent: 场景中没有 CesiumGeoreference,请添加一个"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 旋转
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void UGlobeInteractionComponent::RotateGlobe(float DeltaYaw, float DeltaPitch)
|
||||||
|
{
|
||||||
|
if (!Georeference) return;
|
||||||
|
|
||||||
|
// 累积惯性速度(本帧的输入叠加到惯性上)
|
||||||
|
InertiaYaw += DeltaYaw * RotateSensitivity;
|
||||||
|
InertiaPitch += DeltaPitch * RotateSensitivity;
|
||||||
|
|
||||||
|
// 立即应用(Tick 里再处理惯性衰减)
|
||||||
|
double NewLon = Georeference->GetOriginLongitude() - InertiaYaw;
|
||||||
|
double NewLat = FMath::Clamp(
|
||||||
|
Georeference->GetOriginLatitude() + InertiaPitch,
|
||||||
|
-85.0, 85.0
|
||||||
|
);
|
||||||
|
|
||||||
|
Georeference->SetOriginLongitude(NewLon);
|
||||||
|
Georeference->SetOriginLatitude(NewLat);
|
||||||
|
|
||||||
|
// 保持经度在 [-180, 180]
|
||||||
|
if (NewLon > 180.0) Georeference->SetOriginLongitude(NewLon - 360.0);
|
||||||
|
if (NewLon < -180.0) Georeference->SetOriginLongitude(NewLon + 360.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 缩放
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void UGlobeInteractionComponent::ZoomGlobe(float Value)
|
||||||
|
{
|
||||||
|
if (FMath::IsNearlyZero(Value)) return;
|
||||||
|
|
||||||
|
// Value > 0 = 拉近(高度减小),Value < 0 = 推远(高度增大)
|
||||||
|
CurrentAltitudeMeters -= Value * ZoomSensitivity;
|
||||||
|
CurrentAltitudeMeters = FMath::Clamp(CurrentAltitudeMeters,
|
||||||
|
MinAltitudeMeters,
|
||||||
|
MaxAltitudeMeters);
|
||||||
|
ApplyAltitudeToCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UGlobeInteractionComponent::ApplyAltitudeToCamera()
|
||||||
|
{
|
||||||
|
if (!Georeference) return;
|
||||||
|
|
||||||
|
// 把当前 Origin 经纬度 + 新高度转换为 Unreal 世界坐标,然后移动拥有者 Pawn
|
||||||
|
AController* Controller = Cast<AController>(GetOwner());
|
||||||
|
APawn* Pawn = Controller ? Controller->GetPawn() : Cast<APawn>(GetOwner());
|
||||||
|
if (!Pawn) return;
|
||||||
|
|
||||||
|
double Lon = Georeference->GetOriginLongitude();
|
||||||
|
double Lat = Georeference->GetOriginLatitude();
|
||||||
|
|
||||||
|
FVector NewPos = Georeference->TransformLongitudeLatitudeHeightPositionToUnreal(
|
||||||
|
FVector(Lon, Lat, CurrentAltitudeMeters)
|
||||||
|
);
|
||||||
|
Pawn->SetActorLocation(NewPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 惯性 Tick
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void UGlobeInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType,
|
||||||
|
FActorComponentTickFunction* ThisTickFunction)
|
||||||
|
{
|
||||||
|
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
|
||||||
|
|
||||||
|
// 只有惯性速度足够大时才继续转动
|
||||||
|
if (FMath::IsNearlyZero(InertiaYaw, 0.001f) &&
|
||||||
|
FMath::IsNearlyZero(InertiaPitch, 0.001f)) return;
|
||||||
|
|
||||||
|
if (!Georeference) return;
|
||||||
|
|
||||||
|
double NewLon = Georeference->GetOriginLongitude() - InertiaYaw;
|
||||||
|
double NewLat = FMath::Clamp(
|
||||||
|
Georeference->GetOriginLatitude() + InertiaPitch,
|
||||||
|
-85.0, 85.0
|
||||||
|
);
|
||||||
|
Georeference->SetOriginLongitude(NewLon);
|
||||||
|
Georeference->SetOriginLatitude(NewLat);
|
||||||
|
|
||||||
|
// 惯性衰减
|
||||||
|
InertiaYaw *= InertiaDamping;
|
||||||
|
InertiaPitch *= InertiaDamping;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UGlobeInteractionComponent::StopInertia()
|
||||||
|
{
|
||||||
|
InertiaYaw = 0.f;
|
||||||
|
InertiaPitch = 0.f;
|
||||||
|
}
|
||||||
96
ue_client/Source/PlanetClient/GlobeInteractionComponent.h
Normal file
96
ue_client/Source/PlanetClient/GlobeInteractionComponent.h
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "Components/ActorComponent.h"
|
||||||
|
#include "GlobeInteractionComponent.generated.h"
|
||||||
|
|
||||||
|
class ACesiumGeoreference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UGlobeInteractionComponent
|
||||||
|
*
|
||||||
|
* 挂在 PlayerController 或 Pawn 上,处理地球旋转和缩放。
|
||||||
|
*
|
||||||
|
* 旋转原理:
|
||||||
|
* 修改 CesiumGeoreference 的 OriginLongitude / OriginLatitude,
|
||||||
|
* 相当于"移动地球"到新中心,视觉效果等同于旋转地球。
|
||||||
|
*
|
||||||
|
* 缩放原理:
|
||||||
|
* 沿相机到地球中心方向移动相机,改变观察高度。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* 在 PlanetPlayerController::BeginPlay 里 AddComponent,
|
||||||
|
* 收到拖拽/滚轮输入时调用 RotateGlobe / ZoomGlobe。
|
||||||
|
*/
|
||||||
|
UCLASS(ClassGroup=(Planet), meta=(BlueprintSpawnableComponent), BlueprintType)
|
||||||
|
class PLANETCLIENT_API UGlobeInteractionComponent : public UActorComponent
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
UGlobeInteractionComponent();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 可在 Details 面板调整的参数
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 拖拽灵敏度:每像素对应的经纬度偏移量(度)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
|
||||||
|
float RotateSensitivity = 0.15f;
|
||||||
|
|
||||||
|
// 缩放灵敏度:每格滚轮对应的高度变化(米)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
|
||||||
|
float ZoomSensitivity = 200000.f;
|
||||||
|
|
||||||
|
// 最小观察高度(米),防止穿入地面
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
|
||||||
|
double MinAltitudeMeters = 500000.0;
|
||||||
|
|
||||||
|
// 最大观察高度(米)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
|
||||||
|
double MaxAltitudeMeters = 30000000.0;
|
||||||
|
|
||||||
|
// 旋转惯性平滑系数 (0=无惯性, 1=永不停)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe",
|
||||||
|
meta=(ClampMin="0.0", ClampMax="0.99"))
|
||||||
|
float InertiaDamping = 0.85f;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 公开 API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 每帧由 PlayerController 调用,传入鼠标位移(像素)
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
|
||||||
|
void RotateGlobe(float DeltaYaw, float DeltaPitch);
|
||||||
|
|
||||||
|
// 每帧由 PlayerController 调用,Value > 0 = 拉近,< 0 = 推远
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
|
||||||
|
void ZoomGlobe(float Value);
|
||||||
|
|
||||||
|
// 立即停止惯性
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
|
||||||
|
void StopInertia();
|
||||||
|
|
||||||
|
// 返回当前相机高度(米)
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet|Globe")
|
||||||
|
double GetCurrentAltitudeMeters() const { return CurrentAltitudeMeters; }
|
||||||
|
|
||||||
|
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
|
||||||
|
FActorComponentTickFunction* ThisTickFunction) override;
|
||||||
|
|
||||||
|
virtual void BeginPlay() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
UPROPERTY()
|
||||||
|
ACesiumGeoreference* Georeference = nullptr;
|
||||||
|
|
||||||
|
// 惯性速度(经度/帧,纬度/帧)
|
||||||
|
float InertiaYaw = 0.f;
|
||||||
|
float InertiaPitch = 0.f;
|
||||||
|
|
||||||
|
// 当前相机高度(米),随 Zoom 更新
|
||||||
|
double CurrentAltitudeMeters = 5000000.0;
|
||||||
|
|
||||||
|
void FindGeoreference();
|
||||||
|
void ApplyAltitudeToCamera();
|
||||||
|
};
|
||||||
53
ue_client/Source/PlanetClient/InteractiveObjectBase.cpp
Normal file
53
ue_client/Source/PlanetClient/InteractiveObjectBase.cpp
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#include "InteractiveObjectBase.h"
|
||||||
|
|
||||||
|
AInteractiveObjectBase::AInteractiveObjectBase()
|
||||||
|
{
|
||||||
|
PrimaryActorTick.bCanEverTick = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AInteractiveObjectBase::SetInteractiveState(EInteractiveObjectState NewState)
|
||||||
|
{
|
||||||
|
if (CurrentState == NewState) return;
|
||||||
|
if (CurrentState == EInteractiveObjectState::Disabled &&
|
||||||
|
NewState != EInteractiveObjectState::Normal) return;
|
||||||
|
|
||||||
|
EInteractiveObjectState OldState = CurrentState;
|
||||||
|
CurrentState = NewState;
|
||||||
|
|
||||||
|
switch (NewState)
|
||||||
|
{
|
||||||
|
case EInteractiveObjectState::Hovered: OnHovered(); break;
|
||||||
|
case EInteractiveObjectState::Selected: OnSelected(); break;
|
||||||
|
case EInteractiveObjectState::Normal:
|
||||||
|
if (OldState == EInteractiveObjectState::Selected) OnDeselected();
|
||||||
|
else OnRestored();
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnStateChanged.Broadcast(NewState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 默认实现(子类可重写)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FText AInteractiveObjectBase::GetInfoTitle_Implementation() const
|
||||||
|
{
|
||||||
|
return FText::FromString(GetActorLabel());
|
||||||
|
}
|
||||||
|
|
||||||
|
FText AInteractiveObjectBase::GetInfoBody_Implementation() const
|
||||||
|
{
|
||||||
|
return FText::FromString(TEXT(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
UTexture2D* AInteractiveObjectBase::GetInfoIcon_Implementation() const
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AInteractiveObjectBase::OnHovered_Implementation() {}
|
||||||
|
void AInteractiveObjectBase::OnSelected_Implementation() {}
|
||||||
|
void AInteractiveObjectBase::OnDeselected_Implementation(){}
|
||||||
|
void AInteractiveObjectBase::OnRestored_Implementation() {}
|
||||||
93
ue_client/Source/PlanetClient/InteractiveObjectBase.h
Normal file
93
ue_client/Source/PlanetClient/InteractiveObjectBase.h
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "GameFramework/Actor.h"
|
||||||
|
#include "InteractiveObjectBase.generated.h"
|
||||||
|
|
||||||
|
UENUM(BlueprintType)
|
||||||
|
enum class EInteractiveObjectState : uint8
|
||||||
|
{
|
||||||
|
Normal UMETA(DisplayName="正常"),
|
||||||
|
Hovered UMETA(DisplayName="悬停高亮"),
|
||||||
|
Selected UMETA(DisplayName="已选中"),
|
||||||
|
Disabled UMETA(DisplayName="不可交互"),
|
||||||
|
};
|
||||||
|
|
||||||
|
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnObjectStateChanged,
|
||||||
|
EInteractiveObjectState, NewState);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AInteractiveObjectBase
|
||||||
|
*
|
||||||
|
* 所有可交互 3D 物件的基类。
|
||||||
|
* 当前场景中的可交互物件包括:
|
||||||
|
* - AComputePointActor(超算数据点)—— 已实现
|
||||||
|
* - 其他演示物件(TODO: 根据需求添加子类)
|
||||||
|
*
|
||||||
|
* 子类需要:
|
||||||
|
* 1. 重写 OnHovered / OnSelected / OnDeselected(或响应 OnStateChanged 委托)
|
||||||
|
* 2. 将根 Component 设置为可被射线检测(SetCollisionResponseToChannel)
|
||||||
|
* 3. 可选:重写 GetInfoTitle / GetInfoBody 供信息卡使用
|
||||||
|
*/
|
||||||
|
UCLASS(Abstract, BlueprintType, Blueprintable)
|
||||||
|
class PLANETCLIENT_API AInteractiveObjectBase : public AActor
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
AInteractiveObjectBase();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 状态变更委托
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(BlueprintAssignable, Category="Planet|Interactive")
|
||||||
|
FOnObjectStateChanged OnStateChanged;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 状态 API(由 PlanetPlayerController 调用)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|Interactive")
|
||||||
|
void SetInteractiveState(EInteractiveObjectState NewState);
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
|
||||||
|
EInteractiveObjectState GetInteractiveState() const { return CurrentState; }
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
|
||||||
|
bool IsSelected() const { return CurrentState == EInteractiveObjectState::Selected; }
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
|
||||||
|
bool IsHovered() const { return CurrentState == EInteractiveObjectState::Hovered; }
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 信息卡内容(子类可重写提供具体文本)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 信息卡标题,默认返回 Actor 名
|
||||||
|
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
|
||||||
|
FText GetInfoTitle() const;
|
||||||
|
|
||||||
|
// 信息卡正文,返回富文本格式的详情
|
||||||
|
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
|
||||||
|
FText GetInfoBody() const;
|
||||||
|
|
||||||
|
// 信息卡图标(可选,返回空则不显示图标)
|
||||||
|
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
|
||||||
|
UTexture2D* GetInfoIcon() const;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// 子类重写这三个函数来处理视觉状态变化
|
||||||
|
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
|
||||||
|
void OnHovered();
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
|
||||||
|
void OnSelected();
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
|
||||||
|
void OnDeselected();
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
|
||||||
|
void OnRestored(); // 从任何状态回到 Normal
|
||||||
|
|
||||||
|
private:
|
||||||
|
EInteractiveObjectState CurrentState = EInteractiveObjectState::Normal;
|
||||||
|
};
|
||||||
150
ue_client/Source/PlanetClient/MotionCaptureInterface.h
Normal file
150
ue_client/Source/PlanetClient/MotionCaptureInterface.h
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "UObject/Interface.h"
|
||||||
|
#include "MotionCaptureInterface.generated.h"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 动捕事件 —— 供应商中间件触发这些事件,UE5 侧响应
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手势类型枚举
|
||||||
|
*
|
||||||
|
* TODO(等动捕供应商确认手势集合后补充):
|
||||||
|
* 当前列出的是推测的基本手势,以实际交付为准。
|
||||||
|
*/
|
||||||
|
UENUM(BlueprintType)
|
||||||
|
enum class EMotionGesture : uint8
|
||||||
|
{
|
||||||
|
None UMETA(DisplayName="无"),
|
||||||
|
SwipeLeft UMETA(DisplayName="向左划"), // 地球向左转
|
||||||
|
SwipeRight UMETA(DisplayName="向右划"), // 地球向右转
|
||||||
|
SwipeUp UMETA(DisplayName="向上划"), // 地球向上转
|
||||||
|
SwipeDown UMETA(DisplayName="向下划"), // 地球向下转
|
||||||
|
PinchIn UMETA(DisplayName="捏合(缩小)"),// 缩放
|
||||||
|
PinchOut UMETA(DisplayName="展开(放大)"),// 缩放
|
||||||
|
Point UMETA(DisplayName="指向"), // 悬停选中
|
||||||
|
Confirm UMETA(DisplayName="确认"), // 点击选中
|
||||||
|
Dismiss UMETA(DisplayName="挥手取消"), // 关闭信息卡
|
||||||
|
};
|
||||||
|
|
||||||
|
// 动捕系统触发某个离散手势时广播
|
||||||
|
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnGestureDetected, EMotionGesture, Gesture);
|
||||||
|
|
||||||
|
// 动捕系统持续输出指向方向时广播(用于悬停高亮)
|
||||||
|
// Direction: 归一化方向向量,由动捕系统解算后传入
|
||||||
|
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnPointingDirectionChanged, FVector, Direction);
|
||||||
|
|
||||||
|
// 动捕系统输出旋转增量时广播(用于连续拖拽旋转地球)
|
||||||
|
// DeltaYaw/DeltaPitch: 单帧内的角度增量(度)
|
||||||
|
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnRotateDelta, float, DeltaYaw, float, DeltaPitch);
|
||||||
|
|
||||||
|
// 动捕系统输出缩放增量时广播
|
||||||
|
// DeltaScale: > 0 放大,< 0 缩小
|
||||||
|
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnZoomDelta, float, DeltaScale);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UMotionCaptureReceiver
|
||||||
|
*
|
||||||
|
* 动捕接收器基类。根据供应商协议,创建对应的子类实现:
|
||||||
|
*
|
||||||
|
* - LiveLink 协议 → UMotionCaptureReceiverLiveLink (TODO)
|
||||||
|
* - OSC/UDP 协议 → UMotionCaptureReceiverOSC (TODO)
|
||||||
|
*
|
||||||
|
* 子类负责接收数据并调用 BroadcastXxx() 方法,
|
||||||
|
* PlanetPlayerController 订阅这些委托即可,不关心底层协议。
|
||||||
|
*
|
||||||
|
* TODO(等动捕供应商回复后):
|
||||||
|
* 1. 确认协议(Live Link / OSC / 私有 SDK)
|
||||||
|
* 2. 在对应子类里实现连接和数据接收
|
||||||
|
* 3. 把子类拖进场景,在 PlanetDataManager / PlayerController 里引用它
|
||||||
|
*/
|
||||||
|
UCLASS(Abstract, BlueprintType, Blueprintable)
|
||||||
|
class PLANETCLIENT_API UMotionCaptureReceiver : public UObject
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 外部订阅这些委托
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
|
||||||
|
FOnGestureDetected OnGestureDetected;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
|
||||||
|
FOnPointingDirectionChanged OnPointingDirectionChanged;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
|
||||||
|
FOnRotateDelta OnRotateDelta;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
|
||||||
|
FOnZoomDelta OnZoomDelta;
|
||||||
|
|
||||||
|
// 连接到动捕中间件(子类实现)
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|MotionCapture")
|
||||||
|
virtual void Connect() {}
|
||||||
|
|
||||||
|
// 断开连接(子类实现)
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|MotionCapture")
|
||||||
|
virtual void Disconnect() {}
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet|MotionCapture")
|
||||||
|
virtual bool IsConnected() const { return false; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// 子类解析到数据后调用这些方法广播
|
||||||
|
void BroadcastGesture(EMotionGesture Gesture)
|
||||||
|
{
|
||||||
|
OnGestureDetected.Broadcast(Gesture);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BroadcastPointing(const FVector& Direction)
|
||||||
|
{
|
||||||
|
OnPointingDirectionChanged.Broadcast(Direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BroadcastRotateDelta(float DeltaYaw, float DeltaPitch)
|
||||||
|
{
|
||||||
|
OnRotateDelta.Broadcast(DeltaYaw, DeltaPitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BroadcastZoomDelta(float DeltaScale)
|
||||||
|
{
|
||||||
|
OnZoomDelta.Broadcast(DeltaScale);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 动作映射配置
|
||||||
|
//
|
||||||
|
// 把动捕手势映射到具体的 UE 操作,在 Details 面板里可以改。
|
||||||
|
// 这样不用改代码就能重新映射手势。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
USTRUCT(BlueprintType)
|
||||||
|
struct FMotionActionMapping
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
// 触发"旋转地球"的手势(持续输出增量)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||||
|
EMotionGesture RotateGlobe = EMotionGesture::None; // TODO: 填入供应商手势名
|
||||||
|
|
||||||
|
// 触发"放大"的手势
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||||
|
EMotionGesture ZoomIn = EMotionGesture::PinchOut;
|
||||||
|
|
||||||
|
// 触发"缩小"的手势
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||||
|
EMotionGesture ZoomOut = EMotionGesture::PinchIn;
|
||||||
|
|
||||||
|
// 触发"选中数据点"的手势
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||||
|
EMotionGesture SelectPoint = EMotionGesture::Confirm;
|
||||||
|
|
||||||
|
// 触发"关闭信息卡"的手势
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||||
|
EMotionGesture DismissInfoCard = EMotionGesture::Dismiss;
|
||||||
|
};
|
||||||
33
ue_client/Source/PlanetClient/PlanetClient.Build.cs
Normal file
33
ue_client/Source/PlanetClient/PlanetClient.Build.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using UnrealBuildTool;
|
||||||
|
|
||||||
|
public class PlanetClient : ModuleRules
|
||||||
|
{
|
||||||
|
public PlanetClient(ReadOnlyTargetRules Target) : base(Target)
|
||||||
|
{
|
||||||
|
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||||
|
|
||||||
|
PublicDependencyModuleNames.AddRange(new string[]
|
||||||
|
{
|
||||||
|
"Core",
|
||||||
|
"CoreUObject",
|
||||||
|
"Engine",
|
||||||
|
"InputCore",
|
||||||
|
"HTTP",
|
||||||
|
"Json",
|
||||||
|
"JsonUtilities",
|
||||||
|
"CesiumRuntime",
|
||||||
|
"UMG",
|
||||||
|
"Slate",
|
||||||
|
"SlateCore",
|
||||||
|
"RenderCore", // StereoRenderingManager 用到
|
||||||
|
"RHI", // 渲染硬件接口
|
||||||
|
// TODO: 动捕协议确认后按需添加:
|
||||||
|
// "LiveLink", // Live Link 协议
|
||||||
|
// "LiveLinkInterface",
|
||||||
|
// "Networking", // OSC/UDP 协议
|
||||||
|
// "Sockets",
|
||||||
|
});
|
||||||
|
|
||||||
|
PrivateDependencyModuleNames.AddRange(new string[] { });
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ue_client/Source/PlanetClient/PlanetClient.cpp
Normal file
4
ue_client/Source/PlanetClient/PlanetClient.cpp
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#include "PlanetClient.h"
|
||||||
|
#include "Modules/ModuleManager.h"
|
||||||
|
|
||||||
|
IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, PlanetClient, "PlanetClient");
|
||||||
3
ue_client/Source/PlanetClient/PlanetClient.h
Normal file
3
ue_client/Source/PlanetClient/PlanetClient.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
220
ue_client/Source/PlanetClient/PlanetDataManager.cpp
Normal file
220
ue_client/Source/PlanetClient/PlanetDataManager.cpp
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
#include "PlanetDataManager.h"
|
||||||
|
#include "ComputePointActor.h"
|
||||||
|
|
||||||
|
#include "HttpModule.h"
|
||||||
|
#include "Interfaces/IHttpResponse.h"
|
||||||
|
#include "Dom/JsonObject.h"
|
||||||
|
#include "Dom/JsonValue.h"
|
||||||
|
#include "Serialization/JsonReader.h"
|
||||||
|
#include "Serialization/JsonSerializer.h"
|
||||||
|
#include "Misc/FileHelper.h"
|
||||||
|
#include "Misc/Paths.h"
|
||||||
|
#include "Engine/World.h"
|
||||||
|
|
||||||
|
// Cesium coordinate conversion
|
||||||
|
#include "CesiumGeoreference.h"
|
||||||
|
|
||||||
|
APlanetDataManager::APlanetDataManager()
|
||||||
|
{
|
||||||
|
PrimaryActorTick.bCanEverTick = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetDataManager::BeginPlay()
|
||||||
|
{
|
||||||
|
Super::BeginPlay();
|
||||||
|
|
||||||
|
// Build default mock path if not overridden
|
||||||
|
if (MockDataPath.IsEmpty())
|
||||||
|
{
|
||||||
|
MockDataPath = FPaths::ProjectContentDir() / TEXT("Data/mock_compute_points.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
FetchAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetDataManager::FetchAllData()
|
||||||
|
{
|
||||||
|
// Clear previous actors
|
||||||
|
for (AComputePointActor* Point : SpawnedPoints)
|
||||||
|
{
|
||||||
|
if (IsValid(Point)) Point->Destroy();
|
||||||
|
}
|
||||||
|
SpawnedPoints.Empty();
|
||||||
|
|
||||||
|
if (bUseLocalMockData)
|
||||||
|
{
|
||||||
|
LoadMockComputePoints();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
FetchComputePoints();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase A: local JSON file
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetDataManager::LoadMockComputePoints()
|
||||||
|
{
|
||||||
|
FString JsonStr;
|
||||||
|
if (!FFileHelper::LoadFileToString(JsonStr, *MockDataPath))
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: cannot read mock file: %s"), *MockDataPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TArray<FComputePoint> Points = ParseComputePointsJson(JsonStr);
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: loaded %d points from mock file"), Points.Num());
|
||||||
|
SpawnComputePoints(Points);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase B: HTTP request
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetDataManager::FetchComputePoints()
|
||||||
|
{
|
||||||
|
const FString Url = BackendBaseUrl + TEXT("/api/v1/ue/compute-points");
|
||||||
|
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Req = FHttpModule::Get().CreateRequest();
|
||||||
|
Req->SetURL(Url);
|
||||||
|
Req->SetVerb(TEXT("GET"));
|
||||||
|
Req->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
|
||||||
|
Req->OnProcessRequestComplete().BindUObject(
|
||||||
|
this, &APlanetDataManager::OnComputePointsResponse);
|
||||||
|
Req->ProcessRequest();
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: GET %s"), *Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetDataManager::OnComputePointsResponse(FHttpRequestPtr Request,
|
||||||
|
FHttpResponsePtr Response,
|
||||||
|
bool bSuccess)
|
||||||
|
{
|
||||||
|
if (!bSuccess || !Response.IsValid())
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error,
|
||||||
|
TEXT("PlanetDataManager: HTTP request failed. Is the backend running at %s?"),
|
||||||
|
*BackendBaseUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Response->GetResponseCode() != 200)
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: HTTP %d from %s"),
|
||||||
|
Response->GetResponseCode(), *Request->GetURL());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TArray<FComputePoint> Points = ParseComputePointsJson(Response->GetContentAsString());
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: received %d compute points"), Points.Num());
|
||||||
|
SpawnComputePoints(Points);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TArray<FComputePoint> APlanetDataManager::ParseComputePointsJson(const FString& JsonStr)
|
||||||
|
{
|
||||||
|
TArray<FComputePoint> Result;
|
||||||
|
|
||||||
|
TSharedPtr<FJsonObject> Root;
|
||||||
|
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonStr);
|
||||||
|
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: JSON parse failed"));
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TArray<TSharedPtr<FJsonValue>>* Items;
|
||||||
|
if (!Root->TryGetArrayField(TEXT("items"), Items))
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: no 'items' array in JSON"));
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const TSharedPtr<FJsonValue>& Val : *Items)
|
||||||
|
{
|
||||||
|
const TSharedPtr<FJsonObject>* ObjPtr;
|
||||||
|
if (!Val->TryGetObject(ObjPtr)) continue;
|
||||||
|
const TSharedPtr<FJsonObject>& Obj = *ObjPtr;
|
||||||
|
|
||||||
|
FComputePoint Pt;
|
||||||
|
Obj->TryGetStringField(TEXT("id"), Pt.Id);
|
||||||
|
Obj->TryGetStringField(TEXT("name"), Pt.Name);
|
||||||
|
Obj->TryGetStringField(TEXT("country"), Pt.Country);
|
||||||
|
Obj->TryGetStringField(TEXT("city"), Pt.City);
|
||||||
|
|
||||||
|
double Lat, Lon;
|
||||||
|
if (!Obj->TryGetNumberField(TEXT("latitude"), Lat)) continue;
|
||||||
|
if (!Obj->TryGetNumberField(TEXT("longitude"), Lon)) continue;
|
||||||
|
Pt.Latitude = (float)Lat;
|
||||||
|
Pt.Longitude = (float)Lon;
|
||||||
|
|
||||||
|
int32 Rank;
|
||||||
|
if (Obj->TryGetNumberField(TEXT("rank"), Rank)) Pt.Rank = Rank;
|
||||||
|
|
||||||
|
double Rmax;
|
||||||
|
if (Obj->TryGetNumberField(TEXT("rmax_tflops"), Rmax)) Pt.RmaxTFlops = (float)Rmax;
|
||||||
|
double Rpeak;
|
||||||
|
if (Obj->TryGetNumberField(TEXT("rpeak_tflops"), Rpeak)) Pt.RpeakTFlops = (float)Rpeak;
|
||||||
|
int32 Cores;
|
||||||
|
if (Obj->TryGetNumberField(TEXT("cores"), Cores)) Pt.Cores = Cores;
|
||||||
|
double Power;
|
||||||
|
if (Obj->TryGetNumberField(TEXT("power_kw"), Power)) Pt.PowerKw = (float)Power;
|
||||||
|
|
||||||
|
Result.Add(Pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spawn actors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetDataManager::SpawnComputePoints(const TArray<FComputePoint>& Points)
|
||||||
|
{
|
||||||
|
if (!ComputePointClass)
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("PlanetDataManager: ComputePointClass not set — set it in the Details Panel"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UWorld* World = GetWorld();
|
||||||
|
if (!World) return;
|
||||||
|
|
||||||
|
// Find the CesiumGeoreference in the level
|
||||||
|
ACesiumGeoreference* Georeference = ACesiumGeoreference::GetDefaultGeoreference(World);
|
||||||
|
if (!Georeference)
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error,
|
||||||
|
TEXT("PlanetDataManager: no CesiumGeoreference in level. Add a CesiumGeoreference actor."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const FComputePoint& Pt : Points)
|
||||||
|
{
|
||||||
|
// Convert geographic coordinates to Unreal world coordinates
|
||||||
|
// Altitude is PointAltitudeMeters above sea level
|
||||||
|
FVector WorldPos = Georeference->TransformLongitudeLatitudeHeightPositionToUnreal(
|
||||||
|
FVector(Pt.Longitude, Pt.Latitude, PointAltitudeMeters)
|
||||||
|
);
|
||||||
|
|
||||||
|
FActorSpawnParameters Params;
|
||||||
|
Params.SpawnCollisionHandlingOverride =
|
||||||
|
ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
|
||||||
|
|
||||||
|
AComputePointActor* Actor = World->SpawnActor<AComputePointActor>(
|
||||||
|
ComputePointClass, WorldPos, FRotator::ZeroRotator, Params);
|
||||||
|
|
||||||
|
if (Actor)
|
||||||
|
{
|
||||||
|
Actor->SetPointData(Pt);
|
||||||
|
SpawnedPoints.Add(Actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnComputePointsLoaded.Broadcast(SpawnedPoints.Num());
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: spawned %d compute point actors"),
|
||||||
|
SpawnedPoints.Num());
|
||||||
|
}
|
||||||
97
ue_client/Source/PlanetClient/PlanetDataManager.h
Normal file
97
ue_client/Source/PlanetClient/PlanetDataManager.h
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "GameFramework/Actor.h"
|
||||||
|
#include "Interfaces/IHttpRequest.h"
|
||||||
|
#include "PlanetDataTypes.h"
|
||||||
|
#include "PlanetDataManager.generated.h"
|
||||||
|
|
||||||
|
class AComputePointActor;
|
||||||
|
|
||||||
|
// Fired once all compute points have been spawned into the world
|
||||||
|
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnComputePointsLoaded, int32, Count);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APlanetDataManager
|
||||||
|
*
|
||||||
|
* Place one of these in your level. On BeginPlay it fetches data from the
|
||||||
|
* Planet backend and spawns AComputePointActor instances at the correct
|
||||||
|
* Cesium globe positions.
|
||||||
|
*
|
||||||
|
* Phase A (local): Set bUseLocalMockData = true and point MockDataPath to
|
||||||
|
* Content/Data/mock_compute_points.json.
|
||||||
|
* Phase B (live): Set bUseLocalMockData = false and fill BackendBaseUrl.
|
||||||
|
*/
|
||||||
|
UCLASS(BlueprintType, Blueprintable)
|
||||||
|
class PLANETCLIENT_API APlanetDataManager : public AActor
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
APlanetDataManager();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Inspector-editable properties
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Base URL of the Planet backend, e.g. "http://localhost:8000"
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
|
||||||
|
FString BackendBaseUrl = TEXT("http://localhost:8000");
|
||||||
|
|
||||||
|
// When true, loads mock_compute_points.json from disk instead of the API.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
|
||||||
|
bool bUseLocalMockData = true;
|
||||||
|
|
||||||
|
// Absolute path to the mock JSON file (Phase A).
|
||||||
|
// Default points to <ProjectDir>/Content/Data/mock_compute_points.json
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config",
|
||||||
|
meta=(EditCondition="bUseLocalMockData"))
|
||||||
|
FString MockDataPath;
|
||||||
|
|
||||||
|
// The Blueprint subclass of AComputePointActor to spawn.
|
||||||
|
// Set this in the Details Panel to your BP_ComputePointActor.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
|
||||||
|
TSubclassOf<AComputePointActor> ComputePointClass;
|
||||||
|
|
||||||
|
// Height above sea level (metres) at which to place data points.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
|
||||||
|
double PointAltitudeMeters = 50000.0;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Events
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
|
||||||
|
FOnComputePointsLoaded OnComputePointsLoaded;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Call this to re-fetch and rebuild all points.
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||||
|
void FetchAllData();
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||||
|
TArray<AComputePointActor*> GetAllComputePoints() const { return SpawnedPoints; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void BeginPlay() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TArray<AComputePointActor*> SpawnedPoints;
|
||||||
|
|
||||||
|
// HTTP handlers
|
||||||
|
void FetchComputePoints();
|
||||||
|
void OnComputePointsResponse(FHttpRequestPtr Request,
|
||||||
|
FHttpResponsePtr Response,
|
||||||
|
bool bSuccess);
|
||||||
|
|
||||||
|
// Local mock data loader (Phase A)
|
||||||
|
void LoadMockComputePoints();
|
||||||
|
|
||||||
|
// Shared spawn logic
|
||||||
|
void SpawnComputePoints(const TArray<FComputePoint>& Points);
|
||||||
|
|
||||||
|
// JSON → FComputePoint array
|
||||||
|
static TArray<FComputePoint> ParseComputePointsJson(const FString& JsonStr);
|
||||||
|
};
|
||||||
108
ue_client/Source/PlanetClient/PlanetDataTypes.h
Normal file
108
ue_client/Source/PlanetClient/PlanetDataTypes.h
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "PlanetDataTypes.generated.h"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FComputePoint — corresponds to /api/v1/ue/compute-points items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
USTRUCT(BlueprintType)
|
||||||
|
struct FComputePoint
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
// Unique string id (e.g. "top500_42")
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Id;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Name;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
float Latitude = 0.f;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
float Longitude = 0.f;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Country;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString City;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
int32 Rank = 0;
|
||||||
|
|
||||||
|
// Rmax in TFlops. 0 means not available.
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
float RmaxTFlops = 0.f;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
float RpeakTFlops = 0.f;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
int32 Cores = 0;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
float PowerKw = 0.f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FLandingPoint — corresponds to /api/v1/ue/landing-points items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
USTRUCT(BlueprintType)
|
||||||
|
struct FLandingPoint
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Id;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Name;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
float Latitude = 0.f;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
float Longitude = 0.f;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Country;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString City;
|
||||||
|
|
||||||
|
// Names of cables that pass through this landing point
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
TArray<FString> CableNames;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FCableSegment / FCable — corresponds to /api/v1/ue/cables items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
USTRUCT(BlueprintType)
|
||||||
|
struct FCable
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Id;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString CableId; // slug, e.g. "flag"
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Name;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
FString Status;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
float LengthKm = 0.f;
|
||||||
|
|
||||||
|
// Each inner TArray is one segment: ordered [lon, lat] pairs
|
||||||
|
// Stored as FVector2D(lon, lat) per point
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
|
||||||
|
TArray<TArray<FVector2D>> Segments;
|
||||||
|
};
|
||||||
28
ue_client/Source/PlanetClient/PlanetGameMode.cpp
Normal file
28
ue_client/Source/PlanetClient/PlanetGameMode.cpp
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#include "PlanetGameMode.h"
|
||||||
|
#include "PlanetPlayerController.h"
|
||||||
|
#include "StereoRenderingManager.h"
|
||||||
|
#include "GameFramework/SpectatorPawn.h"
|
||||||
|
#include "Engine/World.h"
|
||||||
|
|
||||||
|
APlanetGameMode::APlanetGameMode()
|
||||||
|
{
|
||||||
|
PlayerControllerClass = APlanetPlayerController::StaticClass();
|
||||||
|
// 真实运行时在 World Settings 里改为 CesiumDynamicPawn
|
||||||
|
DefaultPawnClass = ASpectatorPawn::StaticClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetGameMode::BeginPlay()
|
||||||
|
{
|
||||||
|
Super::BeginPlay();
|
||||||
|
|
||||||
|
// 自动在场景里生成 StereoRenderingManager(如果场景里没有的话)
|
||||||
|
TArray<AActor*> Found;
|
||||||
|
UGameplayStatics::GetAllActorsOfClass(GetWorld(), AStereoRenderingManager::StaticClass(), Found);
|
||||||
|
if (Found.Num() == 0)
|
||||||
|
{
|
||||||
|
GetWorld()->SpawnActor<AStereoRenderingManager>(
|
||||||
|
AStereoRenderingManager::StaticClass(),
|
||||||
|
FVector::ZeroVector, FRotator::ZeroRotator);
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("PlanetGameMode: 已自动生成 StereoRenderingManager"));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
ue_client/Source/PlanetClient/PlanetGameMode.h
Normal file
27
ue_client/Source/PlanetClient/PlanetGameMode.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "GameFramework/GameModeBase.h"
|
||||||
|
#include "Kismet/GameplayStatics.h"
|
||||||
|
#include "PlanetGameMode.generated.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APlanetGameMode
|
||||||
|
*
|
||||||
|
* 场景入口。BeginPlay 时自动生成 StereoRenderingManager。
|
||||||
|
*
|
||||||
|
* 编辑器操作(步骤见 ue_client_setup_guide.md):
|
||||||
|
* 1. World Settings → Game Mode Override → PlanetGameMode
|
||||||
|
* 2. World Settings → Default Pawn Class → CesiumDynamicPawn
|
||||||
|
*/
|
||||||
|
UCLASS()
|
||||||
|
class PLANETCLIENT_API APlanetGameMode : public AGameModeBase
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
APlanetGameMode();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void BeginPlay() override;
|
||||||
|
};
|
||||||
246
ue_client/Source/PlanetClient/PlanetPlayerController.cpp
Normal file
246
ue_client/Source/PlanetClient/PlanetPlayerController.cpp
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
#include "PlanetPlayerController.h"
|
||||||
|
#include "GlobeInteractionComponent.h"
|
||||||
|
#include "InteractiveObjectBase.h"
|
||||||
|
#include "Engine/World.h"
|
||||||
|
#include "GameFramework/Pawn.h"
|
||||||
|
|
||||||
|
APlanetPlayerController::APlanetPlayerController()
|
||||||
|
{
|
||||||
|
bShowMouseCursor = true;
|
||||||
|
bEnableClickEvents = true;
|
||||||
|
bEnableMouseOverEvents = true;
|
||||||
|
PrimaryActorTick.bCanEverTick = true;
|
||||||
|
|
||||||
|
GlobeInteraction = CreateDefaultSubobject<UGlobeInteractionComponent>(
|
||||||
|
TEXT("GlobeInteraction"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::BeginPlay()
|
||||||
|
{
|
||||||
|
Super::BeginPlay();
|
||||||
|
SetInputMode(FInputModeGameAndUI());
|
||||||
|
|
||||||
|
// TODO(供应商回复后取消注释):
|
||||||
|
// BindMotionCaptureEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 输入绑定
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetPlayerController::SetupInputComponent()
|
||||||
|
{
|
||||||
|
Super::SetupInputComponent();
|
||||||
|
|
||||||
|
InputComponent->BindAction("SelectPoint", IE_Pressed, this,
|
||||||
|
&APlanetPlayerController::OnLeftMouseDown);
|
||||||
|
InputComponent->BindAction("SelectPoint", IE_Released, this,
|
||||||
|
&APlanetPlayerController::OnLeftMouseUp);
|
||||||
|
InputComponent->BindAction("Escape", IE_Pressed, this,
|
||||||
|
&APlanetPlayerController::OnEscape);
|
||||||
|
|
||||||
|
InputComponent->BindAxis("RotateYaw", this, &APlanetPlayerController::OnMouseX);
|
||||||
|
InputComponent->BindAxis("RotatePitch", this, &APlanetPlayerController::OnMouseY);
|
||||||
|
InputComponent->BindAxis("ZoomCamera", this, &APlanetPlayerController::OnZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tick:悬停检测
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetPlayerController::Tick(float DeltaTime)
|
||||||
|
{
|
||||||
|
Super::Tick(DeltaTime);
|
||||||
|
if (!bIsDragging) UpdateHover();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 鼠标输入
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnLeftMouseDown()
|
||||||
|
{
|
||||||
|
bLeftMouseDown = true;
|
||||||
|
bIsDragging = false;
|
||||||
|
|
||||||
|
float X, Y;
|
||||||
|
GetMousePosition(X, Y);
|
||||||
|
MouseDownPosition = FVector2D(X, Y);
|
||||||
|
|
||||||
|
// 停止惯性,准备接管控制
|
||||||
|
if (GlobeInteraction) GlobeInteraction->StopInertia();
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnLeftMouseUp()
|
||||||
|
{
|
||||||
|
if (!bIsDragging)
|
||||||
|
{
|
||||||
|
// 没有拖拽 → 判定为点击
|
||||||
|
AInteractiveObjectBase* Clicked = GetObjectUnderCursor();
|
||||||
|
if (Clicked && Clicked != SelectedObject)
|
||||||
|
{
|
||||||
|
// 取消上一个选中
|
||||||
|
DeselectCurrent();
|
||||||
|
// 选中新物件
|
||||||
|
SelectedObject = Clicked;
|
||||||
|
SelectedObject->SetInteractiveState(EInteractiveObjectState::Selected);
|
||||||
|
OnObjectSelected.Broadcast(SelectedObject);
|
||||||
|
}
|
||||||
|
else if (!Clicked)
|
||||||
|
{
|
||||||
|
// 点了空白处 → 取消选中
|
||||||
|
DeselectCurrent();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 点了已选中的物件 → 取消选中
|
||||||
|
DeselectCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bLeftMouseDown = false;
|
||||||
|
bIsDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnMouseX(float Value)
|
||||||
|
{
|
||||||
|
if (!bLeftMouseDown || FMath::IsNearlyZero(Value)) return;
|
||||||
|
|
||||||
|
// 判断是否超过拖拽阈值
|
||||||
|
if (!bIsDragging)
|
||||||
|
{
|
||||||
|
float X, Y;
|
||||||
|
GetMousePosition(X, Y);
|
||||||
|
float Dist = FVector2D::Distance(FVector2D(X, Y), MouseDownPosition);
|
||||||
|
if (Dist < DragThresholdPixels) return;
|
||||||
|
bIsDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GlobeInteraction) GlobeInteraction->RotateGlobe(Value * MouseDragSensitivity, 0.f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnMouseY(float Value)
|
||||||
|
{
|
||||||
|
if (!bLeftMouseDown || FMath::IsNearlyZero(Value)) return;
|
||||||
|
if (!bIsDragging) return;
|
||||||
|
|
||||||
|
if (GlobeInteraction) GlobeInteraction->RotateGlobe(0.f, Value * MouseDragSensitivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnZoom(float Value)
|
||||||
|
{
|
||||||
|
if (FMath::IsNearlyZero(Value)) return;
|
||||||
|
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnEscape()
|
||||||
|
{
|
||||||
|
DeselectCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 悬停
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetPlayerController::UpdateHover()
|
||||||
|
{
|
||||||
|
AInteractiveObjectBase* UnderCursor = GetObjectUnderCursor();
|
||||||
|
|
||||||
|
if (HoveredObject && HoveredObject != UnderCursor && HoveredObject != SelectedObject)
|
||||||
|
{
|
||||||
|
HoveredObject->SetInteractiveState(EInteractiveObjectState::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
HoveredObject = UnderCursor;
|
||||||
|
|
||||||
|
if (HoveredObject && HoveredObject != SelectedObject)
|
||||||
|
{
|
||||||
|
HoveredObject->SetInteractiveState(EInteractiveObjectState::Hovered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 取消选中
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetPlayerController::DeselectCurrent()
|
||||||
|
{
|
||||||
|
if (!SelectedObject) return;
|
||||||
|
SelectedObject->SetInteractiveState(EInteractiveObjectState::Normal);
|
||||||
|
SelectedObject = nullptr;
|
||||||
|
OnObjectDeselected.Broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 射线检测
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AInteractiveObjectBase* APlanetPlayerController::GetObjectUnderCursor() const
|
||||||
|
{
|
||||||
|
FHitResult Hit;
|
||||||
|
bool bHit = GetHitResultUnderCursorByChannel(
|
||||||
|
UEngineTypes::ConvertToTraceType(ECC_Visibility), true, Hit);
|
||||||
|
if (!bHit) return nullptr;
|
||||||
|
return Cast<AInteractiveObjectBase>(Hit.GetActor());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 动捕事件(TODO: 供应商回复后实现)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void APlanetPlayerController::BindMotionCaptureEvents()
|
||||||
|
{
|
||||||
|
if (!MotionCaptureReceiver)
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("PlayerController: MotionCaptureReceiver 未设置,动捕功能禁用"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionCaptureReceiver->OnGestureDetected.AddDynamic(
|
||||||
|
this, &APlanetPlayerController::OnMotionGesture);
|
||||||
|
MotionCaptureReceiver->OnRotateDelta.AddDynamic(
|
||||||
|
this, &APlanetPlayerController::OnMotionRotate);
|
||||||
|
MotionCaptureReceiver->OnZoomDelta.AddDynamic(
|
||||||
|
this, &APlanetPlayerController::OnMotionZoom);
|
||||||
|
|
||||||
|
MotionCaptureReceiver->Connect();
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("PlayerController: 动捕事件已绑定"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnMotionGesture(EMotionGesture Gesture)
|
||||||
|
{
|
||||||
|
if (Gesture == MotionActionMapping.SelectPoint)
|
||||||
|
{
|
||||||
|
// 手势确认 → 等同于点击当前悬停的物件
|
||||||
|
if (HoveredObject)
|
||||||
|
{
|
||||||
|
DeselectCurrent();
|
||||||
|
SelectedObject = HoveredObject;
|
||||||
|
SelectedObject->SetInteractiveState(EInteractiveObjectState::Selected);
|
||||||
|
OnObjectSelected.Broadcast(SelectedObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Gesture == MotionActionMapping.DismissInfoCard)
|
||||||
|
{
|
||||||
|
DeselectCurrent();
|
||||||
|
}
|
||||||
|
else if (Gesture == MotionActionMapping.ZoomIn)
|
||||||
|
{
|
||||||
|
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(1.f);
|
||||||
|
}
|
||||||
|
else if (Gesture == MotionActionMapping.ZoomOut)
|
||||||
|
{
|
||||||
|
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(-1.f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnMotionRotate(float DeltaYaw, float DeltaPitch)
|
||||||
|
{
|
||||||
|
if (GlobeInteraction) GlobeInteraction->RotateGlobe(DeltaYaw, DeltaPitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
void APlanetPlayerController::OnMotionZoom(float DeltaScale)
|
||||||
|
{
|
||||||
|
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(DeltaScale);
|
||||||
|
}
|
||||||
117
ue_client/Source/PlanetClient/PlanetPlayerController.h
Normal file
117
ue_client/Source/PlanetClient/PlanetPlayerController.h
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "GameFramework/PlayerController.h"
|
||||||
|
#include "MotionCaptureInterface.h"
|
||||||
|
#include "PlanetPlayerController.generated.h"
|
||||||
|
|
||||||
|
class AInteractiveObjectBase;
|
||||||
|
class UGlobeInteractionComponent;
|
||||||
|
|
||||||
|
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnObjectSelected, AInteractiveObjectBase*, Object);
|
||||||
|
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnObjectDeselected);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APlanetPlayerController
|
||||||
|
*
|
||||||
|
* 统一处理两路输入:
|
||||||
|
* 1. 鼠标/键盘(始终可用,调试和演示备用)
|
||||||
|
* 2. 动捕手势(TODO: 供应商协议确认后接入)
|
||||||
|
*
|
||||||
|
* 鼠标操作:
|
||||||
|
* 左键拖拽 → 旋转地球
|
||||||
|
* 滚轮 → 缩放
|
||||||
|
* 悬停 → 高亮物件
|
||||||
|
* 左键单击 → 选中物件 / 取消选中
|
||||||
|
* ESC → 取消选中
|
||||||
|
*/
|
||||||
|
UCLASS(BlueprintType, Blueprintable)
|
||||||
|
class PLANETCLIENT_API APlanetPlayerController : public APlayerController
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
APlanetPlayerController();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 组件
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Planet|Components")
|
||||||
|
UGlobeInteractionComponent* GlobeInteraction;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 动捕配置(TODO: 供应商回复后配置)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||||
|
FMotionActionMapping MotionActionMapping;
|
||||||
|
|
||||||
|
// 动捕接收器实例(TODO: 确认协议后指向具体子类实例)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
|
||||||
|
UMotionCaptureReceiver* MotionCaptureReceiver = nullptr;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 鼠标灵敏度
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Input")
|
||||||
|
float MouseDragSensitivity = 0.3f;
|
||||||
|
|
||||||
|
// 判定为"拖拽"而非"点击"的最小移动距离(像素)
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Input")
|
||||||
|
float DragThresholdPixels = 5.f;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 事件
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
|
||||||
|
FOnObjectSelected OnObjectSelected;
|
||||||
|
|
||||||
|
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
|
||||||
|
FOnObjectDeselected OnObjectDeselected;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 公开 API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet")
|
||||||
|
void DeselectCurrent();
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet")
|
||||||
|
AInteractiveObjectBase* GetSelectedObject() const { return SelectedObject; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void BeginPlay() override;
|
||||||
|
virtual void Tick(float DeltaTime) override;
|
||||||
|
virtual void SetupInputComponent() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// 鼠标拖拽状态
|
||||||
|
bool bLeftMouseDown = false;
|
||||||
|
bool bIsDragging = false;
|
||||||
|
FVector2D MouseDownPosition = FVector2D::ZeroVector;
|
||||||
|
|
||||||
|
// 交互状态
|
||||||
|
AInteractiveObjectBase* SelectedObject = nullptr;
|
||||||
|
AInteractiveObjectBase* HoveredObject = nullptr;
|
||||||
|
|
||||||
|
// 输入回调
|
||||||
|
void OnLeftMouseDown();
|
||||||
|
void OnLeftMouseUp();
|
||||||
|
void OnMouseX(float Value);
|
||||||
|
void OnMouseY(float Value);
|
||||||
|
void OnZoom(float Value);
|
||||||
|
void OnEscape();
|
||||||
|
|
||||||
|
// 悬停检测
|
||||||
|
void UpdateHover();
|
||||||
|
|
||||||
|
// 射线检测
|
||||||
|
AInteractiveObjectBase* GetObjectUnderCursor() const;
|
||||||
|
|
||||||
|
// 动捕事件绑定(TODO: 供应商回复后在 BeginPlay 里绑定接收器)
|
||||||
|
UFUNCTION()
|
||||||
|
void OnMotionGesture(EMotionGesture Gesture);
|
||||||
|
UFUNCTION()
|
||||||
|
void OnMotionRotate(float DeltaYaw, float DeltaPitch);
|
||||||
|
UFUNCTION()
|
||||||
|
void OnMotionZoom(float DeltaScale);
|
||||||
|
void BindMotionCaptureEvents();
|
||||||
|
};
|
||||||
131
ue_client/Source/PlanetClient/StereoRenderingManager.cpp
Normal file
131
ue_client/Source/PlanetClient/StereoRenderingManager.cpp
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#include "StereoRenderingManager.h"
|
||||||
|
#include "Engine/Engine.h"
|
||||||
|
#include "Engine/World.h"
|
||||||
|
|
||||||
|
AStereoRenderingManager::AStereoRenderingManager()
|
||||||
|
{
|
||||||
|
PrimaryActorTick.bCanEverTick = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AStereoRenderingManager::BeginPlay()
|
||||||
|
{
|
||||||
|
Super::BeginPlay();
|
||||||
|
|
||||||
|
if (bAutoEnableOnPlay)
|
||||||
|
{
|
||||||
|
EnableStereo(DefaultStereoMode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Log,
|
||||||
|
TEXT("StereoRenderingManager: 立体渲染未自动启动。"
|
||||||
|
"确认视频处理器格式后,在 Details 面板勾选 bAutoEnableOnPlay。"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 公开 API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void AStereoRenderingManager::EnableStereo(EStereoOutputMode Mode)
|
||||||
|
{
|
||||||
|
if (Mode == EStereoOutputMode::Off)
|
||||||
|
{
|
||||||
|
DisableStereo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(供应商回复后检查这里的命令是否与视频处理器匹配):
|
||||||
|
//
|
||||||
|
// UE5 内置立体模式通过以下方式启用。
|
||||||
|
// 如果视频处理器需要特殊格式,可能需要修改 stereo mode 参数。
|
||||||
|
//
|
||||||
|
// 参考命令:
|
||||||
|
// stereo on —— 开启立体渲染(需要 VR 设备或自定义立体设备)
|
||||||
|
// r.StereoRendering 1 —— 软件层立体渲染标志
|
||||||
|
// vr.StereoMode <n> —— 0=SbS full, 1=SbS half, 2=TbB full, 3=TbB half
|
||||||
|
//
|
||||||
|
// 目前使用 r.StereoRendering 的"双视口"方式,兼容性最好。
|
||||||
|
|
||||||
|
switch (Mode)
|
||||||
|
{
|
||||||
|
case EStereoOutputMode::SideBySide:
|
||||||
|
// Side-by-Side:左半=左眼,右半=右眼,总分辨率 2x 宽
|
||||||
|
ExecConsoleCommand(TEXT("r.StereoRendering 1"));
|
||||||
|
ExecConsoleCommand(TEXT("stereo on"));
|
||||||
|
ExecConsoleCommand(TEXT("vr.StereoMode 0")); // TODO: 根据实际效果调整
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EStereoOutputMode::TopBottom:
|
||||||
|
// Top-Bottom:上半=左眼,下半=右眼,总分辨率 2x 高
|
||||||
|
ExecConsoleCommand(TEXT("r.StereoRendering 1"));
|
||||||
|
ExecConsoleCommand(TEXT("stereo on"));
|
||||||
|
ExecConsoleCommand(TEXT("vr.StereoMode 2")); // TODO: 根据实际效果调整
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EStereoOutputMode::FrameSequential:
|
||||||
|
// 本项目屏幕 60Hz,帧序列需要 120Hz,暂不支持
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("StereoRenderingManager: Frame Sequential 需要 120Hz 输出,"
|
||||||
|
"当前屏幕规格不支持,已忽略。"));
|
||||||
|
return;
|
||||||
|
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 IPD
|
||||||
|
SetIPD(IPDCm);
|
||||||
|
|
||||||
|
bStereoActive = true;
|
||||||
|
CurrentMode = Mode;
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("StereoRenderingManager: 立体渲染已开启,模式=%s,IPD=%.1fcm"),
|
||||||
|
*ModeToString(Mode), IPDCm);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AStereoRenderingManager::DisableStereo()
|
||||||
|
{
|
||||||
|
ExecConsoleCommand(TEXT("stereo off"));
|
||||||
|
ExecConsoleCommand(TEXT("r.StereoRendering 0"));
|
||||||
|
bStereoActive = false;
|
||||||
|
CurrentMode = EStereoOutputMode::Off;
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("StereoRenderingManager: 立体渲染已关闭,切回 2D"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AStereoRenderingManager::ToggleStereo()
|
||||||
|
{
|
||||||
|
if (bStereoActive)
|
||||||
|
DisableStereo();
|
||||||
|
else
|
||||||
|
EnableStereo(DefaultStereoMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AStereoRenderingManager::SetIPD(float NewIPDCm)
|
||||||
|
{
|
||||||
|
IPDCm = FMath::Clamp(NewIPDCm, 1.f, 10.f);
|
||||||
|
// UE5 以米为单位设置 IPD
|
||||||
|
FString Cmd = FString::Printf(TEXT("vr.HMDIPDInMeters %.4f"), IPDCm / 100.f);
|
||||||
|
ExecConsoleCommand(Cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 内部工具
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void AStereoRenderingManager::ExecConsoleCommand(const FString& Cmd)
|
||||||
|
{
|
||||||
|
if (GEngine)
|
||||||
|
{
|
||||||
|
GEngine->Exec(GetWorld(), *Cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FString AStereoRenderingManager::ModeToString(EStereoOutputMode Mode)
|
||||||
|
{
|
||||||
|
switch (Mode)
|
||||||
|
{
|
||||||
|
case EStereoOutputMode::SideBySide: return TEXT("SideBySide");
|
||||||
|
case EStereoOutputMode::TopBottom: return TEXT("TopBottom");
|
||||||
|
case EStereoOutputMode::FrameSequential: return TEXT("FrameSequential");
|
||||||
|
default: return TEXT("Off");
|
||||||
|
}
|
||||||
|
}
|
||||||
97
ue_client/Source/PlanetClient/StereoRenderingManager.h
Normal file
97
ue_client/Source/PlanetClient/StereoRenderingManager.h
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "GameFramework/Actor.h"
|
||||||
|
#include "StereoRenderingManager.generated.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立体渲染输出格式
|
||||||
|
*
|
||||||
|
* TODO(等待视频处理器供应商回复):
|
||||||
|
* 确认视频处理器接受哪种格式后,在 DefaultStereoMode 里选择对应值。
|
||||||
|
* - 如果是诺瓦星云/Brompton 处理器,通常接受 SideBySide
|
||||||
|
* - Frame Sequential 需要 120Hz+ 输出,本项目屏幕不需要,基本不选
|
||||||
|
*/
|
||||||
|
UENUM(BlueprintType)
|
||||||
|
enum class EStereoOutputMode : uint8
|
||||||
|
{
|
||||||
|
Off UMETA(DisplayName="关闭(普通 2D 输出)"),
|
||||||
|
SideBySide UMETA(DisplayName="左右并排 Side-by-Side"), // 最常见,优先尝试
|
||||||
|
TopBottom UMETA(DisplayName="上下叠加 Top-Bottom"), // 备选
|
||||||
|
FrameSequential UMETA(DisplayName="帧序列(需 120Hz)"), // 本项目暂不用
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AStereoRenderingManager
|
||||||
|
*
|
||||||
|
* 放一个在场景里(或在 GameMode 里 SpawnActor),控制立体渲染的开关和参数。
|
||||||
|
* 运行时可以通过 Blueprint 或控制台随时切换模式,方便现场调试。
|
||||||
|
*
|
||||||
|
* TODO(等待供应商回复后填写):
|
||||||
|
* 1. 确认视频处理器输入格式 → 修改 DefaultStereoMode
|
||||||
|
* 2. 确认屏幕分辨率 → 修改渲染分辨率(目前按 5120x1440 SbS 估算)
|
||||||
|
* 3. 调整 IPDCm 到适合大屏幕观看距离的值(推荐 3-6cm,距离越远 IPD 越小)
|
||||||
|
*/
|
||||||
|
UCLASS(BlueprintType, Blueprintable)
|
||||||
|
class PLANETCLIENT_API AStereoRenderingManager : public AActor
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
AStereoRenderingManager();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 可在 Details 面板调整
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TODO: 等供应商回复后改为正确格式
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo")
|
||||||
|
EStereoOutputMode DefaultStereoMode = EStereoOutputMode::SideBySide;
|
||||||
|
|
||||||
|
// 瞳距(cm)。大屏幕 + 远距离观看建议 3.0-5.0cm
|
||||||
|
// TODO: 根据实际屏幕尺寸和观看距离校准
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo",
|
||||||
|
meta=(ClampMin="1.0", ClampMax="10.0"))
|
||||||
|
float IPDCm = 6.5f;
|
||||||
|
|
||||||
|
// 是否在 BeginPlay 时自动启用立体渲染
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo")
|
||||||
|
bool bAutoEnableOnPlay = false; // 默认 false,等确认格式后改为 true
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Blueprint 可调用的运行时 API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 开启立体渲染
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
|
||||||
|
void EnableStereo(EStereoOutputMode Mode);
|
||||||
|
|
||||||
|
// 关闭立体渲染,切回普通 2D
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
|
||||||
|
void DisableStereo();
|
||||||
|
|
||||||
|
// 切换(当前开 → 关,当前关 → 用 DefaultStereoMode 开)
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
|
||||||
|
void ToggleStereo();
|
||||||
|
|
||||||
|
// 调整 IPD(运行时微调景深效果)
|
||||||
|
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
|
||||||
|
void SetIPD(float NewIPDCm);
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet|Stereo")
|
||||||
|
bool IsStereoActive() const { return bStereoActive; }
|
||||||
|
|
||||||
|
UFUNCTION(BlueprintPure, Category="Planet|Stereo")
|
||||||
|
EStereoOutputMode GetCurrentMode() const { return CurrentMode; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void BeginPlay() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool bStereoActive = false;
|
||||||
|
EStereoOutputMode CurrentMode = EStereoOutputMode::Off;
|
||||||
|
|
||||||
|
// 执行实际的控制台命令
|
||||||
|
void ExecConsoleCommand(const FString& Cmd);
|
||||||
|
static FString ModeToString(EStereoOutputMode Mode);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user