diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index b6d41e89..44924d68 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -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", () => {
diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js
index 87914699..26018c1a 100644
--- a/frontend/public/earth/js/info-card.js
+++ b/frontend/public/earth/js/info-card.js
@@ -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: '卫星详情',
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index bef15e2d..49695a1c 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -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 `
${name}${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();
diff --git a/frontend/public/earth/js/search.js b/frontend/public/earth/js/search.js
new file mode 100644
index 00000000..8f139aea
--- /dev/null
+++ b/frontend/public/earth/js/search.js
@@ -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("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+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 = `
+
+ ${escapeHtml(result.icon || "search")}
+
+
+ ${escapeHtml(result.title)}
+ ${escapeHtml(result.subtitle || "")}
+
+
${escapeHtml(result.typeLabel || "")}
+ `;
+ 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");
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 5413626c..5bfab7b6 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -21,5 +21,5 @@
}
},
"include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
+ "references": [{ "path": "./tsconfig.tooling.json" }]
}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.tooling.json
similarity index 100%
rename from frontend/tsconfig.node.json
rename to frontend/tsconfig.tooling.json
diff --git a/planet.sh b/planet.sh
index 4b10ef02..6f0a8756 100755
--- a/planet.sh
+++ b/planet.sh
@@ -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 <
/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 "
+ log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a --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 查看日志"
diff --git a/pyproject.toml b/pyproject.toml
index 465a631c..a7ce40b2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planet"
-version = "0.33.0"
+version = "0.34.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [
diff --git a/uv.lock b/uv.lock
index 62571cb4..827e8a57 100644
--- a/uv.lock
+++ b/uv.lock
@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
-version = "0.33.0"
+version = "0.34.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },