release: bump version to 0.27.5
This commit is contained in:
@@ -15,6 +15,23 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
981
docs/ue5_mvp_fused_plan.md
Normal file
981
docs/ue5_mvp_fused_plan.md
Normal file
@@ -0,0 +1,981 @@
|
||||
# 智能星球 UE5 客户端一期实施方案(融合版)
|
||||
|
||||
> 版本:v2.0
|
||||
> 日期:2026-04-14
|
||||
> 目标:把现有 Web Earth 项目,平滑推进到 **UE5 可用 MVP 客户端**
|
||||
> 适用对象:**UE 零基础新手**
|
||||
> 输出结果:一份 **能直接照着做** 的实施手册
|
||||
> 策略:**保留原 MVP 方案里适合入门的部分,吸收更稳的工程做法,降低你第一次做 UE 时踩坑概率**
|
||||
|
||||
---
|
||||
|
||||
## 一、这份融合版方案解决什么问题
|
||||
|
||||
你原来的 MVP 方案是靠谱的,优点很明显:
|
||||
|
||||
- 范围克制
|
||||
- 适合新手入门
|
||||
- 目标明确
|
||||
- 能较快做出“看得见、点得到”的成果
|
||||
|
||||
但它也有几个风险:
|
||||
|
||||
- 默认 `localhost` 一定通,这在 WSL2 + Windows + Docker 环境里不一定成立
|
||||
- 默认 UE 蓝图里直接做 HTTP + JSON 解析会很顺,这一步其实很容易卡
|
||||
- 默认“一上来就接真实后端”,新手会同时踩 UE、Cesium、网络、JSON、蓝图五个坑
|
||||
- 时间估计略乐观
|
||||
|
||||
所以这份融合版方案的核心思路是:
|
||||
|
||||
## 核心原则
|
||||
|
||||
**先做“本地数据可交互地球”,再做“真实后端对接”。**
|
||||
|
||||
也就是把一期再拆成两个更稳的里程碑:
|
||||
|
||||
### 里程碑 A:本地演示版
|
||||
先不接后端,只做:
|
||||
|
||||
- UE5 项目能打开
|
||||
- Cesium 地球能显示
|
||||
- 本地 JSON 里的点能正确落到地球
|
||||
- 点击点能弹信息卡
|
||||
- HUD 能正常显示假状态
|
||||
|
||||
### 里程碑 B:后端接入版
|
||||
在 A 的基础上再做:
|
||||
|
||||
- HTTP 拉取真实后端数据
|
||||
- 显示真实 TOP500 数据
|
||||
- 显示后端在线状态
|
||||
- 为后续扩展海缆/BGP/卫星打基础
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
- 把问题拆开
|
||||
- 更容易调试
|
||||
- 更适合 UE 新手
|
||||
- 不会因为后端联调没通就把整个 UE 开发节奏打断
|
||||
|
||||
---
|
||||
|
||||
# 二、一期目标:做什么,不做什么
|
||||
|
||||
## 这次一期一定要做的
|
||||
|
||||
做一个 **可用的 UE5 客户端 MVP**,达到以下 6 项:
|
||||
|
||||
1. 能打开 UE 项目并看到 3D 地球
|
||||
2. 能在地球上显示超算数据点
|
||||
3. 能点击数据点弹出信息卡
|
||||
4. 能显示一个基础 HUD
|
||||
5. 能通过 HTTP 接入后端数据
|
||||
6. 能打包成 Windows 可执行程序
|
||||
|
||||
---
|
||||
|
||||
## 这次一期先不做的
|
||||
|
||||
这些全部放到后续阶段:
|
||||
|
||||
- 海缆路径渲染
|
||||
- 卫星轨迹与卫星图层
|
||||
- BGP 图层
|
||||
- WebSocket 实时更新
|
||||
- 粒子特效大升级
|
||||
- 自动巡航
|
||||
- 多屏/3D 偏振/大屏联动
|
||||
|
||||
一句话:
|
||||
|
||||
**一期不是“把 Web Earth 全搬到 UE”,而是“证明 UE 客户端链路能跑通”。**
|
||||
|
||||
---
|
||||
|
||||
# 三、UE 专有名词字典(零基础版)
|
||||
|
||||
这部分你最好先读一遍。后面所有步骤都围绕这些词。
|
||||
|
||||
## 1. Actor
|
||||
**Actor = 场景里的一个对象**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- 一个地球控制器
|
||||
- 一个超算点
|
||||
- 一台相机
|
||||
- 一条海缆
|
||||
|
||||
这些在 UE 里都可以是 Actor。
|
||||
|
||||
---
|
||||
|
||||
## 2. Component
|
||||
**Component = 挂在 Actor 身上的功能零件**
|
||||
|
||||
比如一个超算点 Actor,可能有:
|
||||
|
||||
- 一个球形外观
|
||||
- 一个碰撞盒
|
||||
- 一个标签
|
||||
- 一个发光效果
|
||||
|
||||
这些零件就是 Component。
|
||||
|
||||
一句话:
|
||||
|
||||
**Actor 是整台机器,Component 是机器上的零件。**
|
||||
|
||||
---
|
||||
|
||||
## 3. Blueprint(蓝图)
|
||||
**Blueprint = UE 的可视化编程系统**
|
||||
|
||||
你不用先写代码,而是把很多“逻辑节点”拖出来,用线连接起来。
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- 前端里的函数 + 事件监听
|
||||
- 只不过不是写文本代码,而是连线
|
||||
|
||||
---
|
||||
|
||||
## 4. Level / Map(关卡)
|
||||
**Level = 一个场景文件**
|
||||
|
||||
你可以把它理解成 Three.js 的一个 Scene。
|
||||
|
||||
本期只需要一个主场景:
|
||||
|
||||
- `Main`
|
||||
|
||||
---
|
||||
|
||||
## 5. Widget / UMG
|
||||
**Widget = UI 组件**
|
||||
**UMG = UE 的 UI 编辑系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 信息卡
|
||||
- 状态栏
|
||||
- 右上角连接状态
|
||||
- 图例
|
||||
- HUD 面板
|
||||
|
||||
这些都用 Widget 做。
|
||||
|
||||
---
|
||||
|
||||
## 6. Material(材质)
|
||||
**Material = 决定物体外观的系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 球体是什么颜色
|
||||
- 是否发光
|
||||
- 是否透明
|
||||
- 是否随性能大小变亮
|
||||
|
||||
这些都由材质控制。
|
||||
|
||||
---
|
||||
|
||||
## 7. Static Mesh
|
||||
**Static Mesh = 不会变形的 3D 模型**
|
||||
|
||||
比如:
|
||||
|
||||
- 球
|
||||
- 立方体
|
||||
- 平面
|
||||
- 某个固定模型
|
||||
|
||||
超算点一期里可以先直接用球体 Static Mesh。
|
||||
|
||||
---
|
||||
|
||||
## 8. Pawn
|
||||
**Pawn = 玩家控制的对象**
|
||||
|
||||
一期里你可以把它理解成:
|
||||
|
||||
- 带相机的飞行控制器
|
||||
|
||||
---
|
||||
|
||||
## 9. PlayerController
|
||||
**PlayerController = 处理输入的对象**
|
||||
|
||||
比如:
|
||||
|
||||
- 鼠标点击
|
||||
- 拖拽
|
||||
- 滚轮缩放
|
||||
|
||||
这些都由 PlayerController 或其相关逻辑来处理。
|
||||
|
||||
---
|
||||
|
||||
## 10. GameMode
|
||||
**GameMode = 游戏/场景的主规则配置入口**
|
||||
|
||||
它决定:
|
||||
|
||||
- 默认用哪个 Pawn
|
||||
- 默认用哪个 PlayerController
|
||||
|
||||
你可以把它理解成“主入口配置”。
|
||||
|
||||
---
|
||||
|
||||
## 11. Viewport
|
||||
**Viewport = 你看 3D 场景的窗口**
|
||||
|
||||
就是 UE 编辑器中间那块 3D 视图。
|
||||
|
||||
---
|
||||
|
||||
## 12. Outliner
|
||||
**Outliner = 当前场景对象列表**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- Scene 树
|
||||
- DOM 树
|
||||
- 资源树
|
||||
|
||||
---
|
||||
|
||||
## 13. Details Panel
|
||||
**Details Panel = 选中对象后的属性面板**
|
||||
|
||||
相当于“右侧属性编辑器”。
|
||||
|
||||
---
|
||||
|
||||
## 14. Cesium for Unreal
|
||||
**Cesium for Unreal = UE 里的地球插件**
|
||||
|
||||
它负责:
|
||||
|
||||
- 真实地球
|
||||
- 卫星影像
|
||||
- 地形
|
||||
- 经纬度坐标和 UE 世界坐标的转换
|
||||
|
||||
如果没有它,你得自己处理地球和坐标系统,会非常难。
|
||||
|
||||
---
|
||||
|
||||
## 15. Struct(结构体)
|
||||
**Struct = 数据结构定义**
|
||||
|
||||
你可以把它理解成 TypeScript 里的 `interface`。
|
||||
|
||||
比如:
|
||||
|
||||
```ts
|
||||
interface ComputePoint {
|
||||
id: string
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
performance: number
|
||||
}
|
||||
```
|
||||
|
||||
在 UE 里这类东西叫 Struct。
|
||||
|
||||
---
|
||||
|
||||
## 16. Event Dispatcher
|
||||
**Event Dispatcher = 事件分发器**
|
||||
|
||||
你可以把它理解成:
|
||||
|
||||
- EventEmitter
|
||||
- 发布订阅
|
||||
|
||||
比如:
|
||||
|
||||
“数据加载完毕”这个事件,就可以分发给其他蓝图。
|
||||
|
||||
---
|
||||
|
||||
## 17. Spline
|
||||
**Spline = 一条平滑曲线**
|
||||
|
||||
后面做海缆、轨迹时非常有用。
|
||||
一期可以先知道这个词,不一定马上用。
|
||||
|
||||
---
|
||||
|
||||
## 18. Niagara
|
||||
**Niagara = UE 粒子特效系统**
|
||||
|
||||
比如:
|
||||
|
||||
- 流光
|
||||
- 光晕
|
||||
- 拖尾
|
||||
- 火花
|
||||
|
||||
一期先不重点碰它。
|
||||
|
||||
---
|
||||
|
||||
# 四、你的真实开发策略:两阶段起步
|
||||
|
||||
这是这份融合版和原方案最大的区别。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 A:本地演示版(先脱离后端)
|
||||
|
||||
### 目标
|
||||
先把下面这些完全打通:
|
||||
|
||||
- UE 项目启动正常
|
||||
- Cesium 地球正常
|
||||
- 相机可操作
|
||||
- 本地 JSON 文件能生成地球标记点
|
||||
- 点击点能弹信息卡
|
||||
- HUD 能显示假数据
|
||||
|
||||
### 为什么一定要先做这个
|
||||
因为如果你一上来就接真实后端,你会同时碰到:
|
||||
|
||||
- WSL2 到 Windows 网络
|
||||
- Docker 端口映射
|
||||
- UE HTTP 请求
|
||||
- 蓝图 JSON 解析
|
||||
- Cesium 坐标转换
|
||||
- 标记点生成
|
||||
|
||||
新手很容易直接乱掉。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 B:后端接入版(再联调)
|
||||
|
||||
### 目标
|
||||
在 A 的基础上,加上:
|
||||
|
||||
- HTTP 拉真实后端数据
|
||||
- 显示真实 TOP500 点
|
||||
- 右上角显示后端在线状态
|
||||
- 为后续做更多图层留下数据接入层
|
||||
|
||||
---
|
||||
|
||||
# 五、环境准备
|
||||
|
||||
## 1. 你要安装的软件
|
||||
|
||||
### Epic Games Launcher
|
||||
用来下载和启动 UE。
|
||||
|
||||
### Unreal Engine 5.4
|
||||
建议直接用 5.4 稳定版。
|
||||
|
||||
### Visual Studio 2022
|
||||
虽然一期主要用 Blueprint,但 UE 的很多项目依赖 VS 环境。
|
||||
|
||||
安装组件:
|
||||
- Desktop development with C++
|
||||
- Game development with C++
|
||||
|
||||
### Git
|
||||
用来管理文档和后续工程。
|
||||
|
||||
### Cesium for Unreal
|
||||
用来做地球。
|
||||
|
||||
---
|
||||
|
||||
## 2. 你的环境约束
|
||||
|
||||
你现在是:
|
||||
|
||||
- 后端可能跑在 WSL2 / Docker
|
||||
- UE 必须跑在 Windows
|
||||
|
||||
所以你的真实运行方式通常会是:
|
||||
|
||||
- **Windows** 运行 UE5
|
||||
- **WSL2** 运行后端
|
||||
- 两者通过 HTTP 通信
|
||||
|
||||
这里最关键的一条是:
|
||||
|
||||
**不要默认 `localhost` 一定能通,必须先在 Windows 浏览器里验证。**
|
||||
|
||||
---
|
||||
|
||||
# 六、推荐的项目结构
|
||||
|
||||
## UE 项目目录内的 Content 结构
|
||||
|
||||
```text
|
||||
Content/
|
||||
Blueprints/
|
||||
Data/
|
||||
Widgets/
|
||||
Materials/
|
||||
Levels/
|
||||
FX/
|
||||
Textures/
|
||||
```
|
||||
|
||||
建议说明:
|
||||
|
||||
- `Blueprints/` 放逻辑蓝图
|
||||
- `Data/` 放本地 JSON、DataTable、Struct
|
||||
- `Widgets/` 放 UI
|
||||
- `Materials/` 放材质
|
||||
- `Levels/` 放场景
|
||||
- `FX/` 放特效
|
||||
- `Textures/` 放贴图
|
||||
|
||||
---
|
||||
|
||||
# 七、一期最小蓝图清单
|
||||
|
||||
一期只需要这几个核心蓝图。
|
||||
|
||||
## 1. `BP_GlobeCamera`
|
||||
作用:相机控制器
|
||||
|
||||
负责:
|
||||
- 鼠标拖拽旋转
|
||||
- 滚轮缩放
|
||||
- 初始视角控制
|
||||
|
||||
---
|
||||
|
||||
## 2. `BP_PlanetGameMode`
|
||||
作用:指定默认的 Pawn 等
|
||||
|
||||
---
|
||||
|
||||
## 3. `BP_DataLoader`
|
||||
作用:负责读数据
|
||||
|
||||
一期建议支持两种来源:
|
||||
|
||||
- 本地 JSON
|
||||
- HTTP 接口
|
||||
|
||||
这样调试更稳。
|
||||
|
||||
---
|
||||
|
||||
## 4. `BP_ComputePoint`
|
||||
作用:一个超算点的显示对象
|
||||
|
||||
负责:
|
||||
- 接收一条数据
|
||||
- 放到正确经纬度位置
|
||||
- 显示外观
|
||||
- 处理点击
|
||||
|
||||
---
|
||||
|
||||
## 5. `WBP_InfoCard`
|
||||
作用:点开后显示详情
|
||||
|
||||
显示:
|
||||
- 名称
|
||||
- 国家
|
||||
- 算力
|
||||
- 可选显示更多字段
|
||||
|
||||
---
|
||||
|
||||
## 6. `WBP_StatusBar`
|
||||
作用:右上角状态栏
|
||||
|
||||
显示:
|
||||
- 后端在线/离线
|
||||
- 当前加载条数
|
||||
- 当前模式(本地数据 / 真实后端)
|
||||
|
||||
---
|
||||
|
||||
# 八、数据层设计
|
||||
|
||||
一期不要一开始就完全照搬后端返回结构。
|
||||
你要先定义一个 UE 友好的结构。
|
||||
|
||||
## `S_ComputePoint`
|
||||
|
||||
字段建议:
|
||||
|
||||
- `PointId`:字符串,唯一 ID
|
||||
- `Name`:字符串
|
||||
- `Latitude`:浮点
|
||||
- `Longitude`:浮点
|
||||
- `Performance`:浮点
|
||||
- `CoreCount`:整数
|
||||
- `Country`:字符串
|
||||
- `Source`:字符串
|
||||
|
||||
这个结构同时适用于:
|
||||
|
||||
- 本地 JSON
|
||||
- 后端 API 返回结果转换后的对象
|
||||
|
||||
---
|
||||
|
||||
# 九、最稳的执行路线
|
||||
|
||||
下面是整个实施计划最重要的部分。
|
||||
|
||||
---
|
||||
|
||||
# Phase 0:安装和验证环境
|
||||
|
||||
## 目标
|
||||
确保你能:
|
||||
|
||||
- 安装 UE5.4
|
||||
- 启用 Cesium
|
||||
- 能打开一个空项目
|
||||
- 能在 Windows 浏览器访问你的后端
|
||||
|
||||
## 验收
|
||||
满足以下 4 条:
|
||||
|
||||
- UE 能打开
|
||||
- Cesium 能启用
|
||||
- 项目能创建
|
||||
- Windows 浏览器能访问后端 summary 接口
|
||||
|
||||
如果第 4 条做不到,不要继续推进真实接口联调。
|
||||
|
||||
---
|
||||
|
||||
# Phase 1:创建项目并把地球显示出来
|
||||
|
||||
## 目标
|
||||
打开项目后,能看到一个真实地球。
|
||||
|
||||
## 操作顺序
|
||||
|
||||
1. 新建 UE5 Blank Blueprint 项目
|
||||
2. 创建 `Main` 场景
|
||||
3. 启用 Cesium
|
||||
4. 添加:
|
||||
- `Cesium World Terrain`
|
||||
- `Cesium Sun Sky`
|
||||
- `CesiumGeoreference`
|
||||
5. 调整视角,让你能看到整个地球
|
||||
|
||||
## 验收
|
||||
能录一段短视频,里面能看到地球和镜头移动。
|
||||
|
||||
---
|
||||
|
||||
# Phase 2:做相机控制
|
||||
|
||||
## 目标
|
||||
让地球可以:
|
||||
|
||||
- 鼠标拖拽旋转
|
||||
- 滚轮缩放
|
||||
|
||||
## 说明
|
||||
这里可以沿用原 MVP 方案的思路:
|
||||
|
||||
- `BP_GlobeCamera` 作为 Pawn
|
||||
- Spring Arm + Camera 组成相机结构
|
||||
- 用输入控制旋转和缩放
|
||||
|
||||
## 注意
|
||||
这一版相机只是“一期可用版”,不是最终镜头系统。
|
||||
|
||||
## 验收
|
||||
按 Play 后:
|
||||
|
||||
- 地球可旋转
|
||||
- 可缩放
|
||||
- 不会直接飞走或抖动失控
|
||||
|
||||
---
|
||||
|
||||
# Phase 3:先喂本地 JSON 数据
|
||||
|
||||
这是融合版方案里最关键的改动。
|
||||
|
||||
## 目标
|
||||
不接后端,先验证:
|
||||
|
||||
- 数据结构正常
|
||||
- JSON 能读
|
||||
- 点能生成
|
||||
- 点击交互正常
|
||||
|
||||
## 为什么先这么做
|
||||
因为这样可以把问题收缩成 3 件事:
|
||||
|
||||
- Cesium 坐标转换
|
||||
- 点渲染
|
||||
- UI 弹窗
|
||||
|
||||
不牵涉后端联调。
|
||||
|
||||
## 本地 JSON 示例格式
|
||||
|
||||
建议放在 `Content/Data/compute_points.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PointId": "top500_1",
|
||||
"Name": "Frontier",
|
||||
"Latitude": 35.93,
|
||||
"Longitude": -84.31,
|
||||
"Performance": 1194.0,
|
||||
"CoreCount": 8730624,
|
||||
"Country": "US",
|
||||
"Source": "top500"
|
||||
},
|
||||
{
|
||||
"PointId": "top500_2",
|
||||
"Name": "Fugaku",
|
||||
"Latitude": 34.69,
|
||||
"Longitude": 135.19,
|
||||
"Performance": 442.0,
|
||||
"CoreCount": 7630848,
|
||||
"Country": "JP",
|
||||
"Source": "top500"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 推荐做法
|
||||
先做一个“本地模式”开关。
|
||||
|
||||
在 `BP_DataLoader` 里支持:
|
||||
|
||||
- Mode = LocalJson
|
||||
- Mode = HttpApi
|
||||
|
||||
先永远跑 `LocalJson`。
|
||||
|
||||
## 验收
|
||||
你应该能看到:
|
||||
|
||||
- 多个点出现在地球上
|
||||
- 大致位置正确
|
||||
- 点击能弹信息卡
|
||||
|
||||
---
|
||||
|
||||
# Phase 4:做超算点蓝图
|
||||
|
||||
## 目标
|
||||
完成 `BP_ComputePoint`
|
||||
|
||||
每个点要实现:
|
||||
|
||||
- 接收一条 `S_ComputePoint`
|
||||
- 经度纬度转成 UE 世界坐标
|
||||
- 在地球上显示为一个可见的发光球
|
||||
- 支持被点击
|
||||
|
||||
## 显示建议
|
||||
|
||||
### 外观
|
||||
先用最简单的球体 Static Mesh。
|
||||
|
||||
### 材质
|
||||
做一个发光材质:
|
||||
|
||||
- 红橙色
|
||||
- 自发光
|
||||
- 不追求复杂效果
|
||||
|
||||
### 大小
|
||||
球体要足够大,确保在地球尺度下看得见。
|
||||
|
||||
### 高度
|
||||
不要贴地表太近,建议悬浮在地表上方一个固定高度。
|
||||
|
||||
## 验收
|
||||
同一批数据点在地球上的位置大体合理。
|
||||
|
||||
---
|
||||
|
||||
# Phase 5:做信息卡
|
||||
|
||||
## 目标
|
||||
点击一个点后,弹出一个简单的信息卡。
|
||||
|
||||
## `WBP_InfoCard` 要显示的内容
|
||||
建议只显示最关键的 3 个字段:
|
||||
|
||||
- 名称
|
||||
- 国家
|
||||
- 算力
|
||||
|
||||
一期先不要堆太多字段。
|
||||
|
||||
## 验收
|
||||
点击点 → 卡片出现
|
||||
点击关闭 → 卡片消失
|
||||
|
||||
---
|
||||
|
||||
# Phase 6:做基础 HUD
|
||||
|
||||
## 目标
|
||||
屏幕上始终有一个简单状态栏。
|
||||
|
||||
## `WBP_StatusBar` 显示内容建议
|
||||
- 当前模式:Local / HTTP
|
||||
- 已加载数据点数量
|
||||
- 后端状态:Unknown / Online / Offline
|
||||
|
||||
在本地模式阶段,状态可以先写死或显示 `Local Demo`。
|
||||
|
||||
## 验收
|
||||
不点击任何点时,屏幕右上角也有“系统正在工作”的感觉。
|
||||
|
||||
---
|
||||
|
||||
# Phase 7:再接真实后端
|
||||
|
||||
这是第二阶段开始。
|
||||
|
||||
## 目标
|
||||
把数据源从本地 JSON 切到 HTTP。
|
||||
|
||||
## 正确做法
|
||||
不要把 `BP_DataLoader` 重写。
|
||||
而是让它支持:
|
||||
|
||||
- LocalJsonLoader
|
||||
- HttpLoader
|
||||
|
||||
也就是:
|
||||
|
||||
**显示层不变,只替换数据来源。**
|
||||
|
||||
## 最重要的接口原则
|
||||
如果后端已有接口字段非常杂,不一定要 UE 直接吃。
|
||||
可以加一个“更适合 UE 的轻量接口”。
|
||||
|
||||
例如:
|
||||
|
||||
`/api/v1/ue/bootstrap/top500`
|
||||
|
||||
返回尽量扁平的数据:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PointId": "top500_1",
|
||||
"Name": "Frontier",
|
||||
"Latitude": 35.93,
|
||||
"Longitude": -84.31,
|
||||
"Performance": 1194.0,
|
||||
"CoreCount": 8730624,
|
||||
"Country": "US",
|
||||
"Source": "top500"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 为什么推荐 UE 轻量接口
|
||||
因为 UE 不适合像前端 React 那样,层层解包一大堆复杂 JSON。
|
||||
|
||||
---
|
||||
|
||||
# Phase 8:做连接状态检测
|
||||
|
||||
## 目标
|
||||
让 HUD 能显示:
|
||||
|
||||
- 在线
|
||||
- 离线
|
||||
- 本地模式
|
||||
|
||||
## 正确实现思路
|
||||
建议用一个很小的状态请求,比如:
|
||||
|
||||
- summary 接口
|
||||
- health 接口
|
||||
- 或 UE 专用 ping 接口
|
||||
|
||||
不要让状态检测去依赖一个超大的数据接口。
|
||||
|
||||
## 验收
|
||||
后端关掉时,状态栏能明显变成 Offline。
|
||||
|
||||
---
|
||||
|
||||
# Phase 9:打包发布
|
||||
|
||||
## 目标
|
||||
把项目打包成 Windows 可执行程序。
|
||||
|
||||
## 注意
|
||||
打包是一期必须尝试的,但不要让它阻塞前面所有开发。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 编辑器里没稳定跑通前,不要反复纠结打包
|
||||
- 等 LocalJson 版和 HTTP 版都能在编辑器 Play 模式稳定运行后,再打包
|
||||
|
||||
## 验收
|
||||
双击 exe 可以运行,进入地球场景并正常展示数据。
|
||||
|
||||
---
|
||||
|
||||
# 十、建议的 14 天执行计划
|
||||
|
||||
这版比原 MVP 的时间估计更保守,也更适合新手。
|
||||
|
||||
## 第 1 天
|
||||
- 安装 UE5.4
|
||||
- 安装 Cesium
|
||||
- 创建空项目
|
||||
- 创建 Main 场景
|
||||
|
||||
## 第 2 天
|
||||
- 启用 Cesium
|
||||
- 把地球跑起来
|
||||
- 保存项目结构
|
||||
|
||||
## 第 3 天
|
||||
- 做 `BP_GlobeCamera`
|
||||
- 跑通旋转和缩放
|
||||
|
||||
## 第 4 天
|
||||
- 建 `S_ComputePoint`
|
||||
- 准备本地 JSON 文件
|
||||
- 做 `BP_DataLoader` 的本地模式
|
||||
|
||||
## 第 5 天
|
||||
- 做 `BP_ComputePoint`
|
||||
- 本地 JSON 批量生成点
|
||||
|
||||
## 第 6 天
|
||||
- 调整点大小、颜色、高度
|
||||
- 检查经纬度位置是否大致正确
|
||||
|
||||
## 第 7 天
|
||||
- 做 `WBP_InfoCard`
|
||||
- 跑通点击点弹卡片
|
||||
|
||||
## 第 8 天
|
||||
- 做 `WBP_StatusBar`
|
||||
- 显示本地模式状态和点数量
|
||||
|
||||
## 第 9 天
|
||||
- Windows 浏览器验证后端接口
|
||||
- 准备 HTTP 版加载逻辑
|
||||
|
||||
## 第 10 天
|
||||
- 实现 HTTP 拉真实数据
|
||||
- 先在日志里确认数据到了
|
||||
|
||||
## 第 11 天
|
||||
- 把 HTTP 数据接到点渲染
|
||||
- 切换 Local / HTTP 两种模式
|
||||
|
||||
## 第 12 天
|
||||
- 做连接状态 Online / Offline
|
||||
- 补错误提示
|
||||
|
||||
## 第 13 天
|
||||
- 测试完整链路
|
||||
- 修点选、缩放、HUD 细节
|
||||
|
||||
## 第 14 天
|
||||
- 进行第一次打包
|
||||
- 在 Windows 下运行 exe 验证
|
||||
|
||||
---
|
||||
|
||||
# 十一、这份方案和原 MVP 方案怎么融合
|
||||
|
||||
下面是合并关系。
|
||||
|
||||
## 保留原 MVP 方案的部分
|
||||
这些内容很好,建议继续用:
|
||||
|
||||
- 术语表
|
||||
- Phase 结构化写法
|
||||
- `BP_GlobeCamera`
|
||||
- `BP_ComputePoint`
|
||||
- `WBP_InfoCard`
|
||||
- `WBP_StatusBar`
|
||||
- 相机、点、信息卡、状态栏这 4 个核心对象
|
||||
- “先别做海缆、卫星、BGP”的范围控制
|
||||
|
||||
## 用融合版修正的部分
|
||||
这些是这份新文档加进去的:
|
||||
|
||||
- 两阶段起步:先本地 JSON,再真实后端
|
||||
- 不默认 `localhost` 一定通
|
||||
- 推荐做 UE 轻量接口,而不是死扛原始接口
|
||||
- 把打包放到后段,而不是过早纠结
|
||||
- 时间预估更保守
|
||||
- 明确“一期只是证明链路跑通”
|
||||
|
||||
---
|
||||
|
||||
# 十二、验收清单
|
||||
|
||||
## 环境
|
||||
- [ ] UE5.4 安装成功
|
||||
- [ ] Cesium 插件启用成功
|
||||
- [ ] Windows 能访问后端接口
|
||||
|
||||
## 本地演示版
|
||||
- [ ] 地球渲染正常
|
||||
- [ ] 鼠标可旋转和缩放
|
||||
- [ ] 本地 JSON 数据能生成点
|
||||
- [ ] 点的位置大体正确
|
||||
- [ ] 点击点能弹信息卡
|
||||
- [ ] HUD 可显示本地模式和点数量
|
||||
|
||||
## 后端接入版
|
||||
- [ ] HTTP 能拉取真实数据
|
||||
- [ ] HTTP 数据能生成点
|
||||
- [ ] HUD 能显示 Online/Offline
|
||||
- [ ] 切换 Local / HTTP 模式不崩
|
||||
- [ ] exe 能打包并运行
|
||||
|
||||
---
|
||||
|
||||
# 十三、后续路线(MVP 之后)
|
||||
|
||||
当这一期做完后,下一步顺序建议是:
|
||||
|
||||
1. 海缆路径
|
||||
2. 卫星点或轨迹
|
||||
3. 更稳的相机与巡航
|
||||
4. WebSocket 增量更新
|
||||
5. BGP 区域态势
|
||||
6. BGP 事件点
|
||||
7. 更强的粒子和视觉风格
|
||||
|
||||
也就是说:
|
||||
|
||||
**先补“静态层和镜头层”,再补“高频实时层”。**
|
||||
|
||||
---
|
||||
|
||||
# 十四、一句话总结
|
||||
|
||||
这份融合版方案的核心就是:
|
||||
|
||||
**保留原 MVP 的入门友好度,但改成“先本地 JSON、再真实后端”的两阶段实施路线,让你第一次做 UE 时更稳、更容易成功。**
|
||||
|
||||
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
|
||||
|
||||
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.27.4`
|
||||
- `dev` 当前开发分支历史推导到:`0.27.5`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `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 变量 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.27.4",
|
||||
"version": "0.27.5",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -265,6 +265,7 @@ function Scrollbar({
|
||||
{scrollbar.y.visible ? (
|
||||
<div
|
||||
className="scrollbar__thumb scrollbar__thumb--y"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
height: `${scrollbar.y.thumbSize}px`,
|
||||
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
|
||||
@@ -284,6 +285,7 @@ function Scrollbar({
|
||||
{scrollbar.x.visible ? (
|
||||
<div
|
||||
className="scrollbar__thumb scrollbar__thumb--x"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
width: `${scrollbar.x.thumbSize}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;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
@@ -173,6 +174,7 @@ body {
|
||||
}
|
||||
|
||||
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar:focus-within .scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar__track--visible .scrollbar__thumb,
|
||||
.scrollbar__track--dragging .scrollbar__thumb {
|
||||
opacity: 1;
|
||||
@@ -182,12 +184,15 @@ body {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scrollbar__thumb:hover {
|
||||
background: rgba(216, 226, 240, 0.68);
|
||||
.scrollbar__thumb:hover,
|
||||
.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 {
|
||||
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 {
|
||||
@@ -398,9 +403,11 @@ body {
|
||||
|
||||
.playground-card__scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.playground-card__scroll .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.playground-card__scroll::-webkit-scrollbar,
|
||||
@@ -1251,25 +1258,21 @@ body {
|
||||
.playground-result-modal__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
.playground-result-modal__content .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.82);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
.playground-result__blocks-scroll .scrollbar__viewport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.playground-result-modal__content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.playground-result__blocks-scroll.scrollbar {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -1583,14 +1586,14 @@ body {
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-body,
|
||||
.data-source-table-region .ant-table-content {
|
||||
.table-scroll-region .ant-table-body,
|
||||
.table-scroll-region .ant-table-content {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-body::-webkit-scrollbar,
|
||||
.data-source-table-region .ant-table-content::-webkit-scrollbar {
|
||||
.table-scroll-region .ant-table-body::-webkit-scrollbar,
|
||||
.table-scroll-region .ant-table-content::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
@@ -1757,7 +1760,10 @@ body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-right: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||
@@ -1836,10 +1842,11 @@ body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-height: calc(100vh - 180px);
|
||||
overflow: auto;
|
||||
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 {
|
||||
@@ -1857,6 +1864,10 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.alerts-brief-drawer .scrollbar__viewport {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.alerts-brief-drawer__loading {
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
@@ -1887,8 +1898,17 @@ body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.system-alerts-page__table-card,
|
||||
@@ -2128,29 +2148,71 @@ body {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact {
|
||||
flex-wrap: nowrap !important;
|
||||
.bgp-page__summary-scroll.scrollbar,
|
||||
.alerts-summary-scroll.scrollbar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bgp-page__summary-scroll .scrollbar__viewport,
|
||||
.alerts-summary-scroll .scrollbar__viewport {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
.bgp-page__summary-grid,
|
||||
.alerts-summary-grid {
|
||||
display: flex;
|
||||
flex-wrap: nowrap !important;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.82);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
.bgp-page__summary-cell,
|
||||
.alerts-summary-cell {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bgp-page__summary-grid--compact::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.situational-alerts-page__summary-grid {
|
||||
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,
|
||||
@@ -2408,25 +2470,21 @@ body {
|
||||
.settings-panel-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.settings-panel-scroll.scrollbar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-panel-scroll .scrollbar__viewport {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 6px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.settings-panel-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.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-panel-scroll .scrollbar__viewport > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-pane .data-source-table-region .ant-table-container {
|
||||
@@ -3273,7 +3331,6 @@ body {
|
||||
|
||||
.dashboard-restart-log {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
@@ -3281,16 +3338,11 @@ body {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.dashboard-restart-log::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.dashboard-restart-log::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.55);
|
||||
.dashboard-restart-log .scrollbar__viewport {
|
||||
overflow-y: auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
} from 'antd'
|
||||
|
||||
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 { getSituationalAwarenessGateway } from '../../services/situational-awareness'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
@@ -171,20 +171,22 @@ export function BGPAlertsPanel() {
|
||||
|
||||
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll bgp-alerts-page__summary-scroll">
|
||||
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<Card className="bgp-alerts-page__table-card">
|
||||
<Tabs
|
||||
@@ -194,7 +196,7 @@ export function BGPAlertsPanel() {
|
||||
key: 'incidents',
|
||||
label: 'BGP 事件',
|
||||
children: (
|
||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||
<TableScrollRegion className="bgp-alerts-page__table-region">
|
||||
<Table<BGPIncident>
|
||||
columns={incidentColumns}
|
||||
dataSource={incidents}
|
||||
@@ -204,14 +206,14 @@ export function BGPAlertsPanel() {
|
||||
scroll={{ x: 1200, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'anomalies',
|
||||
label: 'BGP 异常',
|
||||
children: (
|
||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||
<TableScrollRegion className="bgp-alerts-page__table-region">
|
||||
<Table<BGPAnomaly>
|
||||
columns={anomalyColumns}
|
||||
dataSource={anomalies}
|
||||
@@ -221,7 +223,7 @@ export function BGPAlertsPanel() {
|
||||
scroll={{ x: 1100, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -244,14 +246,14 @@ export function BGPAlertsPanel() {
|
||||
<Spin tip="正在生成 BGP AI 简报..." />
|
||||
</div>
|
||||
) : 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.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
<Text type="secondary">当前没有可查看的 BGP 简报。</Text>
|
||||
)}
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -18,6 +16,7 @@ import {
|
||||
import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
|
||||
import {
|
||||
getSituationalAwarenessGateway,
|
||||
@@ -119,42 +118,46 @@ export function SituationalAlertsPanel() {
|
||||
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
|
||||
/>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll situational-alerts-page__summary-scroll">
|
||||
<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>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="系统告警侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="BGP 风险侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll situational-alerts-page__panels-scroll">
|
||||
<div className="alerts-summary-grid situational-alerts-page__panels-grid" style={{ gap: '12px' }}>
|
||||
<div className="alerts-summary-cell" style={{ width: '360px' }}>
|
||||
<Card title="系统告警侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '360px' }}>
|
||||
<Card title="BGP 风险侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Space>
|
||||
|
||||
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
import axios from 'axios'
|
||||
|
||||
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 { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
@@ -216,20 +216,22 @@ export function SystemAlertsPanel() {
|
||||
|
||||
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Scrollbar className="alerts-summary-scroll system-alerts-page__summary-scroll">
|
||||
<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>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="alerts-summary-cell" style={{ width: '220px' }}>
|
||||
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<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>
|
||||
columns={columns}
|
||||
dataSource={alerts}
|
||||
@@ -239,7 +241,7 @@ export function SystemAlertsPanel() {
|
||||
scroll={{ x: 1100, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
@@ -260,7 +262,7 @@ export function SystemAlertsPanel() {
|
||||
</Modal>
|
||||
|
||||
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||
<div className="alerts-brief-drawer">
|
||||
<Scrollbar className="alerts-brief-drawer">
|
||||
{briefLoading ? (
|
||||
<div className="alerts-brief-drawer__loading">
|
||||
<Spin tip="正在汇总系统告警事实并生成简报..." />
|
||||
@@ -293,7 +295,7 @@ export function SystemAlertsPanel() {
|
||||
</Card>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Drawer>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
} from 'antd'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import {
|
||||
type BGPAnomaly,
|
||||
@@ -708,37 +708,36 @@ function BGP() {
|
||||
/>
|
||||
|
||||
<Card className="bgp-page__summary-card">
|
||||
<Row
|
||||
gutter={[compactViewport ? 8 : 12, compactViewport ? 8 : 12]}
|
||||
className={`bgp-page__summary-grid${compactViewport ? ' bgp-page__summary-grid--compact' : ''}`}
|
||||
wrap={!compactViewport}
|
||||
>
|
||||
{summaryItems.map((item) => (
|
||||
<Col
|
||||
key={item.label}
|
||||
xs={24}
|
||||
sm={12}
|
||||
md={8}
|
||||
flex={compactViewport ? '180px' : undefined}
|
||||
>
|
||||
<div className="bgp-page__summary-item">
|
||||
<div className="bgp-page__summary-label">{item.label}</div>
|
||||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||||
<Scrollbar className="bgp-page__summary-scroll">
|
||||
<div
|
||||
className="bgp-page__summary-grid"
|
||||
style={{ gap: `${compactViewport ? 8 : 12}px` }}
|
||||
>
|
||||
{summaryItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="bgp-page__summary-cell"
|
||||
style={{ width: compactViewport ? '180px' : '220px' }}
|
||||
>
|
||||
<div className="bgp-page__summary-item">
|
||||
<div className="bgp-page__summary-label">{item.label}</div>
|
||||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</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
|
||||
className="bgp-page__tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -755,7 +754,7 @@ function BGP() {
|
||||
destroyOnHidden
|
||||
>
|
||||
{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) ? (
|
||||
<div className="bgp-page__brief-evidence">
|
||||
{brief.facts.length > 0 ? (
|
||||
@@ -785,7 +784,7 @@ function BGP() {
|
||||
</div>
|
||||
) : null}
|
||||
<MarkdownRenderer markdown={brief.content_markdown} />
|
||||
</div>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
<Text type="secondary">当前没有可查看的简报内容。</Text>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
@@ -532,11 +533,11 @@ function Dashboard() {
|
||||
|
||||
<div className="dashboard-restart-section">
|
||||
<Text className="dashboard-restart-section__label">终端输出</Text>
|
||||
<div className="dashboard-restart-log">
|
||||
<Scrollbar className="dashboard-restart-log">
|
||||
{restartLogs.length > 0 ? restartLogs.map((line, index) => (
|
||||
<div key={`${line}-${index}`}>{line}</div>
|
||||
)) : <div>等待操作</div>}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -939,7 +940,7 @@ function DataList() {
|
||||
/>
|
||||
</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
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
@@ -960,7 +961,7 @@ function DataList() {
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,18 +116,39 @@ function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgres
|
||||
if (!batch || batch.sourceIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
const hasRunningItem = batch.sourceIds.some((sourceId) => batch.items[sourceId]?.is_running)
|
||||
if (hasRunningItem) {
|
||||
return batch
|
||||
function resolveTerminalBatchItem(
|
||||
sourceId: number,
|
||||
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]
|
||||
|
||||
const isRunning = trackedTask?.is_running ?? source?.is_running ?? currentItem?.is_running ?? false
|
||||
if (isRunning) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allFinished = batch.sourceIds.every((sourceId) => {
|
||||
const status = batch.items[sourceId]?.status
|
||||
return Boolean(status && status !== 'running')
|
||||
})
|
||||
const status = trackedTask?.status ?? source?.last_status ?? currentItem?.status ?? null
|
||||
if (!status || status === 'running') {
|
||||
return null
|
||||
}
|
||||
|
||||
return allFinished ? null : batch
|
||||
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 {
|
||||
@@ -436,6 +457,41 @@ function DataSources() {
|
||||
return () => clearInterval(interval)
|
||||
}, [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 force = options?.force ?? false
|
||||
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 MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
@@ -239,6 +241,7 @@ function Playground() {
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const pollTimerRef = useRef<number | null>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const messagesShellRef = useRef<HTMLDivElement | null>(null)
|
||||
const forceScrollToBottomRef = useRef(true)
|
||||
|
||||
const selectedPreset = useMemo(
|
||||
@@ -575,7 +578,7 @@ function Playground() {
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="playground-card__scroll">
|
||||
<Scrollbar className="playground-card__scroll">
|
||||
<Spin spinning={statusLoading}>
|
||||
{providerStatus ? (
|
||||
<div className="playground-provider-panel">
|
||||
@@ -617,7 +620,7 @@ function Playground() {
|
||||
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -699,7 +702,7 @@ function Playground() {
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<div className="playground-chat__messages-shell">
|
||||
<div ref={messagesShellRef} className="playground-chat__messages-shell">
|
||||
<div className="playground-chat__messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
|
||||
{messages.map((entry) => (
|
||||
<div key={entry.id} className={`playground-message playground-message--${entry.role}`}>
|
||||
@@ -760,8 +763,9 @@ function Playground() {
|
||||
<Button size="small" type="primary" loading={editSaving} onClick={() => void handleSaveEditMessage(entry)}>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollbarOverlay containerRef={messagesShellRef} targetSelector=".playground-chat__messages" />
|
||||
</div>
|
||||
</div>
|
||||
) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? (
|
||||
<div className="playground-message__markdown">
|
||||
@@ -1000,42 +1004,42 @@ function Playground() {
|
||||
/>
|
||||
</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%' }}>
|
||||
{analysis.text_blocks.length ? (
|
||||
<div className="playground-result__blocks">
|
||||
<Text strong>文本块</Text>
|
||||
<div className="playground-result__blocks-scroll">
|
||||
<Scrollbar className="playground-result__blocks-scroll">
|
||||
{analysis.text_blocks.map((block, index) => (
|
||||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||||
<pre>{block}</pre>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
{analysis.thinking_blocks.length ? (
|
||||
<div className="playground-result__blocks">
|
||||
<Text strong>Thinking Blocks</Text>
|
||||
<div className="playground-result__blocks-scroll">
|
||||
<Scrollbar className="playground-result__blocks-scroll">
|
||||
{analysis.thinking_blocks.map((block, index) => (
|
||||
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
|
||||
<pre>{block}</pre>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="playground-result__blocks">
|
||||
<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">
|
||||
<pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre>
|
||||
</Card>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -91,7 +93,7 @@ function SettingsPanel({
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
<Card className="settings-panel-card" loading={loading}>
|
||||
<div className="settings-panel-scroll">{children}</div>
|
||||
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
@@ -642,7 +644,7 @@ function Settings() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll-region data-source-table-region">
|
||||
<TableScrollRegion className="data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
@@ -652,7 +654,7 @@ function Settings() {
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -668,7 +670,7 @@ function Settings() {
|
||||
loading={loading}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div ref={collectorTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<TableScrollRegion ref={collectorTableRegionRef} className="data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={collectorColumns}
|
||||
@@ -678,7 +680,7 @@ function Settings() {
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</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 { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
interface Task {
|
||||
@@ -146,9 +147,9 @@ function Tasks() {
|
||||
</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" />
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</Card>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from '
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
@@ -144,7 +145,7 @@ function Users() {
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
</div>
|
||||
<div className="page-shell__body">
|
||||
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region users-table-region" style={{ height: '100%' }}>
|
||||
<TableScrollRegion ref={tableRegionRef} className="data-source-table-region users-table-region" style={{ height: '100%' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
@@ -153,7 +154,7 @@ function Users() {
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</TableScrollRegion>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.27.4"
|
||||
version = "0.27.5"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user