release: bump version to 0.34.0

This commit is contained in:
linkong
2026-04-22 12:49:37 +08:00
parent 0082cf3fbd
commit 5b623a6385
15 changed files with 1016 additions and 33 deletions

View File

@@ -1 +1 @@
0.33.0
0.34.0

View File

@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [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 +82,6 @@ This project follows the repository versioning rule:
---
## [0.31.0] — 2026-04-21
## [0.31.2] — 2026-04-21
### ✨ Highlights
@@ -103,6 +118,8 @@ This project follows the repository versioning rule:
---
## [0.31.0] — 2026-04-21
### ✨ Features
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件逐帧追踪连接线位置支持外部交互立即中断序列cancel notifier 模式)
- 巡航目标事件点高亮显示hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
@@ -116,8 +133,6 @@ This project follows the repository versioning rule:
---
## [0.29.1] — 2026-04-20
## [0.30.0] — 2026-04-21
### ✨ Features

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.33.0`
- `dev` 当前开发分支历史推导到:`0.34.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `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.34.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

@@ -453,6 +453,246 @@
user-select: none;
}
.earth-search-modal {
position: fixed;
inset: 0;
z-index: 255;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.earth-search-modal.is-open {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.earth-search-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 8, 20, 0.38);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
opacity: 0;
transition: opacity 0.2s ease;
}
.earth-search-modal.is-open .earth-search-backdrop {
opacity: 1;
}
.earth-search-sheet {
position: fixed;
top: max(calc(28px * var(--hud-scale)), 8vh);
left: 50%;
width: min(calc(680px * var(--hud-scale)), calc(100vw - (32px * var(--hud-scale))));
max-height: min(calc(720px * var(--hud-scale)), calc(100vh - (56px * var(--hud-scale))));
transform: translateX(-50%) scale(0.98);
transform-origin: top center;
padding: calc(var(--hud-panel-padding) * var(--hud-scale));
display: flex;
flex-direction: column;
gap: calc(var(--hud-gap-md) * var(--hud-scale));
overflow: hidden;
opacity: 0;
filter: blur(10px);
transition:
transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.22s ease,
filter 0.22s ease;
}
.earth-search-modal.is-open .earth-search-sheet {
transform: translateX(-50%) scale(1);
opacity: 1;
filter: blur(0);
}
.earth-search-header,
.earth-search-content {
position: relative;
z-index: 1;
}
.earth-search-kicker {
color: var(--hud-text-soft);
font-size: calc(0.72rem * var(--hud-scale));
letter-spacing: 0.16em;
text-transform: uppercase;
}
.earth-search-content {
display: flex;
flex-direction: column;
gap: calc(var(--hud-gap-sm) * var(--hud-scale));
min-height: 0;
}
.earth-search-input-shell {
display: flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
min-height: calc(48px * var(--hud-scale));
padding: calc(8px * var(--hud-scale)) calc(12px * var(--hud-scale));
border: 1px solid rgba(214, 230, 247, 0.12);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
rgba(255, 255, 255, 0.03);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.earth-search-input-shell:focus-within {
border-color: rgba(215, 230, 249, 0.22);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 0 1px rgba(126, 174, 236, 0.1);
}
.earth-search-input-icon {
color: var(--hud-text-muted);
font-size: calc(20px * var(--hud-scale));
}
.earth-search-input {
flex: 1 1 auto;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--hud-title);
font: inherit;
font-size: calc(0.9rem * var(--hud-scale));
letter-spacing: 0.01em;
}
.earth-search-input::placeholder {
color: rgba(190, 208, 227, 0.46);
}
.earth-search-clear[hidden] {
display: none;
}
.earth-search-meta {
min-height: calc(18px * var(--hud-scale));
color: var(--hud-text-muted);
font-size: calc(0.7rem * var(--hud-scale));
letter-spacing: 0.02em;
}
.earth-search-results {
display: flex;
flex-direction: column;
gap: calc(8px * var(--hud-scale));
min-height: 0;
overflow-y: auto;
padding-right: calc(4px * var(--hud-scale));
scrollbar-width: thin;
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
}
.earth-search-results::-webkit-scrollbar {
width: 6px;
}
.earth-search-results::-webkit-scrollbar-track {
background: transparent;
}
.earth-search-results::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
border-radius: 999px;
}
.earth-search-empty {
color: var(--hud-text-muted);
font-size: calc(0.76rem * var(--hud-scale));
line-height: 1.55;
padding: calc(8px * var(--hud-scale)) calc(2px * var(--hud-scale));
}
.earth-search-result {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: calc(12px * var(--hud-scale));
width: 100%;
padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale));
border: 1px solid rgba(212, 227, 244, 0.09);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
rgba(255, 255, 255, 0.025);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: inherit;
text-align: left;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.earth-search-result:hover,
.earth-search-result.is-active {
border-color: rgba(224, 236, 249, 0.16);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent),
rgba(255, 255, 255, 0.04);
transform: translateY(-1px);
}
.earth-search-result-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(32px * var(--hud-scale));
height: calc(32px * var(--hud-scale));
border-radius: calc(10px * var(--hud-scale));
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(214, 230, 247, 0.1);
color: var(--hud-accent-strong);
}
.earth-search-result-icon .material-symbols-rounded {
font-size: calc(18px * var(--hud-scale));
}
.earth-search-result-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.earth-search-result-title {
color: var(--hud-title);
font-size: calc(0.86rem * var(--hud-scale));
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.earth-search-result-subtitle {
color: var(--hud-text-soft);
font-size: calc(0.72rem * var(--hud-scale));
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.earth-search-result-type {
color: var(--hud-text-muted);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.earth-settings-modal {
position: fixed;
inset: 0;

View File

@@ -156,11 +156,11 @@
<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="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;">
@@ -412,6 +412,41 @@
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
<div id="tooltip" class="earth-tooltip"></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

@@ -32,6 +32,7 @@ import {
ensureNewsPanelReady,
updateNewsToggleUI,
} from "./news.js";
import { openSearchPanel } from "./search.js";
import {
setButtonTooltip,
setLayerButtonState,
@@ -1797,7 +1798,7 @@ function setupTerrainControls() {
setupLayerPanel();
bindListener(searchBtn, "click", () => {
showStatusMessage("搜索功能待开发", "info");
openSearchPanel();
});
bindListener(terrainBtn, "pointerenter", () => {

View File

@@ -18,6 +18,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: '卫星详情',

View File

@@ -72,6 +72,7 @@ import {
toggleSatellites,
getShowSatellites,
getSatelliteLegendItems,
getSatelliteData,
setSelectedSatelliteLegend,
clearSelectedSatelliteLegend,
getSatelliteCount,
@@ -162,6 +163,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;
@@ -609,6 +611,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} 起活跃事件`;
@@ -1188,6 +1575,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 +1865,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();

View File

@@ -0,0 +1,227 @@
let initialized = false;
let resolveResultsFn = null;
let onSelectResultFn = null;
let currentResults = [];
let activeIndex = -1;
let searchTimerId = null;
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function getElements() {
return {
modal: document.getElementById("search-modal"),
backdrop: document.getElementById("search-backdrop"),
input: document.getElementById("earth-search-input"),
clear: document.getElementById("earth-search-clear"),
meta: document.getElementById("earth-search-meta"),
results: document.getElementById("earth-search-results"),
empty: document.getElementById("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();
if (!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 { input, clear, close, backdrop } = getElements();
input?.addEventListener("input", scheduleSearch);
input?.addEventListener("keydown", handleKeydown);
clear?.addEventListener("click", () => {
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) return;
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
window.setTimeout(() => {
input?.focus();
input?.select();
runSearch().catch((error) => {
console.warn("Running search failed:", error);
});
}, 16);
}
export function closeSearchPanel() {
const { modal } = getElements();
if (!modal) return;
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
}

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.34.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.34.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },