Compare commits

...

2 Commits
zg ... v0.35.0

Author SHA1 Message Date
linkong
f73fa1ea6d release: bump version to 0.35.0 2026-04-22 17:29:24 +08:00
linkong
5b623a6385 release: bump version to 0.34.0 2026-04-22 12:49:37 +08:00
27 changed files with 3485 additions and 193 deletions

View File

@@ -1 +1 @@
0.33.0
0.35.0

View File

@@ -8,6 +8,41 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.35.0] — 2026-04-22
### ✨ Highlights
- Earth 移动端底部抽屉系统全面上线响应式布局自动切换、Tab 导航、手势上拉/下滑开合、惯性速度判定
- 移动端点击可交互物件海缆、登陆点、卫星、BGP后弹出智能定位悬浮卡片可拖动点击跳转详情
### 🔧 Improvements
- 抽屉把手区域缩小至 36pxcollapsed 时仅露出把手,不遮挡地球操作区)
- 抽屉定期弹跳动画提示用户可上拉5 秒间隔,打开后自动停止
- 通知胶囊位置调整,不再覆盖品牌 logo
- 移动端单指旋转、双指捏合缩放地球触控事件冲突修复pointer-events 级联)
### 🐛 Fixes
- 修复移动端抽屉 shell 因 layout 高度240px+遮挡地球触控区域pointer-events 改为按层级精确控制
- 修复悬浮卡片因 setPointerCapture 在 iOS Safari 抑制合成 click 事件导致无法点击的问题
---
## [0.34.0] — 2026-04-22
### ✨ Highlights
- Earth 搜索面板正式接入支持搜索海缆、登陆点、卫星、BGP 事件与观测站,并可直接聚焦到对应对象
- `planet.sh --allow-lan` 打通 Bun + Vite 的局域网开放链路,启动成功后自动打印推荐访问地址与后端健康检查地址
### 🔧 Improvements
- 前端开发启动链统一改成 Bun 直接执行 Vite 入口,不再依赖 shell 中额外暴露的 Node 路径
- Earth 搜索结果接入登陆点详情卡片与对象聚焦,搜索后可直接进入对应详情流
- `planet.sh` 补充局域网 IPv4 自动识别与推荐地址输出,减少 WSL 局域网调试成本
### 🐛 Fixes
- 修复 `./planet.sh restart --allow-lan` 全量重启时未把 `--allow-lan` 继续传给 `start()`,导致前端退回本机监听的问题
- 修复 WSL + Bun 环境下前端偶发因 Vite 启动链不稳定而无法正确监听 `0.0.0.0:3000` 的问题
---
## [0.33.0] — 2026-04-22
### ✨ Highlights
@@ -65,8 +100,6 @@ This project follows the repository versioning rule:
---
## [0.31.0] — 2026-04-21
## [0.31.2] — 2026-04-21
### ✨ Highlights
@@ -103,6 +136,8 @@ This project follows the repository versioning rule:
---
## [0.31.0] — 2026-04-21
### ✨ Features
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件逐帧追踪连接线位置支持外部交互立即中断序列cancel notifier 模式)
- 巡航目标事件点高亮显示hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
@@ -116,8 +151,6 @@ This project follows the repository versioning rule:
---
## [0.29.1] — 2026-04-20
## [0.30.0] — 2026-04-21
### ✨ Features

View File

@@ -16,6 +16,7 @@
当前重点入口:
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)

View File

@@ -16,12 +16,14 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.33.0`
- `dev` 当前开发分支历史推导到:`0.35.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |
| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override并修复 TV 合并采集源后默认频道消失的问题 |
| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 |
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.33.0",
"version": "0.35.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {
@@ -25,8 +25,8 @@
"vite": "^5.0.10"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"dev": "bun ./node_modules/vite/bin/vite.js",
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
"preview": "bun ./node_modules/vite/bin/vite.js preview"
}
}

View File

@@ -8,6 +8,10 @@
:root {
--hud-scale: 1;
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--hud-offset: calc(20px * var(--hud-scale));
--hud-radius: calc(22px * var(--hud-scale));
--hud-panel-padding: calc(18px * var(--hud-scale));
@@ -72,6 +76,10 @@ body.earth-page {
height: 100vh;
}
.earth-app canvas {
touch-action: none;
}
.earth-app.dragging {
cursor: grabbing;
}

View File

@@ -133,3 +133,20 @@
right: var(--hud-offset);
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
}
.layout-mode-mobile .hud-panel-stats {
position: fixed;
top: calc(8px + var(--safe-top));
right: 8px;
width: min(180px, calc(100vw - 16px));
z-index: 205;
}
.layout-mode-mobile.earth-search-open .hud-panel-stats,
.layout-mode-mobile.earth-settings-open .hud-panel-stats,
.layout-mode-mobile.earth-media-open .hud-panel-stats,
.layout-mode-mobile.earth-info-open .hud-panel-stats {
opacity: 0;
pointer-events: none;
transform: translateY(-12px);
}

File diff suppressed because it is too large Load Diff

View File

@@ -367,3 +367,39 @@
.earth-app.layout-expanded .earth-left-column {
transform: translate(calc(-100% + var(--hud-offset)), 0);
}
.layout-mode-mobile .earth-left-column {
top: calc(8px + var(--safe-top));
left: 8px;
max-width: min(300px, calc(100vw - 16px));
}
.layout-mode-mobile .hud-panel-info {
position: fixed;
left: 8px !important;
right: 8px !important;
top: auto !important;
bottom: calc(84px + var(--safe-bottom)) !important;
width: auto;
max-width: none;
max-height: min(58vh, 520px);
z-index: 240;
}
.layout-mode-mobile .info-card-header {
cursor: default;
}
.layout-mode-mobile .info-card-content {
max-height: min(46vh, 420px);
}
.layout-mode-mobile .info-card-property {
flex-direction: column;
align-items: stretch;
}
.layout-mode-mobile .info-card-value {
max-width: none;
text-align: left;
}

View File

@@ -317,3 +317,29 @@
/* Layout-expanded: layer panel slides off with .earth-left-column — no
individual rule needed since the whole column translates together. */
.layout-mode-mobile .hud-panel-layers {
position: fixed;
left: 12px;
right: 12px;
bottom: calc(88px + var(--safe-bottom));
width: auto;
max-height: min(60vh, 520px);
margin-top: 0;
z-index: 220;
transform: translateY(calc(100% + 28px));
opacity: 0;
pointer-events: none;
transition: transform 0.24s ease, opacity 0.2s ease;
}
.layout-mode-mobile .hud-panel-layers.is-mobile-open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.layout-mode-mobile .layer-panel-body {
max-height: min(52vh, 460px);
overflow: auto;
}

View File

@@ -138,3 +138,24 @@
bottom: var(--hud-offset);
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
}
.layout-mode-mobile .hud-panel-legend {
position: fixed;
left: 8px;
bottom: calc(84px + var(--safe-bottom));
width: min(172px, calc(100vw - 16px));
z-index: 205;
}
.layout-mode-mobile .legend-list {
max-height: min(20vh, 180px);
}
.layout-mode-mobile.earth-search-open .hud-panel-legend,
.layout-mode-mobile.earth-settings-open .hud-panel-legend,
.layout-mode-mobile.earth-media-open .hud-panel-legend,
.layout-mode-mobile.earth-info-open .hud-panel-legend {
opacity: 0;
pointer-events: none;
transform: translateY(12px);
}

View File

@@ -105,6 +105,14 @@
pointer-events: auto;
}
.earth-toolbar-orb:has(#layer-action) {
display: none;
}
.layout-mode-mobile .earth-toolbar-group {
display: none;
}
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
pointer-events: none;
}

View File

@@ -285,6 +285,27 @@
cursor: nesw-resize;
}
.layout-mode-mobile .hud-panel-media {
position: fixed;
left: 8px;
right: 8px;
top: calc(8px + var(--safe-top));
bottom: calc(84px + var(--safe-bottom));
width: auto;
max-width: none;
max-height: none;
min-width: 0;
z-index: 230;
}
.layout-mode-mobile .tv-panel-player {
min-height: min(42vh, 360px);
}
.layout-mode-mobile .tv-panel-edge {
display: none;
}
/* 右下角视觉标记 */
.tv-panel-edge[data-edge="br"]::before {
content: "";

View File

@@ -156,14 +156,22 @@
<div id="control-toolbar" class="earth-toolbar">
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
<button id="layer-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="图层">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">layers</span>
</span>
<span class="tooltip earth-toolbar-tooltip">图层</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.12s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">search</span>
</span>
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
<span class="tooltip earth-toolbar-tooltip">搜索</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
<span class="icon rotate-icon icon-pause" aria-hidden="true">
<span class="material-symbols-rounded">pause</span>
@@ -174,7 +182,7 @@
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.36s;">
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">live_tv</span>
@@ -182,7 +190,7 @@
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">refresh</span>
@@ -190,7 +198,7 @@
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
</button>
</div>
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="4" style="--orb-delay: 0.72s;">
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" style="--orb-delay: 0.72s;">
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">zoom_in</span>
@@ -203,7 +211,7 @@
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true"></span></button>
</div>
</div>
<div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 0.9s;">
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">settings</span>
@@ -211,7 +219,7 @@
<span class="tooltip earth-toolbar-tooltip">设置</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.08s;">
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">my_location</span>
@@ -219,7 +227,7 @@
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
<div class="earth-toolbar-orb" data-orb-index="8" style="--orb-delay: 1.26s;">
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
<span class="icon layout-icon layout-expand" aria-hidden="true">
<span class="material-symbols-rounded">open_in_full</span>
@@ -412,6 +420,295 @@
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
<div id="tooltip" class="earth-tooltip"></div>
<div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite">
<span class="earth-mobile-popup-icon" id="earth-mobile-popup-icon"></span>
<div class="earth-mobile-popup-body">
<div class="earth-mobile-popup-title" id="earth-mobile-popup-title"></div>
<div class="earth-mobile-popup-sub" id="earth-mobile-popup-sub"></div>
</div>
<span class="material-symbols-rounded earth-mobile-popup-chevron">chevron_right</span>
</div>
<div id="mobile-drawer-overlay" class="earth-mobile-drawer-overlay" hidden></div>
<div id="mobile-drawer-shell" class="earth-mobile-drawer-shell" aria-hidden="true">
<div class="earth-mobile-drawer-sheet">
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
</div>
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">图层</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">搜索</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">态势</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">新闻</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">TV</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">设置</button>
</div>
<div class="earth-mobile-drawer-content">
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
<div class="earth-mobile-page earth-mobile-page--layers">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Layer Control</span>
<span id="mobile-layer-summary" class="earth-mobile-page-summary">已启用 0 个图层</span>
</div>
<div id="mobile-layer-list" class="earth-mobile-layer-list"></div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="search">
<div class="earth-mobile-page earth-mobile-page--search">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Object Search</span>
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星和 BGP 事件</span>
</div>
<div class="earth-mobile-search-shell">
<span class="material-symbols-rounded earth-mobile-search-icon" aria-hidden="true">search</span>
<input
id="mobile-earth-search-input"
class="earth-mobile-search-input"
type="text"
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="输入名称、地点、NORAD、ASN..."
>
<button id="mobile-earth-search-clear" class="earth-mobile-search-clear" type="button" aria-label="清除搜索" hidden>
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词以搜索当前地球对象</div>
<div id="mobile-earth-search-results" class="earth-mobile-search-results" role="listbox" aria-label="移动端搜索结果"></div>
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、BGP 事件与观测站。</div>
</div>
</section>
<section class="earth-mobile-drawer-slot earth-mobile-drawer-slot--situation" data-drawer-slot="situation">
<div class="earth-mobile-page earth-mobile-page--situation">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Situation</span>
<span class="earth-mobile-page-summary">面向移动端整合的全球态势概览</span>
</div>
<div class="earth-mobile-stats-grid">
<div class="earth-mobile-stat-card">
<span id="mobile-cable-count" class="earth-mobile-stat-num"></span>
<span class="earth-mobile-stat-label">海缆系统</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-landing-point-count" class="earth-mobile-stat-num"></span>
<span class="earth-mobile-stat-label">登陆点</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-satellite-count" class="earth-mobile-stat-num"></span>
<span class="earth-mobile-stat-label">在轨卫星</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-bgp-anomaly-count" class="earth-mobile-stat-num"></span>
<span class="earth-mobile-stat-label">BGP 事件</span>
</div>
</div>
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card-title">图例</div>
<div id="mobile-situation-legend-mode" class="earth-mobile-situation-card-subtitle">海缆</div>
<div id="mobile-situation-legend-list" class="earth-mobile-situation-legend-list"></div>
</div>
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card-title">BGP 状态</div>
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status">暂无观测数据</div>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="news">
<div class="earth-mobile-page earth-mobile-page--news">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">News</span>
<span class="earth-mobile-page-summary">跟随当前视角聚焦全球区域新闻</span>
</div>
<div class="earth-mobile-news-focus">
<div>
<div class="earth-mobile-news-focus-kicker">当前关注区域</div>
<div id="mobile-news-focus-label" class="earth-mobile-news-focus-label">全球焦点</div>
<div id="mobile-news-focus-coords" class="earth-mobile-news-focus-coords">跟随当前视角自动聚焦</div>
</div>
<div id="mobile-news-source-count" class="earth-mobile-news-source-count">0 路聚合源</div>
</div>
<div id="mobile-news-board-status" class="earth-mobile-news-board-status">正在准备全球态势新闻...</div>
<div id="mobile-news-board-list" class="earth-mobile-news-board-list"></div>
<div id="mobile-news-board-empty" class="earth-mobile-news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
<div class="earth-mobile-news-actions">
<button id="mobile-news-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-news-open-external" class="earth-mobile-action-btn" type="button">打开源站</button>
</div>
<a id="mobile-news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="tv">
<div class="earth-mobile-page earth-mobile-page--tv">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">TV</span>
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
</div>
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
<div class="earth-mobile-tv-meta">
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
</div>
<div class="earth-mobile-tv-player">
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
id="mobile-tv-iframe"
class="earth-mobile-tv-iframe"
hidden
title="移动端新闻直播"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; fullscreen; picture-in-picture"
></iframe>
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
</div>
<div class="earth-mobile-tv-actions">
<button id="mobile-tv-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-tv-open-external" class="earth-mobile-action-btn" type="button">访问官网</button>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
<div class="earth-mobile-page earth-mobile-page--settings">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Settings</span>
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">旋转</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">旋转模式</span>
<span class="earth-mobile-settings-subtitle">巡航模式会按 BGP 事件轮播聚焦</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">视图</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">日夜模式</span>
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-daynight-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">地球默认大小</span>
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
data-default-earth-size-slider
aria-label="移动端调整地球默认大小"
>
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">地形</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">地形透明度</span>
<span class="earth-mobile-settings-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
data-terrain-opacity-slider
aria-label="移动端调整地形透明度"
>
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">系统</div>
<div class="earth-mobile-settings-actions">
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开 Admin</a>
</div>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="details">
<div class="earth-mobile-page earth-mobile-page--details">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Details</span>
<span class="earth-mobile-page-summary">点击地球对象后查看统一详情</span>
</div>
<div class="earth-mobile-detail-card">
<div class="earth-mobile-detail-header">
<span id="mobile-info-card-icon" class="earth-mobile-detail-icon">🛰️</span>
<div class="earth-mobile-detail-heading">
<div id="mobile-info-card-title" class="earth-mobile-detail-title">对象详情</div>
<div id="mobile-info-card-type" class="earth-mobile-detail-type">等待选择对象</div>
</div>
</div>
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
<div class="earth-mobile-detail-empty">点击海缆、BGP 事件或卫星后在这里查看详情。</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<div id="search-modal" class="earth-search-modal" aria-hidden="true">
<div id="search-backdrop" class="earth-search-backdrop"></div>
<div class="earth-search-sheet hud-panel" role="dialog" aria-modal="true" aria-label="搜索">
<div class="earth-search-header hud-panel__header">
<div class="hud-panel__title-group">
<div class="earth-search-kicker">搜索</div>
</div>
<div class="hud-panel__actions">
<button id="search-close" class="earth-search-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭搜索">
<span class="material-symbols-rounded">close</span>
</button>
</div>
</div>
<div class="earth-search-content hud-panel__body">
<div class="earth-search-input-shell">
<span class="material-symbols-rounded earth-search-input-icon" aria-hidden="true">search</span>
<input
id="earth-search-input"
class="earth-search-input"
type="text"
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="搜索海缆、登陆点、卫星、BGP 事件..."
>
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、BGP 事件与观测站。</div>
</div>
</div>
</div>
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">

View File

@@ -26,12 +26,19 @@ import {
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { ensureTVPanelReady } from "./tv.js";
import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
updateNewsToggleUI,
} from "./news.js";
import {
closeSearchPanel,
focusSearchInput,
isSearchPanelOpen,
openSearchPanel,
refreshSearchResults,
} from "./search.js";
import {
setButtonTooltip,
setLayerButtonState,
@@ -85,6 +92,436 @@ let focusViewAnimationToken = 0;
let earthSettingsDefaults = null;
let layerRegistry = new Map();
let layerPanelInitialized = false;
let layoutMode = "desktop";
let activeMobileDrawerId = null;
let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
function detectLayoutMode() {
const width = window.innerWidth;
const height = window.innerHeight;
if (width <= 820) {
return "mobile";
}
if (width <= 1080 || height <= 760) {
return "compact";
}
return "desktop";
}
export function getLayoutMode() {
return layoutMode;
}
export function isMobileLayout() {
return layoutMode === "mobile";
}
function isCompactLayout() {
return layoutMode === "compact";
}
function syncMobileDrawerState() {
const shell = document.getElementById("mobile-drawer-shell");
const overlay = document.getElementById("mobile-drawer-overlay");
const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
const tabs = document.querySelectorAll("[data-drawer-card]");
const slots = document.querySelectorAll("[data-drawer-slot]");
const isMobile = isMobileLayout();
document.body.classList.toggle(
"earth-mobile-drawer-open",
isMobile && mobileDrawerOpen,
);
if (shell instanceof HTMLElement) {
shell.setAttribute("aria-hidden", (!isMobile).toString());
}
if (overlay instanceof HTMLElement) {
overlay.hidden = !isMobile;
}
tabs.forEach((tab) => {
if (!(tab instanceof HTMLButtonElement)) return;
const isActive = isMobile && tab.dataset.drawerCard === mobileDrawerCard;
tab.classList.toggle("is-active", isActive);
tab.setAttribute("aria-selected", String(isActive));
});
slots.forEach((slot) => {
if (!(slot instanceof HTMLElement)) return;
slot.classList.toggle(
"is-active",
isMobile && slot.dataset.drawerSlot === mobileDrawerCard,
);
});
const layerPanel = document.getElementById("layer-toggles");
if (layerPanel instanceof HTMLElement) {
layerPanel.classList.toggle("is-mobile-open", false);
}
if (sheet instanceof HTMLElement) {
if (isMobile && !mobileDrawerOpen) {
if (!mobileDrawerHintTimer) {
mobileDrawerHintTimer = setInterval(() => {
if (mobileDrawerOpen) return;
sheet.classList.remove("is-hinting");
void sheet.offsetWidth;
sheet.classList.add("is-hinting");
}, 5000);
}
} else {
clearInterval(mobileDrawerHintTimer);
mobileDrawerHintTimer = null;
sheet.classList.remove("is-hinting");
}
}
}
function setMobileDrawerOpen(panelId, open) {
if (!isMobileLayout()) return;
if (panelId === "layer-toggles") {
mobileDrawerCard = "layers";
}
mobileDrawerOpen = open;
activeMobileDrawerId = open ? panelId : null;
syncMobileDrawerState();
}
function closeTransientMobileOverlays({ except = null } = {}) {
if (isMobileLayout()) {
if (!except) {
mobileDrawerOpen = false;
syncMobileDrawerState();
}
return;
}
if (except !== "search" && isSearchPanelOpen()) {
closeSearchPanel();
}
if (except !== "settings" && isSettingsModalOpen()) {
closeSettingsModal();
}
if (except !== "layer-toggles" && activeMobileDrawerId === "layer-toggles") {
setMobileDrawerOpen("layer-toggles", false);
}
if (except !== "media" && isTVPanelVisible()) {
setTVPanelVisible(false);
}
}
function applyResponsiveLayout() {
layoutMode = detectLayoutMode();
const isMobile = isMobileLayout();
const isCompact = isCompactLayout();
const container = document.getElementById("container");
document.documentElement.classList.toggle("layout-mode-mobile", isMobile);
document.documentElement.classList.toggle("layout-mode-compact", isCompact);
document.body.classList.toggle("layout-mode-mobile", isMobile);
document.body.classList.toggle("layout-mode-compact", isCompact);
container?.classList.toggle("layout-mode-mobile", isMobile);
container?.classList.toggle("layout-mode-compact", isCompact);
if (!isMobile) {
activeMobileDrawerId = null;
mobileDrawerOpen = false;
}
syncMobileDrawerState();
}
function setMobileDrawerState({ open = mobileDrawerOpen, card = mobileDrawerCard } = {}) {
mobileDrawerOpen = Boolean(open);
mobileDrawerCard = card || "layers";
activeMobileDrawerId = mobileDrawerOpen && mobileDrawerCard === "layers"
? "layer-toggles"
: null;
syncMobileDrawerState();
if (!isMobileLayout()) {
return;
}
if (mobileDrawerOpen) {
closeFloatingMenus();
closeSearchPanel();
if (isSettingsModalOpen()) {
closeSettingsModal();
}
}
if (!mobileDrawerOpen) {
return;
}
if (mobileDrawerCard === "search") {
window.setTimeout(() => {
focusSearchInput({ select: true });
refreshSearchResults().catch((error) => {
console.warn("刷新抽屉搜索失败:", error);
});
}, 16);
} else if (mobileDrawerCard === "tv") {
ensureTVPanelReady().catch((error) => {
console.error("初始化媒体抽屉失败:", error);
});
}
}
function getMobileLayerButtons(layerId) {
return Array.from(
document.querySelectorAll(`[data-mobile-layer-button="${layerId}"]`),
).filter((button) => button instanceof HTMLButtonElement);
}
function syncMobileLayerCards() {
const summary = document.getElementById("mobile-layer-summary");
const definitions = getSortedLayerDefinitions();
let activeCount = 0;
definitions.forEach((definition) => {
const visible = Boolean(definition.getVisible?.());
if (visible) {
activeCount += 1;
}
getMobileLayerButtons(definition.id).forEach((button) => {
button.classList.toggle("is-active", visible);
button.setAttribute("aria-checked", visible ? "true" : "false");
const status = button.querySelector("[data-mobile-layer-status]");
if (status) {
status.textContent = visible ? "开启" : "关闭";
}
});
});
if (summary) {
summary.textContent = `已启用 ${activeCount} 个图层`;
}
}
function renderMobileLayerCards() {
const list = document.getElementById("mobile-layer-list");
if (!(list instanceof HTMLElement)) return;
const definitions = getSortedLayerDefinitions();
list.innerHTML = definitions
.map((definition) => `
<button
class="earth-mobile-layer-card"
type="button"
data-mobile-layer-button="${definition.id}"
role="switch"
aria-checked="${definition.getVisible?.() ? "true" : "false"}"
>
<span class="earth-mobile-layer-card-icon material-symbols-rounded">${definition.icon}</span>
<span class="earth-mobile-layer-card-copy">
<span class="earth-mobile-layer-card-title">${definition.label}</span>
<span class="earth-mobile-layer-card-subtitle">${definition.meta || ""}</span>
</span>
<span class="earth-mobile-layer-card-status" data-mobile-layer-status>${definition.getVisible?.() ? "开启" : "关闭"}</span>
</button>
`)
.join("");
list.querySelectorAll("[data-mobile-layer-button]").forEach((button) => {
bindListener(button, "click", async (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const layerId = target.dataset.mobileLayerButton;
const definition = layerId ? getLayerDefinition(layerId) : null;
if (!definition) return;
await definition.setVisible(!definition.getVisible());
syncMobileLayerCards();
});
});
syncMobileLayerCards();
}
function setupMobileDrawerShell() {
const overlay = document.getElementById("mobile-drawer-overlay");
const shell = document.getElementById("mobile-drawer-shell");
const handle = document.getElementById("mobile-drawer-handle");
const tabs = document.querySelectorAll("[data-drawer-card]");
const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
bindListener(overlay, "click", () => {
setMobileDrawerState({ open: false });
});
tabs.forEach((tab) => {
bindListener(tab, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const card = target.dataset.drawerCard || "layers";
setMobileDrawerState({ open: true, card });
});
});
if (handle instanceof HTMLElement && sheet instanceof HTMLElement) {
const DRAWER_HANDLE_PX = 36;
const SWIPE_OPEN_VELOCITY = 0.3; // px/ms upward → open regardless of position
const SWIPE_IDLE_VELOCITY = 0.05; // px/ms threshold below which position decides
const SWIPE_CLOSE_VELOCITY = 0.5; // px/ms downward on content → close
let startY = 0;
let startTranslate = 0;
let dragging = false;
let activePointerId = null;
let lastMoveY = 0;
let lastMoveTime = 0;
let velocityY = 0;
sheet.addEventListener("animationend", () => {
sheet.classList.remove("is-hinting");
});
const getClosedOffset = () => {
const safeBottom = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
) || 0;
return Math.max(sheet.offsetHeight - DRAWER_HANDLE_PX - safeBottom, 0);
};
const applyTranslate = (value) => {
sheet.style.transition = "none";
sheet.style.transform = `translateY(${Math.max(0, Math.min(getClosedOffset(), value))}px)`;
};
const stopDragging = (event) => {
if (!dragging) return;
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
const currentTransform = sheet.style.transform;
const match = currentTransform.match(/translateY\(([-\d.]+)px\)/);
const finalOffset = match ? Number.parseFloat(match[1]) : startTranslate;
const closedOffset = getClosedOffset();
const velocity = velocityY;
dragging = false;
activePointerId = null;
velocityY = 0;
lastMoveY = 0;
lastMoveTime = 0;
sheet.style.transition = "";
sheet.style.transform = "";
const shouldOpen =
velocity < -SWIPE_OPEN_VELOCITY
|| (velocity <= SWIPE_IDLE_VELOCITY && finalOffset < closedOffset * 0.5);
if (shouldOpen) {
setMobileDrawerState({ open: true, card: mobileDrawerCard || "layers" });
} else {
setMobileDrawerState({ open: false });
}
};
bindListener(handle, "click", () => {
if (dragging) return;
setMobileDrawerState({ open: !mobileDrawerOpen, card: mobileDrawerCard || "layers" });
});
bindListener(handle, "pointerdown", (event) => {
if (!isMobileLayout()) return;
sheet.classList.remove("is-hinting");
dragging = true;
activePointerId = event.pointerId;
startY = event.clientY;
lastMoveY = event.clientY;
lastMoveTime = performance.now();
velocityY = 0;
startTranslate = mobileDrawerOpen ? 0 : getClosedOffset();
applyTranslate(startTranslate);
handle.setPointerCapture?.(event.pointerId);
event.preventDefault();
});
bindListener(window, "pointermove", (event) => {
if (!dragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
const now = performance.now();
const dt = now - lastMoveTime;
if (dt > 0) {
velocityY = (event.clientY - lastMoveY) / dt;
}
lastMoveY = event.clientY;
lastMoveTime = now;
const deltaY = event.clientY - startY;
applyTranslate(startTranslate + deltaY);
event.preventDefault();
}, { passive: false });
bindListener(window, "pointerup", stopDragging);
bindListener(window, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
}
bindListener(window, "earth:open-details-tab", () => {
if (isMobileLayout()) setMobileDrawerState({ open: true, card: "details" });
});
const content = shell?.querySelector(".earth-mobile-drawer-content");
if (content instanceof HTMLElement) {
let contentStartY = 0;
let contentLastY = 0;
let contentLastTime = 0;
let contentVelocityY = 0;
let contentTracking = false;
bindListener(content, "pointerdown", (event) => {
if (!isMobileLayout() || !mobileDrawerOpen) return;
contentStartY = event.clientY;
contentLastY = event.clientY;
contentLastTime = performance.now();
contentVelocityY = 0;
contentTracking = true;
});
bindListener(content, "pointermove", (event) => {
if (!contentTracking) return;
const now = performance.now();
const dt = now - contentLastTime;
if (dt > 0) {
contentVelocityY = (event.clientY - contentLastY) / dt;
}
contentLastY = event.clientY;
contentLastTime = now;
});
const endContentTrack = () => {
if (!contentTracking) return;
contentTracking = false;
const activeSlot = content.querySelector(".earth-mobile-drawer-slot.is-active");
const atTop = !activeSlot || activeSlot.scrollTop <= 2;
if (atTop && contentVelocityY > SWIPE_CLOSE_VELOCITY) {
setMobileDrawerState({ open: false });
}
contentVelocityY = 0;
};
bindListener(content, "pointerup", endContentTrack);
bindListener(content, "pointercancel", endContentTrack);
}
}
function compareLayerDefinitionsByStartupPriority(left, right) {
const leftPriority = Number.isFinite(left?.startupPriority)
@@ -281,20 +718,23 @@ function persistEarthSettings() {
}
function syncDefaultEarthZoomUi(nextZoom) {
const slider = document.getElementById("default-earth-size-slider");
const value = document.getElementById("default-earth-size-value");
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
const zoomValue = document.getElementById("zoom-value");
const tooltipText = getZoomResetTooltipText(nextZoom);
if (slider instanceof HTMLInputElement) {
sliders.forEach((slider) => {
if (!(slider instanceof HTMLInputElement)) return;
slider.min = CONFIG.minZoom.toString();
slider.max = CONFIG.maxZoom.toString();
slider.step = DEFAULT_EARTH_ZOOM_STEP.toString();
slider.value = nextZoom.toFixed(2);
}
if (value) {
value.textContent = formatZoomPercent(nextZoom);
}
});
values.forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = formatZoomPercent(nextZoom);
}
});
if (zoomValue instanceof HTMLElement) {
zoomValue.title = tooltipText;
const tooltip = zoomValue.querySelector(".tooltip");
@@ -331,14 +771,16 @@ async function applyEarthSettings(settings) {
});
const appliedOpacity = setTerrainOpacity(settings.terrainOpacity);
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
if (terrainOpacitySlider instanceof HTMLInputElement) {
terrainOpacitySlider.value = appliedOpacity.toFixed(2);
}
if (terrainOpacityValue) {
terrainOpacityValue.textContent = `${Math.round(appliedOpacity * 100)}%`;
}
document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]").forEach((slider) => {
if (slider instanceof HTMLInputElement) {
slider.value = appliedOpacity.toFixed(2);
}
});
document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]").forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = `${Math.round(appliedOpacity * 100)}%`;
}
});
setRotationMode(settings.rotationMode, { persist: false, suppressStatus: true });
@@ -376,6 +818,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
if (!enabled) {
applyTerrainUiState(button, false);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("地形已隐藏", "info");
@@ -398,6 +841,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
if (toggleToken !== terrainToggleToken) return showTerrain;
applyTerrainUiState(button, true);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形已显示", "success");
@@ -406,6 +850,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
} catch (error) {
console.error("加载真实地形失败:", error);
applyTerrainUiState(button, false);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形暂时不可用", "error");
@@ -428,11 +873,14 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
if (!enabled && !silent) {
showStatusMessage("卫星已隐藏", "info");
} else if (enabled) {
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl) {
satelliteCountEl.textContent = `${getSatelliteCount()}`;
}
["satellite-count", "mobile-satellite-count"].forEach((id) => {
const satelliteCountEl = document.getElementById(id);
if (satelliteCountEl) {
satelliteCountEl.textContent = `${getSatelliteCount()}`;
}
});
}
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
@@ -442,6 +890,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
loading: false,
tooltip: "显示卫星",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return false;
}
@@ -461,6 +910,7 @@ function setBGPLayerEnabled(button, enabled, { persist = true, silent = false }
if (bgpCountEl) {
bgpCountEl.textContent = `${getBGPCount()}`;
}
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info");
@@ -474,6 +924,7 @@ function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false
active: enabled,
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info");
@@ -485,10 +936,12 @@ async function setCablesLayerEnabled(button, enabled, { persist = true, silent =
clearSelectionIfHiding(!enabled);
try {
await setCablesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换线缆显示失败:", error);
syncMobileLayerCards();
if (persist) persistEarthSettings();
return getShowCables();
}
@@ -660,6 +1113,7 @@ function registerLayerDefinition(definition, options = {}) {
if (row && layerPanelInitialized) {
bindLayerButton(row, normalizedDefinition);
}
renderMobileLayerCards();
return normalizedDefinition;
}
@@ -748,6 +1202,15 @@ export function applyImmediateView(targetEarthObj, camera, options = {}) {
}
}
export function setZoomLevel(nextZoom, camera = activeCamera) {
zoomLevel = clampEarthZoomLevel(nextZoom);
if (camera) {
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
}
return zoomLevel;
}
function cancelSettingsSheetAnimation() {
if (settingsSheetAnimation) {
settingsSheetAnimation.cancel();
@@ -891,6 +1354,10 @@ function closeFloatingMenus() {
}
function openSettingsModal() {
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "settings" });
return;
}
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
@@ -900,7 +1367,9 @@ function openSettingsModal() {
settingsModalTimer = null;
}
closeFloatingMenus();
closeTransientMobileOverlays({ except: "settings" });
cancelSettingsSheetAnimation();
document.body.classList.add("earth-settings-open");
modal.classList.remove("is-closing");
modal.classList.add("is-opening");
modal.classList.add("is-open");
@@ -917,11 +1386,15 @@ function openSettingsModal() {
}
function closeSettingsModal() {
if (isMobileLayout()) {
return;
}
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
if (!modal) return;
cancelSettingsSheetAnimation();
document.body.classList.remove("earth-settings-open");
modal.classList.remove("is-open");
modal.classList.add("is-closing");
if (sheet instanceof HTMLElement) {
@@ -941,6 +1414,10 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible);
if (!visible && activeMobileDrawerId === panelId) {
activeMobileDrawerId = null;
syncMobileDrawerState();
}
syncSettingsToggle(panelId, visible);
if (panelId === "media-panel") {
updateTVToggleUI(visible);
@@ -973,10 +1450,11 @@ function syncAllHudPanelToggles() {
}
function syncDayNightToggle(enabled) {
const input = document.getElementById("toggle-daynight");
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
function applyDayNightEnabled(enabled, { persist = true } = {}) {
@@ -1028,24 +1506,29 @@ function setupSettingsControls() {
});
});
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
const defaultEarthSizeSlider = document.getElementById("default-earth-size-slider");
const terrainOpacitySliders = document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]");
const terrainOpacityValues = document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]");
const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
if (terrainOpacitySlider instanceof HTMLInputElement) {
terrainOpacitySlider.value = nextOpacity.toFixed(2);
}
if (terrainOpacityValue) {
terrainOpacityValue.textContent = `${safeOpacity}%`;
}
terrainOpacitySliders.forEach((slider) => {
if (slider instanceof HTMLInputElement) {
slider.value = nextOpacity.toFixed(2);
}
});
terrainOpacityValues.forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = `${safeOpacity}%`;
}
});
};
syncTerrainOpacityUi(getTerrainOpacity());
syncDefaultEarthZoomUi(defaultEarthZoom);
if (terrainOpacitySlider instanceof HTMLInputElement) {
terrainOpacitySliders.forEach((terrainOpacitySlider) => {
if (!(terrainOpacitySlider instanceof HTMLInputElement)) return;
bindListener(terrainOpacitySlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
@@ -1056,9 +1539,10 @@ function setupSettingsControls() {
syncTerrainOpacityUi(appliedOpacity);
persistEarthSettings();
});
}
});
if (defaultEarthSizeSlider instanceof HTMLInputElement) {
defaultEarthSizeSliders.forEach((defaultEarthSizeSlider) => {
if (!(defaultEarthSizeSlider instanceof HTMLInputElement)) return;
bindListener(defaultEarthSizeSlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
@@ -1068,7 +1552,7 @@ function setupSettingsControls() {
{ persist: true, applyToCurrentView: true },
);
});
}
});
rotationModeButtons.forEach((button) => {
bindListener(button, "click", (event) => {
@@ -1080,12 +1564,17 @@ function setupSettingsControls() {
});
});
const dayNightToggle = document.getElementById("toggle-daynight");
if (dayNightToggle instanceof HTMLInputElement) {
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
applyDayNightEnabled(dayNightToggle.checked);
});
}
});
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
bindListener(mobileSettingsReset, "click", () => {
resetEarthSettings();
});
captureEarthSettingsDefaults();
applyEarthSettings(loadEarthSettings());
@@ -1103,6 +1592,10 @@ function setupHudPanelControls() {
if (!(target instanceof HTMLElement)) return;
const panelId = target.dataset.closePanel;
if (!panelId) return;
if (isMobileLayout() && panelId === "layer-toggles") {
setMobileDrawerOpen(panelId, false);
return;
}
setHudPanelVisibility(panelId, false);
});
});
@@ -1230,19 +1723,32 @@ function setupDraggableHudPanels() {
if (!handle) return;
let isDragging = false;
let activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = () => {
const stopDragging = (event) => {
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
isDragging = false;
activePointerId = null;
panel.classList.remove("is-dragging");
document.body.style.userSelect = "";
};
const onMove = (event) => {
if (!isDragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
if (isMobileLayout()) return;
event.preventDefault();
const desiredLeft = startLeft + (event.clientX - startPointerX);
const desiredTop = startTop + (event.clientY - startPointerY);
capturePanelAnchor(app, panel, desiredLeft, desiredTop);
@@ -1262,8 +1768,11 @@ function setupDraggableHudPanels() {
};
bindListener(handle, "pointerdown", (event) => {
if (isMobileLayout()) return;
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
event.preventDefault();
isDragging = true;
activePointerId = event.pointerId;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
@@ -1298,9 +1807,9 @@ function setupDraggableHudPanels() {
handle.setPointerCapture?.(event.pointerId);
});
bindListener(handle, "pointermove", onMove);
bindListener(handle, "pointerup", stopDragging);
bindListener(handle, "pointercancel", stopDragging);
bindListener(window, "pointermove", onMove, { passive: false });
bindListener(window, "pointerup", stopDragging);
bindListener(window, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
});
@@ -1393,6 +1902,7 @@ export function setupControls(camera, renderer, scene, earth) {
resetCleanup();
activeCamera = camera;
earthObj = earth;
applyResponsiveLayout();
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
@@ -1400,6 +1910,21 @@ export function setupControls(camera, renderer, scene, earth) {
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
bindListener(window, "resize", () => {
applyResponsiveLayout();
});
bindListener(window, "earth:search-open-change", (event) => {
if (event instanceof CustomEvent && event.detail?.open) {
closeTransientMobileOverlays({ except: "search" });
}
});
bindListener(window, "earth:tv-visibility-change", (event) => {
if (event instanceof CustomEvent && event.detail?.visible) {
closeTransientMobileOverlays({ except: "media" });
}
});
// No longer auto-navigates to details tab on mobile — popup handles the display.
// Drawer details tab is opened explicitly via earth:open-details-tab when user taps popup.
}
function setupZoomControls(camera) {
@@ -1785,6 +2310,7 @@ export function getStartupLoadLayers() {
function setupTerrainControls() {
initializeLayerRegistry();
const container = document.getElementById("container");
const layerBtn = document.getElementById("layer-action");
const searchBtn = document.getElementById("search-action");
const terrainBtn = getLayerButton("terrain");
const layoutBtn = document.getElementById("layout-toggle");
@@ -1795,9 +2321,30 @@ function setupTerrainControls() {
setupHudPanelControls();
setupDraggableHudPanels();
setupLayerPanel();
setupMobileDrawerShell();
renderMobileLayerCards();
bindListener(layerBtn, "click", (event) => {
event.preventDefault();
event.stopPropagation();
if (isMobileLayout()) {
const nextOpen = !(mobileDrawerOpen && mobileDrawerCard === "layers");
setMobileDrawerState({ open: nextOpen, card: "layers" });
return;
}
const panel = document.getElementById("layer-toggles");
const currentlyVisible = !panel?.classList.contains("hud-panel-hidden");
setHudPanelVisibility("layer-toggles", !currentlyVisible);
});
bindListener(searchBtn, "click", () => {
showStatusMessage("搜索功能待开发", "info");
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "search" });
return;
}
closeTransientMobileOverlays({ except: "search" });
openSearchPanel();
});
bindListener(terrainBtn, "pointerenter", () => {
@@ -1820,6 +2367,15 @@ function setupTerrainControls() {
const openGroups = [zoomGroup].filter((group) =>
group?.classList.contains("open"),
);
if (
isMobileLayout() &&
mobileDrawerOpen &&
event.target instanceof Element &&
!event.target.closest("#mobile-drawer-shell")
) {
setMobileDrawerState({ open: false });
}
if (openGroups.length === 0) return;
const clickedInsideOpenGroup = openGroups.some((group) =>
@@ -1850,6 +2406,7 @@ function setupTerrainControls() {
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
applyResponsiveLayout();
updateLayoutUI(container);
}
@@ -1857,11 +2414,21 @@ function setupKeyboardControls() {
bindListener(document, "keydown", (event) => {
if (event.key !== "Escape") return;
if (isSearchPanelOpen()) {
closeSearchPanel();
return;
}
if (isSettingsModalOpen()) {
closeSettingsModal();
return;
}
if (isMobileLayout() && mobileDrawerOpen) {
setMobileDrawerState({ open: false });
return;
}
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return;
@@ -1969,9 +2536,12 @@ function setupToolbarHubCluster() {
let collapseTimer = null;
let expandedToolbarBounds = null;
let refreshBoundsFrameId = 0;
let hubPinnedOpen = false;
const layoutToolbarOrbs = () => {
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb"));
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb")).filter(
(orb) => getComputedStyle(orb).display !== "none",
);
if (orbs.length === 0) return;
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
@@ -2055,6 +2625,7 @@ function setupToolbarHubCluster() {
};
const scheduleCollapse = () => {
if (hubPinnedOpen) return;
if (collapseTimer) clearTimeout(collapseTimer);
collapseTimer = window.setTimeout(() => {
setExpanded(false);
@@ -2093,6 +2664,19 @@ function setupToolbarHubCluster() {
setExpanded(true);
});
bindListener(hub, "click", (event) => {
event.preventDefault();
event.stopPropagation();
cancelCollapse();
if (isMobileLayout()) {
hubPinnedOpen = !cluster.classList.contains("is-expanded");
setExpanded(hubPinnedOpen);
return;
}
hubPinnedOpen = !cluster.classList.contains("is-expanded");
setExpanded(hubPinnedOpen);
});
const HOVER_PADDING_PX = 12;
const collectExpandedToolbarBounds = () => {
const rects = [];
@@ -2152,6 +2736,7 @@ function setupToolbarHubCluster() {
};
bindListener(document, "mousemove", (event) => {
if (hubPinnedOpen) return;
if (!cluster.classList.contains("is-expanded")) return;
if (
event.target instanceof Element &&
@@ -2187,6 +2772,24 @@ function setupToolbarHubCluster() {
scheduleExpandedToolbarBoundsRefresh();
}
});
cluster.querySelectorAll(".earth-toolbar-orb > button").forEach((button) => {
if (!(button instanceof HTMLButtonElement) || button === hub) return;
bindListener(button, "click", () => {
if (hubPinnedOpen) {
hubPinnedOpen = false;
setExpanded(false);
}
});
});
bindListener(document, "pointerdown", (event) => {
if (!hubPinnedOpen) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest("#toolbar-cluster")) return;
hubPinnedOpen = false;
setExpanded(false);
});
}
export function teardownControls() {

View File

@@ -4,6 +4,189 @@ import { showStatusMessage } from './ui.js';
let currentType = null;
let cardMounted = false;
// ── Mobile popup ─────────────────────────────────────────────
function getMobilePopupTitle(type, data) {
switch (type) {
case 'cable': return data.name || '海缆';
case 'landing_point': return data.name || '登陆点';
case 'satellite': return data.name || '卫星';
case 'bgp': return data.anomaly_type || 'BGP事件';
case 'bgp_collector': return data.collector || 'BGP观测站';
case 'supercomputer': return data.name || '超算';
case 'gpu_cluster': return data.name || 'GPU集群';
default: return '详情';
}
}
function getMobilePopupSubtitle(type, data) {
switch (type) {
case 'cable': return data.owner || data.status || '海缆';
case 'landing_point': return data.country || '登陆点';
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
case 'bgp': return data.severity || 'BGP路由异常';
case 'bgp_collector': return data.location || 'BGP观测站';
case 'supercomputer': return data.country || '超级计算机';
case 'gpu_cluster': return data.country || 'GPU集群';
default: return '';
}
}
function positionMobilePopup(popup, touchX, touchY) {
const margin = 14;
const drawerClearance = 52;
const vpW = window.innerWidth;
const vpH = window.innerHeight;
const safeBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom')
) || 0;
const bottomBound = vpH - drawerClearance - safeBottom;
// Measure actual popup size (it's rendered but invisible via opacity)
const popW = popup.offsetWidth || 200;
const popH = popup.offsetHeight || 68;
const gap = 22;
const spaceRight = vpW - touchX;
const spaceLeft = touchX;
const spaceBottom = bottomBound - touchY;
const spaceTop = touchY;
let left, top;
// Horizontal: side with more room
if (spaceRight >= popW + gap + margin) {
left = touchX + gap;
} else if (spaceLeft >= popW + gap + margin) {
left = touchX - gap - popW;
} else {
left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin));
}
// Vertical: prefer above touch, then below
if (spaceTop >= popH + gap + margin) {
top = touchY - gap - popH;
} else if (spaceBottom >= popH + gap + margin) {
top = touchY + gap;
} else {
top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin));
}
left = Math.max(margin, Math.min(left, vpW - popW - margin));
top = Math.max(margin, Math.min(top, bottomBound - popH - margin));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
}
let popupShowToken = 0;
function showMobilePopup(type, data, x, y) {
// Require coordinates — skip if called without position (e.g. from handleCableClick)
if (x == null || y == null) return;
const popup = document.getElementById('earth-mobile-popup');
const iconEl = document.getElementById('earth-mobile-popup-icon');
const titleEl = document.getElementById('earth-mobile-popup-title');
const subEl = document.getElementById('earth-mobile-popup-sub');
if (!popup || !iconEl || !titleEl || !subEl) return;
const config = CARD_CONFIG[type];
if (!config) return;
iconEl.textContent = config.icon;
titleEl.textContent = getMobilePopupTitle(type, data);
subEl.textContent = getMobilePopupSubtitle(type, data);
// Invalidate any in-flight hide listener
popupShowToken += 1;
const token = popupShowToken;
popup.removeAttribute('hidden');
popup.classList.remove('is-visible');
requestAnimationFrame(() => {
positionMobilePopup(popup, x, y);
requestAnimationFrame(() => {
if (token !== popupShowToken) return; // superseded
popup.classList.add('is-visible');
});
});
}
function hideMobilePopup() {
const popup = document.getElementById('earth-mobile-popup');
if (!popup) return;
popupShowToken += 1; // invalidate any pending show
popup.classList.remove('is-visible');
popup.addEventListener('transitionend', () => {
if (!popup.classList.contains('is-visible')) {
popup.setAttribute('hidden', '');
}
}, { once: true });
}
let popupClickBound = false;
function ensurePopupClickHandler() {
if (popupClickBound) return;
popupClickBound = true;
const popup = document.getElementById('earth-mobile-popup');
if (!popup) return;
let dragPointerId = null;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
let dragged = false;
const DRAG_THRESHOLD = 10;
popup.addEventListener('pointerdown', (e) => {
if (e.button > 0) return;
e.stopPropagation();
dragPointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
const rect = popup.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
dragged = false;
});
// Track drag at document level so pointer can leave popup bounds
document.addEventListener('pointermove', (e) => {
if (e.pointerId !== dragPointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
dragged = true;
e.stopPropagation();
const margin = 8;
const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
});
document.addEventListener('pointerup', (e) => {
if (e.pointerId !== dragPointerId) return;
const wasDragged = dragged;
dragPointerId = null;
dragged = false;
if (!wasDragged) {
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
}
});
document.addEventListener('pointercancel', (e) => {
if (e.pointerId === dragPointerId) {
dragPointerId = null;
dragged = false;
}
});
// Block click from bubbling to document (which would close the drawer)
popup.addEventListener('click', (e) => e.stopPropagation());
}
const CARD_CONFIG = {
cable: {
icon: '🛥️',
@@ -18,6 +201,18 @@ const CARD_CONFIG = {
{ key: 'rfs', label: '投入使用' }
]
},
landing_point: {
icon: '📍',
title: '登陆点详情',
className: 'cable',
fields: [
{ key: 'name', label: '名称' },
{ key: 'country', label: '国家' },
{ key: 'status', label: '状态' },
{ key: 'cable_count', label: '关联海缆数' },
{ key: 'cables', label: '关联海缆' }
]
},
satellite: {
icon: '🛰️',
title: '卫星详情',
@@ -114,19 +309,31 @@ function setupInfoCardDrag(panel) {
if (!handle) return;
let isDragging = false;
let activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = () => {
const stopDragging = (event) => {
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
isDragging = false;
activePointerId = null;
panel.classList.remove('is-dragging');
document.body.style.userSelect = '';
};
const onMove = (event) => {
if (!isDragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
event.preventDefault();
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const nextLeft = Math.min(
@@ -143,7 +350,9 @@ function setupInfoCardDrag(panel) {
handle.addEventListener('pointerdown', (event) => {
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
event.preventDefault();
isDragging = true;
activePointerId = event.pointerId;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
@@ -159,9 +368,9 @@ function setupInfoCardDrag(panel) {
handle.setPointerCapture?.(event.pointerId);
});
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', stopDragging);
handle.addEventListener('pointercancel', stopDragging);
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', stopDragging);
window.addEventListener('pointercancel', stopDragging);
handle.addEventListener('lostpointercapture', stopDragging);
}
@@ -240,6 +449,13 @@ function mountCard() {
function positionPanel(panel, x, y, options = {}) {
if (!panel) return;
if (document.body.classList.contains('layout-mode-mobile')) {
panel.style.left = '8px';
panel.style.right = '8px';
panel.style.top = 'auto';
panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))';
return;
}
const margin = 12;
const offset = 14;
const vpW = window.innerWidth;
@@ -284,11 +500,19 @@ function showPanel(x, y, options = {}) {
if (!panel) return;
if (x != null && y != null) positionPanel(panel, x, y, options);
panel.classList.add('is-visible');
document.body.classList.add('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
);
}
function hidePanel() {
const panel = getPanel();
if (panel) panel.classList.remove('is-visible');
document.body.classList.remove('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
}
// No-op: event binding now happens lazily in mountCard()
@@ -308,6 +532,51 @@ export function showInfoCard(type, data, options = {}) {
return;
}
if (document.body.classList.contains('layout-mode-mobile')) {
currentType = type;
// Fill drawer details slot (accessible when user taps popup → opens details tab)
const icon = document.getElementById('mobile-info-card-icon');
const title = document.getElementById('mobile-info-card-title');
const typeLabel = document.getElementById('mobile-info-card-type');
const content = document.getElementById('mobile-info-card-content');
if (icon) icon.textContent = config.icon;
if (title) title.textContent = config.title;
if (typeLabel) typeLabel.textContent = type.replaceAll('_', ' ');
if (content) {
let html = '';
for (const field of config.fields) {
let value = data[field.key];
if (value === undefined || value === null || value === '') {
value = '-';
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
if (field.unit && value !== '-') value = value + ' ' + field.unit;
html += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
}
// Show the floating mini popup near the touch point (requires coordinates)
if (options.x != null && options.y != null) {
ensurePopupClickHandler();
showMobilePopup(type, data, options.x, options.y);
document.body.classList.add('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
);
}
return;
}
mountCard();
currentType = type;
@@ -347,6 +616,15 @@ export function showInfoCard(type, data, options = {}) {
}
export function hideInfoCard() {
if (document.body.classList.contains('layout-mode-mobile')) {
hideMobilePopup();
document.body.classList.remove('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
currentType = null;
return;
}
hidePanel();
currentType = null;
}

View File

@@ -62,17 +62,18 @@ export function setLegendItems(mode, items) {
}
function syncCurrentLabel(mode) {
const labelEl = document.getElementById("legend-current-label");
if (!labelEl) return;
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
.forEach((labelEl) => {
if (labelEl) {
labelEl.textContent = nextLabel;
}
});
}
function renderLegend(mode) {
const listEl = document.querySelector("#legend .legend-list");
if (!listEl) return;
const items = legendItemsByMode[mode] || [];
listEl.innerHTML = items
const html = items
.map(
(item) => `
<div class="legend-item">
@@ -81,4 +82,14 @@ function renderLegend(mode) {
</div>`,
)
.join("");
const desktopList = document.querySelector("#legend .legend-list");
if (desktopList) {
desktopList.innerHTML = html;
}
const mobileList = document.getElementById("mobile-situation-legend-list");
if (mobileList) {
mobileList.innerHTML = html;
}
}

View File

@@ -72,6 +72,7 @@ import {
toggleSatellites,
getShowSatellites,
getSatelliteLegendItems,
getSatelliteData,
setSelectedSatelliteLegend,
clearSelectedSatelliteLegend,
getSatelliteCount,
@@ -136,6 +137,7 @@ import {
applyImmediateView,
focusEarthView,
getZoomLevel,
setZoomLevel,
teardownControls,
} from "./controls.js";
import {
@@ -162,6 +164,7 @@ import {
import { mountBrand } from "./brand.js";
import { initTVPanel } from "./tv.js";
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
import { initSearchPanel } from "./search.js";
export let scene;
export let camera;
@@ -204,6 +207,11 @@ let cruisePollTimerId = null;
let cruiseConnector = null;
let cruiseBGPAdapter = null;
let cruiseSequencer = null;
let activeDragPointerId = null;
let activeTouchPoints = new Map();
let pinchGesture = null;
let pointerDragDistance = 0;
let suppressNextClick = false;
const clock = new THREE.Clock();
const interactionRaycaster = new THREE.Raycaster();
@@ -224,6 +232,7 @@ const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
const DRAG_POINTER_THRESHOLD_PX = 8;
const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column",
".earth-left-column *",
@@ -237,6 +246,8 @@ const HUD_INTERACTIVE_SELECTORS = [
"#earth-stats *",
"#media-panel",
"#media-panel *",
"#mobile-drawer-shell",
"#mobile-drawer-shell *",
];
function bindListener(target, eventName, handler, options) {
@@ -274,6 +285,13 @@ function getDragRotationFactor() {
return CONFIG.dragRotationFactorBase * scale;
}
function getTouchDistance(firstPoint, secondPoint) {
return Math.hypot(
secondPoint.clientX - firstPoint.clientX,
secondPoint.clientY - firstPoint.clientY,
);
}
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
@@ -609,6 +627,391 @@ function getBGPCollectorBriefHtml(marker) {
return `<strong>${name}</strong><br>${count} 条事件`;
}
function getSearchCardCoords() {
return {
x: Math.round(window.innerWidth * SEARCH_CARD_X_RATIO),
y: Math.round(window.innerHeight * SEARCH_CARD_Y_RATIO),
absolute: true,
};
}
function normalizeSearchString(...parts) {
return parts
.flat()
.filter((part) => part !== undefined && part !== null && part !== false)
.map((part) => String(part).trim())
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function computeSearchScore(query, ...parts) {
const text = normalizeSearchString(...parts);
if (!text) return -1;
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return -1;
if (text === normalizedQuery) return 240;
if (text.startsWith(normalizedQuery)) return 180;
if (text.includes(normalizedQuery)) return 120;
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return -1;
let score = 0;
for (const token of tokens) {
if (text.startsWith(token)) {
score += 60;
continue;
}
if (text.includes(token)) {
score += 36;
continue;
}
return -1;
}
return score;
}
function getCableFocusCoords(cable) {
if (!cable?.userData?.localCenter) return null;
return vector3ToLatLon(cable.userData.localCenter);
}
function getLandingPointFocusCoords(point) {
if (!point?.position) return null;
return vector3ToLatLon(point.position);
}
function getSatelliteFocusCoords(index) {
const positions = getSatellitePositions();
const vector = positions?.[index]?.current;
if (!vector) return null;
return vector3ToLatLon(vector);
}
function getBGPFocusCoords(marker) {
const lat = marker?.userData?.displayLatitude ?? marker?.userData?.latitude;
const lon = marker?.userData?.displayLongitude ?? marker?.userData?.longitude;
if (typeof lat !== "number" || typeof lon !== "number") return null;
return { lat, lon };
}
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
if (!coords || !camera) return;
await focusEarthView(camera, {
lat: coords.lat,
lon: coords.lon,
zoom,
duration: 950,
suppressStatus: true,
});
}
function showLandingPointInfo(point, coords) {
const cableNames = Array.isArray(point?.userData?.cableNames)
? point.userData.cableNames
: [];
setLegendMode("cables");
showInfoCard(
"landing_point",
{
name: point?.userData?.name || "-",
country: point?.userData?.country || "-",
status: point?.userData?.status || "-",
cable_count: cableNames.length,
cables: cableNames.length > 0 ? cableNames.join(" / ") : "-",
},
coords,
);
}
async function focusSearchCable(cable) {
await setCablesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getCableFocusCoords(cable);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.14));
}
const cableId = cable?.userData?.cableId;
if (cableId !== undefined) {
setCableState(cableId, CABLE_STATE.LOCKED);
}
lockedObject = cable;
lockedObjectType = "cable";
handleCableClick(cable);
showCableInfo(cable, getSearchCardCoords());
}
async function focusSearchLandingPoint(point) {
await setCablesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getLandingPointFocusCoords(point);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.22));
}
const relatedCableNames = Array.isArray(point?.userData?.cableNames)
? point.userData.cableNames
: [];
clearAllCableStates();
getCableLines().forEach((cable) => {
if (relatedCableNames.includes(cable.userData?.name)) {
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
}
});
applyLandingPointVisualState(relatedCableNames, relatedCableNames.length === 0, camera);
showLandingPointInfo(point, getSearchCardCoords());
showStatusMessage(`已定位登陆点:${point.userData?.name || "未知登陆点"}`, "info");
}
async function focusSearchSatellite(index) {
await setSatellitesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const sat = selectSatellite(index);
if (!sat?.properties) return;
const coords = getSatelliteFocusCoords(index);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.18));
}
lockedObject = sat;
lockedObjectType = "satellite";
lockedSatellite = sat;
lockedSatelliteIndex = index;
setLockedSatelliteIndex(index);
showPredictedOrbit(sat);
const satPositions = getSatellitePositions();
if (satPositions?.[index]) {
setSatelliteRingState(index, "locked", satPositions[index].current);
}
showSatelliteInfo(sat.properties, getSearchCardCoords());
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
}
async function focusSearchBGPMarker(marker) {
if (!getShowBGP()) {
toggleBGP(true);
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getBGPFocusCoords(marker);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.2));
}
const earth = getEarth();
if (marker?.userData?.type === "bgp") {
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp";
showBGPEventOverlay(marker, earth);
applyBGPEventSatelliteHighlights(marker);
showBGPInfo(marker, getSearchCardCoords());
showStatusMessage(`已定位 BGP 事件:${marker.userData?.collector || "未知观测站"}`, "info");
return;
}
if (marker?.userData?.type === "bgp_collector") {
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp_collector";
showBGPCollectorCoverageOverlay(marker, earth);
showBGPCollectorInfo(marker, getSearchCardCoords());
showStatusMessage(`已定位观测站:${marker.userData?.collector || "未知观测站"}`, "info");
}
}
function resolveEarthSearchResults(query) {
const results = [];
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return results;
getCableLines().forEach((cable) => {
const score = computeSearchScore(
normalizedQuery,
cable.userData?.name,
cable.userData?.owner,
cable.userData?.status,
cable.userData?.length,
"海缆 电缆 cable",
);
if (score < 0) return;
results.push({
id: `cable:${cable.userData?.cableId || cable.uuid}`,
kind: "cable",
icon: "cable",
typeLabel: "海缆",
title: cable.userData?.name || "未知海缆",
subtitle: [cable.userData?.owner, cable.userData?.status].filter(Boolean).join(" · ") || "海底光缆系统",
score,
entity: cable,
});
});
getLandingPoints().forEach((point, index) => {
const score = computeSearchScore(
normalizedQuery,
point.userData?.name,
point.userData?.country,
point.userData?.status,
point.userData?.cableNames,
"登陆点 landing point",
);
if (score < 0) return;
results.push({
id: `landing:${point.uuid || index}`,
kind: "landing_point",
icon: "location_on",
typeLabel: "登陆点",
title: point.userData?.name || "未知登陆点",
subtitle:
[point.userData?.country, Array.isArray(point.userData?.cableNames) ? `${point.userData.cableNames.length} 条海缆` : ""]
.filter(Boolean)
.join(" · ") || "海缆登陆点",
score,
entity: point,
});
});
getSatelliteData().forEach((satellite, index) => {
const props = satellite?.properties;
const score = computeSearchScore(
normalizedQuery,
props?.name,
props?.norad_cat_id,
props?.inclination,
"卫星 satellite norad",
);
if (score < 0) return;
results.push({
id: `sat:${props?.norad_cat_id || index}`,
kind: "satellite",
icon: "satellite_alt",
typeLabel: "卫星",
title: props?.name || `NORAD ${props?.norad_cat_id || index}`,
subtitle: props?.norad_cat_id ? `NORAD ${props.norad_cat_id}` : "在轨卫星",
score,
entity: { index },
});
});
getBGPAnomalyMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.collector,
marker.userData?.prefix,
marker.userData?.city,
marker.userData?.country,
marker.userData?.anomaly_type,
marker.userData?.incident_type,
marker.userData?.origin_asn,
marker.userData?.new_origin_asn,
"bgp 事件 anomaly prefix asn",
);
if (score < 0) return;
results.push({
id: `bgp:${marker.userData?.id || marker.uuid}`,
kind: "bgp",
icon: "hub",
typeLabel: "BGP事件",
title:
formatBGPAnomalyTypeLabel(
marker.userData?.incident_type || marker.userData?.anomaly_type,
) || "BGP 事件",
subtitle:
[
marker.userData?.collector,
marker.userData?.prefix,
formatBGPLocation(marker.userData?.city, marker.userData?.country),
]
.filter(Boolean)
.join(" · ") || "BGP 异常事件",
score,
entity: marker,
});
});
getBGPCollectorMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.collector,
marker.userData?.city,
marker.userData?.country,
marker.userData?.status,
"bgp collector 观测站",
);
if (score < 0) return;
results.push({
id: `collector:${marker.userData?.collector || marker.uuid}`,
kind: "bgp_collector",
icon: "travel_explore",
typeLabel: "观测站",
title: marker.userData?.collector || "未知观测站",
subtitle:
[
formatBGPLocation(marker.userData?.city, marker.userData?.country),
formatBGPCollectorStatus(marker.userData?.status || "online"),
]
.filter(Boolean)
.join(" · ") || "BGP 观测站",
score,
entity: marker,
});
});
return results
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.title.localeCompare(right.title, "zh-CN");
})
.slice(0, SEARCH_RESULT_LIMIT);
}
async function handleSearchSelection(result) {
if (!result) return;
if (result.kind === "cable") {
await focusSearchCable(result.entity);
return;
}
if (result.kind === "landing_point") {
await focusSearchLandingPoint(result.entity);
return;
}
if (result.kind === "satellite") {
await focusSearchSatellite(result.entity.index);
return;
}
if (result.kind === "bgp" || result.kind === "bgp_collector") {
await focusSearchBGPMarker(result.entity);
}
}
function getBGPStatusText(bgpResult) {
if (bgpResult.totalCount > 0) {
return `${bgpResult.totalCount} 起活跃事件`;
@@ -639,10 +1042,12 @@ function updateBGPHud(bgpResult) {
bgpCollectorEl.textContent = `${bgpResult.collectorCount}`;
}
const bgpStatusEl = document.getElementById("bgp-status-summary");
if (bgpStatusEl) {
bgpStatusEl.textContent = getBGPStatusText(bgpResult);
}
["bgp-status-summary", "mobile-bgp-status-summary"].forEach((id) => {
const bgpStatusEl = document.getElementById(id);
if (bgpStatusEl) {
bgpStatusEl.textContent = getBGPStatusText(bgpResult);
}
});
}
function ensureCruiseConnector() {
@@ -1005,10 +1410,12 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
});
}
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl) {
satelliteCountEl.textContent = `${satelliteCount}`;
}
["satellite-count", "mobile-satellite-count"].forEach((id) => {
const satelliteCountEl = document.getElementById(id);
if (satelliteCountEl) {
satelliteCountEl.textContent = `${satelliteCount}`;
}
});
}
function updateCableToggleUi(enabled) {
@@ -1188,6 +1595,10 @@ export function init() {
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
initTVPanel();
initNewsPanel();
initSearchPanel({
resolveResults: resolveEarthSearchResults,
onSelectResult: handleSearchSelection,
});
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
@@ -1474,6 +1885,9 @@ async function loadData() {
}
const POSITION_UPDATE_FORCE_DELTA = 250;
const SEARCH_RESULT_LIMIT = 28;
const SEARCH_CARD_X_RATIO = 0.68;
const SEARCH_CARD_Y_RATIO = 0.18;
export async function reloadData() {
await loadData();
@@ -1580,9 +1994,9 @@ export async function setSatellitesEnabled(
function setupEventListeners() {
const handleResize = () => onWindowResize();
const handleMouseMove = (event) => onMouseMove(event);
const handleMouseDown = (event) => onMouseDown(event);
const handleMouseUp = () => onMouseUp();
const handlePointerMove = (event) => onPointerMove(event);
const handlePointerDown = (event) => onPointerDown(event);
const handlePointerUp = (event) => onPointerUp(event);
const handleMouseLeave = () => onMouseLeave();
const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy();
@@ -1592,11 +2006,15 @@ function setupEventListeners() {
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
bindListener(window, "mousemove", handleMouseMove);
bindListener(renderer.domElement, "mousedown", handleMouseDown);
bindListener(window, "mouseup", handleMouseUp);
bindListener(renderer.domElement, "pointerdown", handlePointerDown);
bindListener(window, "pointermove", handlePointerMove);
bindListener(window, "pointerup", handlePointerUp);
bindListener(window, "pointercancel", handlePointerUp);
bindListener(renderer.domElement, "mouseleave", handleMouseLeave);
bindListener(renderer.domElement, "click", handleClick);
if (renderer?.domElement) {
renderer.domElement.style.touchAction = "none";
}
}
function updateHudScale() {
@@ -1684,6 +2102,9 @@ function onMouseMove(event) {
if (Date.now() - dragStartTime > 500) {
isLongDrag = true;
}
if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
isLongDrag = true;
}
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
@@ -1860,6 +2281,100 @@ function onMouseUp() {
document.getElementById("container")?.classList.remove("dragging");
}
function onPointerDown(event) {
if (isEventOnHud(event)) return;
if (event.pointerType !== "touch" && event.button !== 0) return;
if (event.pointerType === "touch") {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
});
renderer?.domElement?.setPointerCapture?.(event.pointerId);
if (activeTouchPoints.size === 2) {
const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
pinchGesture = {
distance: getTouchDistance(firstPoint, secondPoint),
startZoom: getZoomLevel(),
};
activeDragPointerId = null;
onMouseUp();
return;
}
}
activeDragPointerId = event.pointerId;
pointerDragDistance = 0;
suppressNextClick = false;
onMouseDown(event);
}
function onPointerMove(event) {
if (event.pointerType === "touch") {
if (activeTouchPoints.has(event.pointerId)) {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
});
}
if (pinchGesture && activeTouchPoints.size >= 2) {
const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
const nextDistance = getTouchDistance(firstPoint, secondPoint);
if (pinchGesture.distance > 0) {
const scale = nextDistance / pinchGesture.distance;
setZoomLevel(pinchGesture.startZoom * scale, camera);
suppressNextClick = true;
hideTooltip();
}
return;
}
if (activeDragPointerId === event.pointerId && isDragging) {
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
pointerDragDistance = Math.max(
pointerDragDistance,
Math.hypot(deltaX, deltaY),
);
onMouseMove(event);
return;
}
return;
}
if (activeDragPointerId === event.pointerId && isDragging) {
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
pointerDragDistance = Math.max(
pointerDragDistance,
Math.hypot(deltaX, deltaY),
);
}
onMouseMove(event);
}
function onPointerUp(event) {
if (event.pointerType === "touch") {
activeTouchPoints.delete(event.pointerId);
if (activeTouchPoints.size < 2) {
pinchGesture = null;
}
}
if (activeDragPointerId === event.pointerId) {
if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
suppressNextClick = true;
isLongDrag = true;
}
activeDragPointerId = null;
pointerDragDistance = 0;
onMouseUp();
}
}
function onMouseLeave() {
hideTooltip();
}
@@ -1868,6 +2383,10 @@ function onClick(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) return;
if (suppressNextClick) {
suppressNextClick = false;
return;
}
updatePointerFromEvent(event);

View File

@@ -18,17 +18,18 @@ let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
refreshBtn: document.getElementById("news-refresh"),
openBtn: document.getElementById("news-open-external"),
status: document.getElementById("news-board-status"),
focusLabel: document.getElementById("news-focus-label"),
focusCoords: document.getElementById("news-focus-coords"),
sourceCount: document.getElementById("news-source-count"),
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
regionChip: document.getElementById("news-region-chip"),
board: document.getElementById("news-board-list"),
empty: document.getElementById("news-board-empty"),
feedAnchor: document.getElementById("news-feed-anchor"),
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
};
}
@@ -81,18 +82,33 @@ function renderPayload(nextPayload) {
openBtn,
feedAnchor,
} = getElements();
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
return;
}
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
return;
}
} else {
return;
}
}
if (regionChip) {
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
}
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page does not show the compact chip row.
} else if (!regionChip) {
return;
}
focusLabel.textContent = focus.label || "全球焦点";
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
@@ -275,8 +291,6 @@ export function initNewsPanel() {
if (initialized) return;
initialized = true;
const { refreshBtn, openBtn } = getElements();
updateNewsToggleUI(isTVPanelVisible());
renderEmptyState("正在准备全球态势新闻聚合源...");
@@ -287,16 +301,22 @@ export function initNewsPanel() {
updateNewsToggleUI(Boolean(event.detail?.visible));
});
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻已刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
["news-refresh", "mobile-news-refresh"].forEach((id) => {
const refreshBtn = document.getElementById(id);
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
});
});
openBtn?.addEventListener("click", openCurrentSourceHomepage);
["news-open-external", "mobile-news-open-external"].forEach((id) => {
const openBtn = document.getElementById(id);
openBtn?.addEventListener("click", openCurrentSourceHomepage);
});
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
}

View File

@@ -0,0 +1,282 @@
let initialized = false;
let resolveResultsFn = null;
let onSelectResultFn = null;
let currentResults = [];
let activeIndex = -1;
let searchTimerId = null;
let isOpen = false;
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
modal: document.getElementById("search-modal"),
backdrop: document.getElementById("search-backdrop"),
input: document.getElementById(
isMobile ? "mobile-earth-search-input" : "earth-search-input",
),
clear: document.getElementById(
isMobile ? "mobile-earth-search-clear" : "earth-search-clear",
),
meta: document.getElementById(
isMobile ? "mobile-earth-search-meta" : "earth-search-meta",
),
results: document.getElementById(
isMobile ? "mobile-earth-search-results" : "earth-search-results",
),
empty: document.getElementById(
isMobile ? "mobile-earth-search-empty" : "earth-search-empty",
),
close: document.getElementById("search-close"),
};
}
function setMeta(text) {
const { meta } = getElements();
if (meta) meta.textContent = text;
}
function updateEmptyState(query) {
const { empty } = getElements();
if (!empty) return;
if (!query) {
empty.textContent = "支持搜索海缆、登陆点、卫星、BGP 事件与观测站。";
return;
}
empty.textContent = "未找到匹配对象可尝试名称、地点、NORAD、ASN、前缀等关键词。";
}
function renderResults(query) {
const { results, empty } = getElements();
if (!results || !empty) return;
results.innerHTML = "";
const hasResults = currentResults.length > 0;
empty.hidden = hasResults;
updateEmptyState(query);
if (!hasResults) return;
currentResults.forEach((result, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "earth-search-result";
button.setAttribute("role", "option");
button.dataset.index = String(index);
button.innerHTML = `
<span class="earth-search-result-icon" aria-hidden="true">
<span class="material-symbols-rounded">${escapeHtml(result.icon || "search")}</span>
</span>
<span class="earth-search-result-copy">
<span class="earth-search-result-title">${escapeHtml(result.title)}</span>
<span class="earth-search-result-subtitle">${escapeHtml(result.subtitle || "")}</span>
</span>
<span class="earth-search-result-type">${escapeHtml(result.typeLabel || "")}</span>
`;
button.addEventListener("click", async () => {
await selectResult(index);
});
results.appendChild(button);
});
syncActiveResult();
}
function syncActiveResult() {
const { results } = getElements();
if (!results) return;
Array.from(results.children).forEach((node, index) => {
node.classList.toggle("is-active", index === activeIndex);
});
}
function moveActiveResult(delta) {
if (currentResults.length === 0) return;
activeIndex =
((activeIndex < 0 ? 0 : activeIndex) + delta + currentResults.length) %
currentResults.length;
syncActiveResult();
const { results } = getElements();
const activeNode = results?.children?.[activeIndex];
activeNode?.scrollIntoView({ block: "nearest" });
}
async function selectResult(index) {
const result = currentResults[index];
if (!result || typeof onSelectResultFn !== "function") return;
closeSearchPanel();
try {
await onSelectResultFn(result);
} catch (error) {
console.error("Search selection failed:", error);
}
}
async function runSearch() {
const { input, clear } = getElements();
if (!input) return;
const query = input.value.trim();
if (clear) {
clear.hidden = query.length === 0;
}
if (!query) {
currentResults = [];
activeIndex = -1;
setMeta("输入关键词以搜索当前地球对象");
renderResults("");
return;
}
setMeta("正在检索…");
try {
const nextResults = await resolveResultsFn?.(query);
currentResults = Array.isArray(nextResults) ? nextResults : [];
activeIndex = currentResults.length > 0 ? 0 : -1;
setMeta(`找到 ${currentResults.length} 个结果`);
renderResults(query);
} catch (error) {
console.error("Search failed:", error);
currentResults = [];
activeIndex = -1;
setMeta("搜索失败");
renderResults(query);
}
}
function scheduleSearch() {
if (searchTimerId) {
clearTimeout(searchTimerId);
}
searchTimerId = window.setTimeout(() => {
searchTimerId = null;
runSearch();
}, 120);
}
function handleKeydown(event) {
const { modal, input } = getElements();
const isMobile = document.body.classList.contains("layout-mode-mobile");
if (!isMobile && !modal?.classList.contains("is-open")) return;
if (event.key === "Escape") {
event.preventDefault();
closeSearchPanel();
return;
}
if (event.target !== input) return;
if (event.key === "ArrowDown") {
event.preventDefault();
moveActiveResult(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveActiveResult(-1);
} else if (event.key === "Enter" && activeIndex >= 0) {
event.preventDefault();
selectResult(activeIndex).catch((error) => {
console.warn("Selecting search result failed:", error);
});
}
}
export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
resolveResultsFn = resolveResults;
onSelectResultFn = onSelectResult;
if (initialized) return;
initialized = true;
const inputs = ["earth-search-input", "mobile-earth-search-input"]
.map((id) => document.getElementById(id))
.filter((node) => node instanceof HTMLInputElement);
const clears = ["earth-search-clear", "mobile-earth-search-clear"]
.map((id) => document.getElementById(id))
.filter((node) => node instanceof HTMLButtonElement);
const close = document.getElementById("search-close");
const backdrop = document.getElementById("search-backdrop");
inputs.forEach((input) => {
input.addEventListener("input", scheduleSearch);
input.addEventListener("keydown", handleKeydown);
});
clears.forEach((clear) => {
clear.addEventListener("click", () => {
const { input } = getElements();
if (!input) return;
input.value = "";
input.focus();
runSearch().catch((error) => {
console.warn("Clearing search failed:", error);
});
});
});
close?.addEventListener("click", () => {
closeSearchPanel();
});
backdrop?.addEventListener("click", () => {
closeSearchPanel();
});
document.addEventListener("keydown", handleKeydown);
}
export function openSearchPanel() {
const { modal, input } = getElements();
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
if (isOpen) return;
isOpen = true;
document.body.classList.add("earth-search-open");
modal?.classList.add("is-open");
modal?.setAttribute("aria-hidden", "false");
window.dispatchEvent(
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
);
window.setTimeout(() => {
input?.focus();
input?.select();
runSearch().catch((error) => {
console.warn("Running search failed:", error);
});
}, 16);
}
export function focusSearchInput({ select = false } = {}) {
const { input } = getElements();
if (!(input instanceof HTMLInputElement)) return;
input.focus();
if (select) {
input.select();
}
}
export function refreshSearchResults() {
return runSearch();
}
export function closeSearchPanel() {
const { modal } = getElements();
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
if (!isOpen) return;
isOpen = false;
document.body.classList.remove("earth-search-open");
modal?.classList.remove("is-open");
modal?.setAttribute("aria-hidden", "true");
window.dispatchEvent(
new CustomEvent("earth:search-open-change", { detail: { open: false } }),
);
}
export function isSearchPanelOpen() {
return isOpen;
}

View File

@@ -56,21 +56,22 @@ const HLS_RETRY_CONFIG = {
};
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
// Outer media shell node.
panel: document.getElementById("media-panel"),
toggleBtn: document.getElementById("toggle-tv"),
select: document.getElementById("tv-source-select"),
title: document.getElementById("tv-source-title"),
meta: document.getElementById("tv-source-meta"),
catalog: document.getElementById("tv-source-catalog"),
status: document.getElementById("tv-source-status"),
notes: document.getElementById("tv-source-notes"),
iframe: document.getElementById("tv-iframe"),
video: document.getElementById("tv-video"),
empty: document.getElementById("tv-empty-state"),
refreshBtn: document.getElementById("tv-refresh"),
openBtn: document.getElementById("tv-open-external"),
select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"),
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
video: document.getElementById(isMobile ? "mobile-tv-video" : "tv-video"),
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
metaWrap: document.getElementById("tv-meta-wrap"),
metaToggle: document.getElementById("tv-meta-toggle"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
@@ -351,6 +352,7 @@ function setPanelVisible(visible) {
const { panel } = getElements();
if (!panel) return;
mediaPanel?.setVisible(visible);
document.body.classList.toggle("earth-media-open", visible);
updateToggleButton(visible);
syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
@@ -1071,12 +1073,16 @@ export function initTVPanel() {
showStatusMessage("已切换到态势新闻", "info");
});
select?.addEventListener("change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) return;
currentSourceId = target.value;
renderSource(findSourceById(currentSourceId));
});
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((selectEl) => {
selectEl?.addEventListener("change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) return;
currentSourceId = target.value;
renderSource(findSourceById(currentSourceId));
});
});
metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
@@ -1084,9 +1090,13 @@ export function initTVPanel() {
setMetaCollapsed(isNowCollapsed);
});
refreshBtn?.addEventListener("click", () => {
refreshTVPanel();
});
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((refreshEl) => {
refreshEl?.addEventListener("click", () => {
refreshTVPanel();
});
});
liveTabBtn?.addEventListener("click", () => {
setActiveTab("live");
@@ -1095,24 +1105,32 @@ export function initTVPanel() {
setActiveTab("news");
});
iframe?.addEventListener("load", () => {
if (iframe.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
[iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((iframeEl) => {
iframeEl?.addEventListener("load", () => {
if (iframeEl.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
});
video?.addEventListener("loadedmetadata", () => {
if (video.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
[video, document.getElementById("mobile-tv-video"), document.getElementById("tv-video")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((videoEl) => {
videoEl?.addEventListener("loadedmetadata", () => {
if (videoEl.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
video?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
videoEl?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
});
setupResizeHandle();
syncPanelActiveTab("live");

View File

@@ -19,6 +19,15 @@ function getElement(id) {
return document.getElementById(id);
}
function setTextTargets(ids, value) {
ids.forEach((id) => {
const element = getElement(id);
if (element) {
element.textContent = value;
}
});
}
function setElementDisplay(element, visible, displayValue = "block") {
if (!element) return;
element.style.display = visible ? displayValue : "none";
@@ -171,27 +180,13 @@ export function updateZoomDisplay(zoomLevel, distance) {
// Update earth stats
export function updateEarthStats(stats) {
const cableCountEl = getElement("cable-count");
const landingPointCountEl = getElement("landing-point-count");
const bgpAnomalyCountEl = getElement("bgp-anomaly-count");
const bgpCollectorCountEl = getElement("bgp-collector-count");
const bgpStatusSummaryEl = getElement("bgp-status-summary");
const terrainStatusEl = getElement("terrain-status");
const textureQualityEl = getElement("texture-quality");
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
if (landingPointCountEl)
landingPointCountEl.textContent = stats.landingPointCount || 0;
if (bgpAnomalyCountEl)
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
if (bgpCollectorCountEl)
bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0;
if (bgpStatusSummaryEl)
bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-";
if (terrainStatusEl)
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
if (textureQualityEl)
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
setTextTargets(["cable-count", "mobile-cable-count"], String(stats.cableCount || 0));
setTextTargets(["landing-point-count", "mobile-landing-point-count"], String(stats.landingPointCount || 0));
setTextTargets(["bgp-anomaly-count", "mobile-bgp-anomaly-count"], String(stats.bgpAnomalyCount || 0));
setTextTargets(["bgp-collector-count"], String(stats.bgpCollectorCount || 0));
setTextTargets(["bgp-status-summary", "mobile-bgp-status-summary"], stats.bgpStatusSummary || "-");
setTextTargets(["terrain-status"], stats.terrainOn ? "开启" : "关闭");
setTextTargets(["texture-quality"], stats.textureQuality || "8K 卫星图");
}
// Show/hide loading via status message

View File

@@ -21,5 +21,5 @@
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"references": [{ "path": "./tsconfig.tooling.json" }]
}

View File

@@ -59,6 +59,7 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
@@ -371,6 +372,41 @@ log_success() {
log_line "done" "$GREEN" "$1"
}
get_recommended_lan_ipv4() {
local candidate
while read -r candidate; do
[ -z "$candidate" ] && continue
case "$candidate" in
127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*)
continue
;;
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
printf "%s" "$candidate"
return 0
;;
esac
done <<EOF
$(hostname -I 2>/dev/null | tr ' ' '\n')
EOF
return 1
}
log_lan_access_notes() {
local frontend_port="$1"
local backend_port="$2"
local recommended_lan_ip=""
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}"
log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health"
else
log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}"
log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health"
fi
}
print_splash() {
clear_wait_spinner
printf "%b" "$CYAN"
@@ -766,8 +802,8 @@ ensure_frontend_deps() {
cd "$SCRIPT_DIR/frontend"
set_wait_detail "检查 vite 是否已安装"
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
set_wait_detail "检查 Vite Bun 入口是否已安装"
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
if ! run_with_retry \
@@ -780,9 +816,9 @@ ensure_frontend_deps() {
fi
fi
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
close_wait_session_context "$owns_wait_session"
log_error "前端依赖安装失败,未找到 vite"
log_error "前端依赖安装失败,未找到 Vite Bun 入口"
exit 1
fi
@@ -1324,15 +1360,15 @@ cleanup_frontend_processes() {
rm -f "$FRONTEND_PID_FILE"
fi
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true
pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
}
start_frontend_with_retry() {
local frontend_port="$1"
local frontend_port_requested="${2:-0}"
local frontend_lan_enabled="${3:-0}"
local retry=1
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
@@ -1342,7 +1378,13 @@ start_frontend_with_retry() {
fi
cd "$SCRIPT_DIR/frontend"
: > /tmp/planet_frontend.log
nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 &
local -a frontend_args
frontend_args=("$FRONTEND_VITE_ENTRY")
if [ "$frontend_lan_enabled" -eq 1 ]; then
frontend_args+=(--host 0.0.0.0)
fi
frontend_args+=(--port "$frontend_port" --strictPort)
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 &
FRONTEND_PID=$!
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
@@ -1368,6 +1410,7 @@ start_frontend_with_retry() {
start_frontend_service() {
local frontend_port="$1"
local frontend_port_requested="$2"
local frontend_lan_enabled="${3:-0}"
if [ "$frontend_port_requested" -eq 1 ]; then
kill_port_if_requested "$frontend_port" "前端"
@@ -1379,8 +1422,12 @@ start_frontend_service() {
log_success "前端依赖已就绪"
start_wait_session "启动前端服务"
set_wait_detail "启动 Vite 开发服务器"
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then
if [ "$frontend_lan_enabled" -eq 1 ]; then
set_wait_detail "启动 Vite 开发服务器(局域网开放)"
else
set_wait_detail "启动 Vite 开发服务器"
fi
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
stop_wait_session
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES}"
tail -10 /tmp/planet_frontend.log
@@ -1399,6 +1446,7 @@ parse_service_args() {
FRONTEND_PORT_REQUESTED=0
AI_PROVIDER_REQUESTED=0
DATABASE_REQUESTED=0
FRONTEND_LAN_ENABLED=0
while [ "$#" -gt 0 ]; do
case "$1" in
@@ -1433,6 +1481,10 @@ parse_service_args() {
DATABASE_REQUESTED=1
shift 1
;;
--allow-lan)
FRONTEND_LAN_ENABLED=1
shift 1
;;
*)
log_error "未知参数: $1"
exit 1
@@ -1479,7 +1531,7 @@ stop_ai_provider_service() {
}
stop_frontend_service() {
if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
cleanup_frontend_processes "$FRONTEND_PORT"
@@ -1607,13 +1659,16 @@ start() {
print_splash
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
log_success "启动完成"
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
}
stop() {
@@ -1633,7 +1688,7 @@ restart() {
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop
sleep 1
start
start "$@"
return 0
fi
@@ -1658,7 +1713,7 @@ restart() {
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
stop_frontend_service
sleep 1
start_frontend_service "$FRONTEND_PORT" 1
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
fi
echo ""
@@ -1674,6 +1729,9 @@ restart() {
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
log_note "前端: http://localhost:${FRONTEND_PORT}"
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
fi
}
@@ -1754,9 +1812,9 @@ case "$1" in
;;
*)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan"
log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
log_note "createuser 交互创建用户"
log_note "health 检查健康状态"
log_note "log 查看日志"

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.33.0"
version = "0.35.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.33.0"
version = "0.35.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },