release: bump version to 0.60.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
rayd1o
2026-05-17 02:50:42 +08:00
parent 9b913a3b83
commit 81970a1d05
43 changed files with 3378 additions and 463 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.59.0",
"version": "0.60.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -1290,6 +1290,53 @@
gap: 10px;
}
.earth-mobile-settings-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.earth-mobile-settings-tabs::-webkit-scrollbar {
display: none;
}
.earth-mobile-settings-tab {
flex: 0 0 auto;
min-width: 52px;
border: 1px solid rgba(212, 227, 244, 0.1);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
rgba(255, 255, 255, 0.025);
color: var(--hud-text-soft);
padding: 9px 14px;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.earth-mobile-settings-tab:hover {
color: var(--hud-text);
transform: translateY(-1px);
}
.earth-mobile-settings-tab.is-active {
color: var(--hud-title);
border-color: rgba(122, 180, 255, 0.28);
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.18), transparent 58%),
linear-gradient(180deg, rgba(122, 180, 255, 0.18), rgba(82, 123, 186, 0.24));
}
.earth-mobile-settings-card {
display: flex;
align-items: center;
@@ -2164,6 +2211,52 @@ label.is-disabled.earth-mobile-settings-card {
gap: var(--hud-gap-md);
}
.earth-settings-tabs {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: calc(6px * var(--settings-scale));
padding: calc(2px * var(--settings-scale)) 0 calc(4px * var(--settings-scale));
}
.earth-settings-tab {
min-width: 0;
border: 1px solid rgba(212, 227, 244, 0.1);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
rgba(255, 255, 255, 0.025);
color: var(--hud-text-soft);
padding: calc(7px * var(--settings-scale)) calc(10px * var(--settings-scale));
font: inherit;
font-size: calc(0.68rem * var(--hud-scale));
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.earth-settings-tab:hover {
color: var(--hud-text);
transform: translateY(-1px);
}
.earth-settings-tab.is-active {
color: var(--hud-title);
border-color: rgba(122, 180, 255, 0.28);
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%),
linear-gradient(180deg, rgba(121, 159, 207, 0.22), rgba(72, 101, 139, 0.28));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 18px rgba(0, 0, 0, 0.16);
}
.earth-settings-kicker {
color: var(--hud-text-soft);
font-size: calc(0.72rem * var(--hud-scale));
@@ -2193,6 +2286,8 @@ label.is-disabled.earth-mobile-settings-card {
}
.earth-settings-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-inline: 8px;
padding-right: 10px;
@@ -2656,6 +2751,154 @@ label.is-disabled.earth-mobile-settings-card {
pointer-events: none;
}
.earth-shortcut-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.earth-shortcut-category {
margin-top: 4px;
color: var(--hud-text-soft);
font-size: calc(0.62rem * var(--hud-scale));
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.earth-shortcut-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 10px;
padding: 9px 10px;
border: 1px solid rgba(212, 227, 244, 0.08);
background: rgba(255, 255, 255, 0.025);
}
.earth-shortcut-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.earth-shortcut-label {
color: var(--hud-text);
font-size: calc(0.72rem * var(--hud-scale));
font-weight: 700;
}
.earth-shortcut-alias {
color: var(--hud-text-muted);
font-size: calc(0.62rem * var(--hud-scale));
line-height: 1.3;
}
.earth-shortcut-key {
min-width: calc(68px * var(--hud-scale));
border: 1px solid rgba(122, 180, 255, 0.24);
border-radius: calc(6px * var(--hud-scale));
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.16), transparent 58%),
linear-gradient(180deg, rgba(121, 159, 207, 0.16), rgba(72, 101, 139, 0.2));
color: var(--hud-title);
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
font: inherit;
font-size: calc(0.68rem * var(--hud-scale));
font-weight: 800;
cursor: pointer;
}
.earth-shortcut-key.is-capturing {
border-color: rgba(110, 226, 188, 0.52);
color: #d8fff3;
}
.earth-shortcut-reset {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(28px * var(--hud-scale));
height: calc(28px * var(--hud-scale));
border: 1px solid rgba(212, 227, 244, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: var(--hud-text-soft);
cursor: pointer;
}
.earth-shortcut-enable {
position: relative;
display: inline-flex;
width: calc(34px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
cursor: pointer;
}
.earth-shortcut-enable input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.earth-shortcut-enable .earth-settings-switch-track {
width: 100%;
height: 100%;
}
.earth-shortcut-enable .earth-settings-switch-track::after {
width: calc(14px * var(--hud-scale));
height: calc(14px * var(--hud-scale));
}
.earth-shortcut-enable input:checked + .earth-settings-switch-track {
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
border-color: rgba(223, 236, 252, 0.28);
}
.earth-shortcut-enable input:checked + .earth-settings-switch-track::after {
transform: translate(calc(14px * var(--hud-scale)), -50%);
}
.earth-shortcut-reset .material-symbols-rounded {
font-size: calc(0.9rem * var(--hud-scale));
}
.earth-settings-shortcut-actions {
display: flex;
justify-content: flex-start;
}
.earth-mobile-settings-card .earth-shortcut-category {
font-size: 0.68rem;
}
.earth-mobile-settings-card .earth-shortcut-row {
grid-template-columns: minmax(0, 1fr) auto auto auto;
padding: 10px;
}
.earth-mobile-settings-card .earth-shortcut-label {
font-size: 0.82rem;
}
.earth-mobile-settings-card .earth-shortcut-alias {
font-size: 0.72rem;
}
.earth-mobile-settings-card .earth-shortcut-key {
min-width: 70px;
padding: 8px 10px;
font-size: 0.78rem;
}
.earth-mobile-settings-card .earth-shortcut-reset {
width: 32px;
height: 32px;
}
.hud-panel-motion-debug {
position: absolute;
right: calc(24px * var(--hud-scale));

View File

@@ -92,6 +92,16 @@
object-fit: contain;
}
.hud-panel-brand .earth-brand__title-text {
color: var(--hud-text);
font-size: calc(1rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.18;
font-weight: 700;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.hud-panel-brand .earth-brand__meta {
display: flex;
flex-direction: column;

View File

@@ -849,8 +849,16 @@
<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-tabs" role="tablist" aria-label="移动端设置分类">
<button type="button" class="earth-mobile-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="runtime">
<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>
@@ -877,8 +885,8 @@
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">视图</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="display" hidden>
<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>
@@ -950,10 +958,115 @@
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label
class="earth-mobile-settings-card"
data-gatekeeper-permission="earth.motion_debug"
>
<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 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 class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label" data-boundary-precision-status>国界精度</span>
<span class="earth-mobile-settings-subtitle" data-boundary-precision-detail>正在读取高清国界状态...</span>
</div>
<div class="earth-boundary-progress" data-boundary-precision-progress-wrap hidden>
<div class="earth-boundary-progress__track">
<div class="earth-boundary-progress__bar" data-boundary-precision-progress-bar></div>
</div>
<span class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
</div>
<div class="earth-settings-boundary-action-row">
<div class="earth-mobile-settings-segmented earth-mobile-settings-segmented--boundary" role="group" aria-label="移动端选择国界精度">
<button type="button" class="earth-mobile-settings-pill is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
<button type="button" class="earth-mobile-settings-pill" data-boundary-precision-build aria-pressed="false">高精</button>
</div>
<button type="button" class="earth-settings-reload-action" data-boundary-precision-rebuild aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
<span class="material-symbols-rounded">refresh</span>
</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="panels" hidden>
<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-settings-panel="layer-toggles" checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<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-settings-panel="legend">
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<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-settings-panel="earth-stats">
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<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">控制 Live 新闻面板显示</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-settings-panel="media-panel">
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="motion" hidden>
<div class="earth-mobile-settings-title">动捕</div>
<label
class="earth-mobile-settings-card"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">动捕调试模式</span>
<span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span>
@@ -976,49 +1089,21 @@
<button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="shortcuts" hidden>
<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>
<span class="earth-mobile-settings-label">键盘控制</span>
<span class="earth-mobile-settings-subtitle">点击按键后按下新的快捷键Esc 取消录入</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 class="earth-shortcut-list" data-shortcut-list></div>
<div class="earth-mobile-settings-actions">
<button class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button" data-shortcut-reset-all>恢复默认快捷键</button>
</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-group" data-settings-tab-panel="system" hidden>
<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>
@@ -1100,9 +1185,17 @@
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="earth-settings-tabs" role="tablist" aria-label="设置分类">
<button type="button" class="earth-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
<button type="button" class="earth-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
<button type="button" class="earth-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
<button type="button" class="earth-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
<button type="button" class="earth-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
<button type="button" class="earth-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
</div>
<div class="earth-settings-content hud-panel__body">
<section class="earth-settings-section">
<div class="earth-settings-section-title">旋转</div>
<section class="earth-settings-section" data-settings-tab-panel="runtime">
<div class="earth-settings-section-title">运行</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
@@ -1194,8 +1287,8 @@
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<section class="earth-settings-section" data-settings-tab-panel="display" hidden>
<div class="earth-settings-section-title">显示</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
@@ -1303,33 +1396,70 @@
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label
class="earth-settings-item"
for="toggle-motion-debug"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">动捕调试模式</span>
<span class="earth-settings-item-subtitle">打开骨架连线面板;未匹配为红线,匹配动作后变绿</span>
<span class="earth-settings-item-title">地球默认大小</span>
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<div
class="earth-settings-item earth-settings-item--stacked"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">动捕输入源</span>
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaSMotion Agent 适合双摄、RTSP/HTTP 和客户端</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
<div class="earth-settings-slider-row">
<input
id="default-earth-size-slider"
class="earth-settings-slider"
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
aria-label="调整地球默认大小"
>
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">地形透明度</span>
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
</div>
<div class="earth-settings-slider-row">
<input
id="terrain-opacity-slider"
class="earth-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
aria-label="调整地形透明度"
>
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title" id="boundary-precision-status" data-boundary-precision-status>国界精度</span>
<span class="earth-settings-item-subtitle" id="boundary-precision-detail" data-boundary-precision-detail>正在读取高清国界状态...</span>
</div>
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" data-boundary-precision-progress-wrap hidden>
<div class="earth-boundary-progress__track">
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar" data-boundary-precision-progress-bar></div>
</div>
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
</div>
<div class="earth-settings-boundary-action-row">
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" data-boundary-precision-build aria-pressed="false">高精</button>
</div>
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" data-boundary-precision-rebuild aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
<span class="material-symbols-rounded">refresh</span>
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section" data-settings-tab-panel="panels" hidden>
<div class="earth-settings-section-title">面板</div>
<div class="earth-settings-list">
<label class="earth-settings-item" for="toggle-view-layers">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">图层控制</span>
@@ -1372,81 +1502,54 @@
</label>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<section class="earth-settings-section" data-settings-tab-panel="motion" hidden>
<div class="earth-settings-section-title">动捕</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<label
class="earth-settings-item"
for="toggle-motion-debug"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">地球默认大小</span>
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
<span class="earth-settings-item-title">动捕调试模式</span>
<span class="earth-settings-item-subtitle">打开骨架连线面板;未匹配为红线,匹配动作后变绿</span>
</div>
<div class="earth-settings-slider-row">
<input
id="default-earth-size-slider"
class="earth-settings-slider"
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
aria-label="调整地球默认大小"
>
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
<span class="earth-settings-switch">
<input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<div
class="earth-settings-item earth-settings-item--stacked"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">动捕输入源</span>
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaSMotion Agent 适合双摄、RTSP/HTTP 和客户端</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">地形</div>
<section class="earth-settings-section" data-settings-tab-panel="shortcuts" hidden>
<div class="earth-settings-section-title">快捷键</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">地形透明度</span>
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
<span class="earth-settings-item-title">键盘控制</span>
<span class="earth-settings-item-subtitle">点击按键后按下新的快捷键Esc 取消录入,冲突快捷键不会保存</span>
</div>
<div class="earth-settings-slider-row">
<input
id="terrain-opacity-slider"
class="earth-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
aria-label="调整地形透明度"
>
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
<div class="earth-shortcut-list" data-shortcut-list></div>
<div class="earth-settings-shortcut-actions">
<button class="earth-settings-reload-action earth-settings-reload-action--text" type="button" data-shortcut-reset-all>恢复默认</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">国界精度</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title" id="boundary-precision-status">国界精度</span>
<span class="earth-settings-item-subtitle" id="boundary-precision-detail">正在读取高清国界状态...</span>
</div>
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" hidden>
<div class="earth-boundary-progress__track">
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar"></div>
</div>
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value">0%</span>
</div>
<div class="earth-settings-boundary-action-row">
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" aria-pressed="true">低精</button>
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" aria-pressed="false">高精</button>
</div>
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
<span class="material-symbols-rounded">refresh</span>
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<section class="earth-settings-section" data-settings-tab-panel="system" hidden>
<div class="earth-settings-section-title">系统</div>
<div class="earth-settings-list">
<a

View File

@@ -1,57 +1,110 @@
const DEFAULT_BRAND_LANGUAGE = "zh";
const EARTH_BRAND_API = "/api/v1/earth/brand";
const BRANDS = {
zh: {
ariaLabel: "智能星球计划品牌标识",
titleAlt: "智能星球计划",
titleSrc: "assets/brand/title-zh.png",
logoSrc: "/earth/assets/brand/earth-logo.png",
titleSrc: "/earth/assets/brand/title-zh.png",
titleText: "智能星球计划",
subtitle: "现实层宇宙全息感知系统",
description: "卫星 · 海底光缆 · 算力基础设施",
},
en: {
ariaLabel: "Intelligent Planet Program brand banner",
titleAlt: "Intelligent Planet Program",
titleSrc: "assets/brand/title-en.png",
logoSrc: "/earth/assets/brand/earth-logo.png",
titleSrc: "/earth/assets/brand/title-en.png",
titleText: "Intelligent Planet Program",
subtitle: "Physical-Universe Holography",
description: "Satellites · Cables · Compute Infra",
},
};
function getBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
export function getDefaultBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
return BRANDS[variant] ?? BRANDS[DEFAULT_BRAND_LANGUAGE];
}
export function renderBrand(variant = DEFAULT_BRAND_LANGUAGE) {
const config = getBrandConfig(variant);
function escapeHtml(value = "") {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function normalizeBrandConfig(config = {}, variant = DEFAULT_BRAND_LANGUAGE) {
const defaults = getDefaultBrandConfig(variant);
const normalized = {
...defaults,
...config,
ariaLabel: config.aria_label ?? config.ariaLabel ?? defaults.ariaLabel,
titleAlt: config.title_alt ?? config.titleAlt ?? defaults.titleAlt,
logoSrc: config.logo_src ?? config.logoSrc ?? defaults.logoSrc,
titleSrc: config.title_src ?? config.titleSrc ?? defaults.titleSrc,
titleText: config.title_text ?? config.titleText ?? defaults.titleText,
};
if (!normalized.titleText) normalized.titleText = defaults.titleText;
if (!normalized.ariaLabel) normalized.ariaLabel = normalized.titleText;
if (!normalized.titleAlt) normalized.titleAlt = normalized.titleText;
return normalized;
}
export function renderBrand(configOrVariant = DEFAULT_BRAND_LANGUAGE) {
const variant =
typeof configOrVariant === "string"
? configOrVariant
: configOrVariant.variant ?? DEFAULT_BRAND_LANGUAGE;
const config =
typeof configOrVariant === "string"
? normalizeBrandConfig({}, variant)
: normalizeBrandConfig(configOrVariant, variant);
const titleMarkup = config.titleSrc
? `
<img
class="earth-brand__title"
src="${escapeHtml(config.titleSrc)}"
alt="${escapeHtml(config.titleAlt)}"
fetchpriority="high"
loading="eager"
>
`
: `<div class="earth-brand__title-text">${escapeHtml(config.titleText)}</div>`;
return `
<div class="earth-brand earth-brand--${variant}" aria-label="${config.ariaLabel}">
<div class="earth-brand earth-brand--${escapeHtml(variant)}" aria-label="${escapeHtml(config.ariaLabel)}">
<img
class="earth-brand__logo"
src="assets/brand/earth-logo.png"
src="${escapeHtml(config.logoSrc)}"
alt=""
aria-hidden="true"
fetchpriority="high"
loading="eager"
>
<div class="earth-brand__copy">
<img
class="earth-brand__title"
src="${config.titleSrc}"
alt="${config.titleAlt}"
fetchpriority="high"
loading="eager"
>
${titleMarkup}
<div class="earth-brand__meta">
<span class="earth-brand__subtitle">${config.subtitle}</span>
<span class="earth-brand__description">${config.description}</span>
<span class="earth-brand__subtitle">${escapeHtml(config.subtitle)}</span>
<span class="earth-brand__description">${escapeHtml(config.description)}</span>
</div>
</div>
</div>
`.trim();
}
export function mountBrand(target, variant = DEFAULT_BRAND_LANGUAGE) {
export function mountBrand(target, configOrVariant = DEFAULT_BRAND_LANGUAGE) {
if (!target) return;
target.innerHTML = renderBrand(variant);
target.innerHTML = renderBrand(configOrVariant);
}
export async function fetchEarthBrandConfig() {
const response = await fetch(EARTH_BRAND_API, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Earth brand config request failed: ${response.status}`);
}
const payload = await response.json();
return payload?.brand ?? getDefaultBrandConfig();
}

View File

@@ -153,7 +153,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 11;
const EARTH_SETTINGS_VERSION = 13;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
@@ -163,8 +163,14 @@ const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
const VISUAL_PREFERENCES_DEFAULT_VERSION = 10;
const SURFACE_HOVER_INFO_DEFAULT_VERSION = 11;
const KEYBOARD_SHORTCUTS_DEFAULT_VERSION = 13;
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
const KEYBOARD_ROTATION_ACCELERATION = 3.2;
const KEYBOARD_ROTATION_MAX_SPEED = 2.8;
const KEYBOARD_ROTATION_FRICTION = 5.4;
const KEYBOARD_ROTATION_STOP_SPEED = 0.012;
const KEYBOARD_ZOOM_STEP = 0.1;
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
const TARGET_SWITCH_ROTATE_PHASE = 0.5;
let settingsModalTimer = null;
@@ -186,6 +192,13 @@ let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
let toolbarHubController = null;
let keyboardShortcuts = {};
let capturingShortcutActionId = null;
let keyboardRotationFrameId = null;
let keyboardRotationLastFrameAt = 0;
let keyboardRotationOriginalAutoRotate = null;
const activeKeyboardRotationActions = new Set();
const keyboardRotationVelocity = { x: 0, y: 0 };
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
@@ -202,6 +215,36 @@ const CRUISE_MODULE_LABELS = {
[CRUISE_MODULES.SATELLITES]: "卫星",
};
const KEYBOARD_SHORTCUT_DEFINITIONS = [
{ id: "rotateUp", label: "向上旋转", category: "视角控制", defaultBinding: "W", aliases: ["ArrowUp"] },
{ id: "rotateLeft", label: "向左旋转", category: "视角控制", defaultBinding: "A", aliases: ["ArrowLeft"] },
{ id: "rotateDown", label: "向下旋转", category: "视角控制", defaultBinding: "S", aliases: ["ArrowDown"] },
{ id: "rotateRight", label: "向右旋转", category: "视角控制", defaultBinding: "D", aliases: ["ArrowRight"] },
{ id: "zoomIn", label: "放大", category: "视角控制", defaultBinding: "Plus", aliases: ["="] },
{ id: "zoomOut", label: "缩小", category: "视角控制", defaultBinding: "-", aliases: ["_"] },
{ id: "closeFocus", label: "关闭当前焦点菜单", category: "工具", defaultBinding: "Escape" },
{ id: "openSearch", label: "打开搜索", category: "工具", defaultBinding: "F" },
{ id: "resetView", label: "重置视角", category: "工具", defaultBinding: "R" },
{ id: "toggleLayerPanel", label: "打开/关闭图层面板", category: "工具", defaultBinding: "L" },
{ id: "toggleLayoutExpanded", label: "最大化布局", category: "工具", defaultBinding: "M" },
{ id: "toggleMediaPanel", label: "打开/关闭新闻直播", category: "工具", defaultBinding: "Ctrl+M" },
{ id: "toggleAutoRotate", label: "暂停/恢复旋转", category: "工具", defaultBinding: "Space" },
{ id: "toggleLayer:cables", label: "切换海缆", category: "图层", defaultBinding: "Ctrl+1" },
{ id: "toggleLayer:satellites", label: "切换卫星", category: "图层", defaultBinding: "Ctrl+2" },
{ id: "toggleLayer:computeCenters", label: "切换算力中心", category: "图层", defaultBinding: "Ctrl+3" },
{ id: "toggleLayer:vessels", label: "切换船只", category: "图层", defaultBinding: "Ctrl+4" },
{ id: "toggleLayer:bgp", label: "切换 BGP观测", category: "图层", defaultBinding: "Ctrl+5" },
{ id: "toggleLayer:terrain", label: "切换地形", category: "图层", defaultBinding: "Ctrl+6" },
{ id: "toggleLayer:earthHighResTexture", label: "切换高清材质", category: "图层", defaultBinding: "Ctrl+7" },
{ id: "toggleLayer:atmosphereClouds", label: "切换大气云图", category: "图层", defaultBinding: "Ctrl+8" },
{ id: "toggleLayer:countryBoundaries", label: "切换国界线", category: "图层", defaultBinding: "Ctrl+9" },
{ id: "toggleLayer:gridLines", label: "切换经纬线", category: "图层", defaultBinding: "Ctrl+0" },
];
const KEYBOARD_SHORTCUT_DEFINITION_BY_ID = new Map(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition]),
);
function normalizeMediaPanelActiveTab(tab) {
return tab === "news" ? "news" : "live";
}
@@ -212,6 +255,431 @@ function normalizeSurfaceHoverInfoMode(mode) {
: DEFAULT_SURFACE_HOVER_INFO_MODE;
}
function getDefaultKeyboardShortcuts() {
return Object.fromEntries(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [
definition.id,
{
binding: definition.defaultBinding,
enabled: true,
},
]),
);
}
function normalizeShortcutBinding(binding) {
if (typeof binding !== "string") return "";
if (binding.trim() === "+") return "+";
const parts = binding
.split("+")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) return "";
const key = parts[parts.length - 1];
const modifiers = new Set(
parts.slice(0, -1).map((part) => {
const normalized = part.toLowerCase();
if (normalized === "control") return "Ctrl";
if (normalized === "cmd" || normalized === "command") return "Meta";
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}),
);
const orderedModifiers = ["Ctrl", "Alt", "Shift", "Meta"].filter((modifier) =>
modifiers.has(modifier),
);
return [...orderedModifiers, normalizeShortcutKeyName(key)].join("+");
}
function normalizeShortcutKeyName(key) {
if (!key) return "";
if (key === " ") return "Space";
if (key === "+") return "Plus";
if (key === "_") return "-";
if (key.length === 1) return key.toUpperCase();
const lowered = key.toLowerCase();
if (lowered === "esc") return "Escape";
if (lowered === "spacebar") return "Space";
if (lowered.startsWith("arrow")) {
return `Arrow${lowered.slice(5, 6).toUpperCase()}${lowered.slice(6)}`;
}
return key.charAt(0).toUpperCase() + key.slice(1);
}
function normalizeKeyboardShortcuts(rawShortcuts = {}) {
const defaults = getDefaultKeyboardShortcuts();
const source =
rawShortcuts && typeof rawShortcuts === "object"
? rawShortcuts
: {};
return Object.fromEntries(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => {
const rawShortcut = source[definition.id] || {};
const binding = normalizeShortcutBinding(rawShortcut.binding);
return [
definition.id,
{
binding: binding || defaults[definition.id].binding,
enabled:
typeof rawShortcut.enabled === "boolean"
? rawShortcut.enabled
: defaults[definition.id].enabled,
},
];
}),
);
}
function getShortcutDisplayLabel(binding) {
const normalized = normalizeShortcutBinding(binding);
if (!normalized) return "未设置";
return normalized
.replaceAll("ArrowUp", "↑")
.replaceAll("ArrowDown", "↓")
.replaceAll("ArrowLeft", "←")
.replaceAll("ArrowRight", "→")
.replaceAll("Space", "空格")
.replaceAll("Escape", "Esc")
.replaceAll("Plus", "+")
.replaceAll("Ctrl", "Ctrl")
.replaceAll("Meta", "⌘");
}
function getShortcutAliasesLabel(definition) {
const aliases = Array.isArray(definition?.aliases) ? definition.aliases : [];
if (aliases.length === 0) return "";
return `备用:${aliases.map(getShortcutDisplayLabel).join(" / ")}`;
}
function getShortcutForAction(actionId) {
return keyboardShortcuts[actionId] || getDefaultKeyboardShortcuts()[actionId] || {
binding: "",
enabled: false,
};
}
function getShortcutOwnerByBinding(binding, { excludeActionId = null } = {}) {
const normalized = normalizeShortcutBinding(binding);
if (!normalized) return null;
for (const definition of KEYBOARD_SHORTCUT_DEFINITIONS) {
if (definition.id === excludeActionId) continue;
const shortcut = getShortcutForAction(definition.id);
if (!shortcut.enabled) continue;
const bindings = [
shortcut.binding,
...(Array.isArray(definition.aliases) ? definition.aliases : []),
].map(normalizeShortcutBinding);
if (bindings.includes(normalized)) {
return definition;
}
}
return null;
}
function getShortcutChordFromEvent(event) {
if (!(event instanceof KeyboardEvent)) return "";
let key = event.key;
if (!key || key === "Unidentified" || key === "Dead") return "";
if (key === " ") key = "Space";
const ignoreShift = key === "+" || key === "_";
if (key.length === 1) key = key.toUpperCase();
const modifiers = [];
if (event.ctrlKey) modifiers.push("Ctrl");
if (event.altKey) modifiers.push("Alt");
if (event.shiftKey && key.length !== 1 && !ignoreShift) modifiers.push("Shift");
if (event.metaKey) modifiers.push("Meta");
return [...modifiers, normalizeShortcutKeyName(key)].join("+");
}
function isEditableShortcutTarget(target) {
if (!(target instanceof Element)) return false;
if (target.closest("[data-shortcut-capture]")) return false;
return Boolean(
target.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']"),
);
}
function isShortcutSuppressedBySettingsUi(target) {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("#settings-modal, .earth-mobile-page--settings"));
}
function getShortcutDefinitionForChord(chord) {
const normalizedChord = normalizeShortcutBinding(chord);
if (!normalizedChord) return null;
for (const definition of KEYBOARD_SHORTCUT_DEFINITIONS) {
const shortcut = getShortcutForAction(definition.id);
if (!shortcut.enabled) continue;
const bindings = [
shortcut.binding,
...(Array.isArray(definition.aliases) ? definition.aliases : []),
].map(normalizeShortcutBinding);
if (bindings.includes(normalizedChord)) {
return definition;
}
}
return null;
}
function closeCurrentFocusOverlay() {
if (capturingShortcutActionId) {
capturingShortcutActionId = null;
syncShortcutCaptureUi();
return true;
}
if (isSearchPanelOpen()) {
closeSearchPanel();
return true;
}
if (isSettingsModalOpen()) {
closeSettingsModal();
return true;
}
if (isMobileLayout() && mobileDrawerOpen) {
setMobileDrawerState({ open: false });
return true;
}
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return true;
}
if (toolbarHubController?.isOpen?.()) {
toolbarHubController.close();
return true;
}
clearLockedObjectAndInfo();
return true;
}
function isKeyboardRotationAction(actionId) {
return (
actionId === "rotateUp" ||
actionId === "rotateDown" ||
actionId === "rotateLeft" ||
actionId === "rotateRight"
);
}
function getKeyboardRotationDirection() {
const direction = { x: 0, y: 0 };
if (activeKeyboardRotationActions.has("rotateUp")) direction.x -= 1;
if (activeKeyboardRotationActions.has("rotateDown")) direction.x += 1;
if (activeKeyboardRotationActions.has("rotateLeft")) direction.y -= 1;
if (activeKeyboardRotationActions.has("rotateRight")) direction.y += 1;
const magnitude = Math.hypot(direction.x, direction.y);
if (magnitude > 1) {
direction.x /= magnitude;
direction.y /= magnitude;
}
return direction;
}
function clampKeyboardRotationVelocity() {
const speed = Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y);
if (speed <= KEYBOARD_ROTATION_MAX_SPEED) return;
const scale = KEYBOARD_ROTATION_MAX_SPEED / speed;
keyboardRotationVelocity.x *= scale;
keyboardRotationVelocity.y *= scale;
}
function applyKeyboardRotationFrame(timestamp) {
keyboardRotationFrameId = null;
if (!earthObj) {
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
return;
}
const deltaSeconds = keyboardRotationLastFrameAt
? Math.min((timestamp - keyboardRotationLastFrameAt) / 1000, 0.05)
: 0.016;
keyboardRotationLastFrameAt = timestamp;
const direction = getKeyboardRotationDirection();
const hasInput = direction.x !== 0 || direction.y !== 0;
if (hasInput) {
keyboardRotationVelocity.x += direction.x * KEYBOARD_ROTATION_ACCELERATION * deltaSeconds;
keyboardRotationVelocity.y += direction.y * KEYBOARD_ROTATION_ACCELERATION * deltaSeconds;
clampKeyboardRotationVelocity();
} else {
const decay = Math.exp(-KEYBOARD_ROTATION_FRICTION * deltaSeconds);
keyboardRotationVelocity.x *= decay;
keyboardRotationVelocity.y *= decay;
}
const speed = Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y);
if (!hasInput && speed < KEYBOARD_ROTATION_STOP_SPEED) {
keyboardRotationVelocity.x = 0;
keyboardRotationVelocity.y = 0;
restoreKeyboardRotationAutoRotate();
keyboardRotationLastFrameAt = 0;
return;
}
earthObj.rotation.x = THREE.MathUtils.clamp(
earthObj.rotation.x + keyboardRotationVelocity.x * deltaSeconds,
-Math.PI / 2,
Math.PI / 2,
);
earthObj.rotation.y += keyboardRotationVelocity.y * deltaSeconds;
keyboardRotationFrameId = window.requestAnimationFrame(applyKeyboardRotationFrame);
}
function ensureKeyboardRotationFrame() {
if (keyboardRotationFrameId !== null) return;
keyboardRotationFrameId = window.requestAnimationFrame(applyKeyboardRotationFrame);
}
function restoreKeyboardRotationAutoRotate() {
if (keyboardRotationOriginalAutoRotate === true && !autoRotate) {
setAutoRotate(true);
}
keyboardRotationOriginalAutoRotate = null;
}
function startKeyboardRotationControl(actionId) {
if (!earthObj || !isKeyboardRotationAction(actionId)) return;
if (keyboardRotationOriginalAutoRotate === null) {
keyboardRotationOriginalAutoRotate = autoRotate;
}
if (autoRotate) {
setAutoRotate(false);
}
activeKeyboardRotationActions.add(actionId);
clearLockedObject();
ensureKeyboardRotationFrame();
}
function stopKeyboardRotationControl({ actionId = null, restoreAutoRotate = false, clearVelocity = false } = {}) {
if (actionId) {
activeKeyboardRotationActions.delete(actionId);
} else {
activeKeyboardRotationActions.clear();
}
if (clearVelocity) {
keyboardRotationVelocity.x = 0;
keyboardRotationVelocity.y = 0;
}
if (restoreAutoRotate && activeKeyboardRotationActions.size === 0) {
restoreKeyboardRotationAutoRotate();
}
if (clearVelocity && keyboardRotationFrameId !== null) {
window.cancelAnimationFrame(keyboardRotationFrameId);
keyboardRotationFrameId = null;
keyboardRotationLastFrameAt = 0;
} else if (activeKeyboardRotationActions.size === 0 && Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y) > 0) {
ensureKeyboardRotationFrame();
}
}
function applyKeyboardZoom(direction) {
setZoomLevel(zoomLevel + direction * KEYBOARD_ZOOM_STEP, activeCamera);
showZoomStatusCapsule({ force: true });
}
function openSearchFromShortcut() {
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "search" });
return;
}
closeTransientMobileOverlays({ except: "search" });
openSearchPanel();
}
function toggleLayerPanelFromShortcut() {
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);
}
function toggleMediaPanelFromShortcut() {
if (isMobileLayout()) {
const nextOpen = !(mobileDrawerOpen && (mobileDrawerCard === "tv" || mobileDrawerCard === "news"));
setMobileDrawerState({ open: nextOpen, card: "tv" });
return;
}
const nextVisible = !isTVPanelVisible();
setTVPanelVisible(nextVisible);
}
function toggleLayoutExpandedFromShortcut() {
const container = document.getElementById("container");
if (!(container instanceof HTMLElement)) return;
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
}
async function toggleLayerFromShortcut(layerId) {
const definition = getLayerDefinition(layerId);
if (!definition) return;
const button = getLayerButton(layerId);
if (button?.disabled || button?.classList.contains("is-disabled")) {
showStatusMessage(`${definition.label}当前不可用`, "warning");
return;
}
await definition.setVisible(!definition.getVisible());
showStatusMessage(`${definition.label}${definition.getVisible() ? "已显示" : "已隐藏"}`, "info");
}
function executeKeyboardShortcut(actionId) {
if (actionId === "closeFocus") {
closeCurrentFocusOverlay();
return;
}
if (isKeyboardRotationAction(actionId)) {
startKeyboardRotationControl(actionId);
return;
}
if (actionId === "zoomIn") {
applyKeyboardZoom(1);
return;
}
if (actionId === "zoomOut") {
applyKeyboardZoom(-1);
return;
}
if (actionId === "openSearch") {
openSearchFromShortcut();
return;
}
if (actionId === "resetView") {
resetView(activeCamera);
return;
}
if (actionId === "toggleLayerPanel") {
toggleLayerPanelFromShortcut();
return;
}
if (actionId === "toggleLayoutExpanded") {
toggleLayoutExpandedFromShortcut();
return;
}
if (actionId === "toggleMediaPanel") {
toggleMediaPanelFromShortcut();
return;
}
if (actionId === "toggleAutoRotate") {
const isRotating = toggleAutoRotate();
showStatusMessage(isRotating ? "旋转已恢复" : "旋转已暂停", "info");
return;
}
if (actionId.startsWith("toggleLayer:")) {
void toggleLayerFromShortcut(actionId.slice("toggleLayer:".length));
}
}
function detectLayoutMode() {
const width = window.innerWidth;
const height = window.innerHeight;
@@ -819,6 +1287,7 @@ function getCurrentSharedSettingsSnapshot() {
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
surfaceHoverInfoMode: getSurfaceHoverInfoMode(),
keyboardShortcuts: normalizeKeyboardShortcuts(keyboardShortcuts),
};
}
@@ -879,6 +1348,7 @@ function cloneEarthSettings(settings) {
surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode(
settings.shared.surfaceHoverInfoMode,
),
keyboardShortcuts: normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts),
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
views: {
@@ -1017,6 +1487,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
(rawSettings?.version || 0) >= SURFACE_HOVER_INFO_DEFAULT_VERSION
? normalizeSurfaceHoverInfoMode(sharedSettings?.surfaceHoverInfoMode)
: defaults.shared.surfaceHoverInfoMode;
const nextKeyboardShortcuts =
(rawSettings?.version || 0) >= KEYBOARD_SHORTCUTS_DEFAULT_VERSION
? normalizeKeyboardShortcuts(sharedSettings?.keyboardShortcuts)
: defaults.shared.keyboardShortcuts;
const nextTrailsEnabled = typeof sharedSettings?.trailsEnabled === "boolean"
? sharedSettings.trailsEnabled
: typeof inputLayerVisibility.trails === "boolean"
@@ -1046,6 +1520,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
surfaceHoverInfoMode: nextSurfaceHoverInfoMode,
keyboardShortcuts: nextKeyboardShortcuts,
},
views: {
desktop: {
@@ -1550,6 +2025,8 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
suppressStatus: true,
});
setActiveTVTab(settings.shared.mediaPanelActiveTab);
keyboardShortcuts = normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts);
renderShortcutSettings();
if (!applyLayers) {
const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
@@ -2659,12 +3136,14 @@ function applyCurrentViewportPanelVisibility({ persist = false } = {}) {
}
function syncSettingsToggle(panelId, visible) {
const input = document.querySelector(
const inputs = document.querySelectorAll(
`[data-settings-panel="${panelId}"]`,
);
if (input instanceof HTMLInputElement) {
input.checked = visible;
}
inputs.forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = visible;
}
});
}
function syncAllHudPanelToggles() {
@@ -2717,14 +3196,14 @@ export function setDayNightInteractable(enabled) {
function getBoundaryPrecisionEls() {
return {
status: document.getElementById("boundary-precision-status"),
detail: document.getElementById("boundary-precision-detail"),
progressWrap: document.getElementById("boundary-precision-progress-wrap"),
progressBar: document.getElementById("boundary-precision-progress-bar"),
progressValue: document.getElementById("boundary-precision-progress-value"),
buildButton: document.getElementById("boundary-precision-build"),
rebuildButton: document.getElementById("boundary-precision-rebuild"),
disableButton: document.getElementById("boundary-precision-disable"),
statuses: Array.from(document.querySelectorAll("[data-boundary-precision-status]")),
details: Array.from(document.querySelectorAll("[data-boundary-precision-detail]")),
progressWraps: Array.from(document.querySelectorAll("[data-boundary-precision-progress-wrap]")),
progressBars: Array.from(document.querySelectorAll("[data-boundary-precision-progress-bar]")),
progressValues: Array.from(document.querySelectorAll("[data-boundary-precision-progress-value]")),
buildButtons: Array.from(document.querySelectorAll("[data-boundary-precision-build]")),
rebuildButtons: Array.from(document.querySelectorAll("[data-boundary-precision-rebuild]")),
disableButtons: Array.from(document.querySelectorAll("[data-boundary-precision-disable]")),
};
}
@@ -2764,45 +3243,48 @@ function renderBoundaryPrecisionStatus(payload = {}) {
? `高清国界更新源未配置完整:${job.message || job.code}`
: `高清国界下载失败:${job.message || job.code || "请检查更新源"}`;
if (els.status) {
els.status.textContent = "国界精度";
}
if (els.detail) {
els.detail.textContent = running
els.statuses.forEach((status) => {
status.textContent = "国界精度";
});
els.details.forEach((detail) => {
detail.textContent = running
? (job.message || "正在准备高清国界")
: failed
? failureMessage
: highReady
? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。")
: "当前使用低精国界;切到高精会下载并构建。";
}
if (els.progressWrap) {
els.progressWrap.hidden = !running;
}
if (els.progressBar) {
els.progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
}
if (els.progressValue) {
els.progressValue.textContent = job.status === "failed"
});
els.progressWraps.forEach((progressWrap) => {
progressWrap.hidden = !running;
});
els.progressBars.forEach((progressBar) => {
progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
});
els.progressValues.forEach((progressValue) => {
progressValue.textContent = job.status === "failed"
? `失败:${job.message || job.code || "构建失败"}`
: `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
}
if (els.buildButton instanceof HTMLButtonElement) {
els.buildButton.disabled = running;
els.buildButton.textContent = "高精";
els.buildButton.classList.toggle("is-active", enabled || running);
els.buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false");
}
if (els.rebuildButton instanceof HTMLButtonElement) {
});
els.buildButtons.forEach((buildButton) => {
if (!(buildButton instanceof HTMLButtonElement)) return;
buildButton.disabled = running;
buildButton.textContent = "高精";
buildButton.classList.toggle("is-active", enabled || running);
buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false");
});
els.rebuildButtons.forEach((rebuildButton) => {
if (!(rebuildButton instanceof HTMLButtonElement)) return;
const shouldShowRebuild = highReady && enabled && !running;
els.rebuildButton.hidden = !shouldShowRebuild;
els.rebuildButton.disabled = !shouldShowRebuild;
}
if (els.disableButton instanceof HTMLButtonElement) {
els.disableButton.disabled = running;
els.disableButton.classList.toggle("is-active", !enabled && !running);
els.disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false");
}
rebuildButton.hidden = !shouldShowRebuild;
rebuildButton.disabled = !shouldShowRebuild;
});
els.disableButtons.forEach((disableButton) => {
if (!(disableButton instanceof HTMLButtonElement)) return;
disableButton.disabled = running;
disableButton.classList.toggle("is-active", !enabled && !running);
disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false");
});
syncSegmentedControlSliders();
}
@@ -2855,7 +3337,7 @@ async function startBoundaryPrecisionBuild() {
async function setupBoundaryPrecisionControls() {
const els = getBoundaryPrecisionEls();
if (!els.buildButton && !els.disableButton) return;
if (els.buildButtons.length === 0 && els.disableButtons.length === 0) return;
try {
const payload = await refreshBoundaryPrecisionStatus();
const jobStatus = payload.current_job?.status;
@@ -2867,8 +3349,9 @@ async function setupBoundaryPrecisionControls() {
showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning");
}
if (els.buildButton instanceof HTMLButtonElement) {
bindListener(els.buildButton, "click", async () => {
els.buildButtons.forEach((buildButton) => {
if (!(buildButton instanceof HTMLButtonElement)) return;
bindListener(buildButton, "click", async () => {
try {
const statusPayload = await refreshBoundaryPrecisionStatus();
const job = statusPayload.job || statusPayload.current_job || {};
@@ -2887,9 +3370,10 @@ async function setupBoundaryPrecisionControls() {
showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning");
}
});
}
if (els.rebuildButton instanceof HTMLButtonElement) {
bindListener(els.rebuildButton, "click", async () => {
});
els.rebuildButtons.forEach((rebuildButton) => {
if (!(rebuildButton instanceof HTMLButtonElement)) return;
bindListener(rebuildButton, "click", async () => {
try {
await startBoundaryPrecisionBuild();
} catch (error) {
@@ -2897,9 +3381,10 @@ async function setupBoundaryPrecisionControls() {
showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning");
}
});
}
if (els.disableButton instanceof HTMLButtonElement) {
bindListener(els.disableButton, "click", async () => {
});
els.disableButtons.forEach((disableButton) => {
if (!(disableButton instanceof HTMLButtonElement)) return;
bindListener(disableButton, "click", async () => {
try {
if (!getHighPrecisionBoundariesEnabled()) return;
setHighPrecisionBoundariesEnabled(false);
@@ -2910,7 +3395,204 @@ async function setupBoundaryPrecisionControls() {
showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning");
}
});
});
}
function setSettingsTab(root, tabId) {
if (!(root instanceof HTMLElement)) return;
const nextTab = tabId || "runtime";
root.querySelectorAll("[data-settings-tab]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const active = button.dataset.settingsTab === nextTab;
button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
root.querySelectorAll("[data-settings-tab-panel]").forEach((panel) => {
if (!(panel instanceof HTMLElement)) return;
panel.hidden = panel.dataset.settingsTabPanel !== nextTab;
});
}
function setupSettingsTabs() {
const roots = document.querySelectorAll(".earth-settings-sheet, .earth-mobile-page--settings");
roots.forEach((root) => {
if (!(root instanceof HTMLElement)) return;
setSettingsTab(root, "runtime");
root.querySelectorAll("[data-settings-tab]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
bindListener(button, "click", () => {
setSettingsTab(root, button.dataset.settingsTab || "runtime");
});
});
});
}
function renderShortcutSettings() {
const lists = document.querySelectorAll("[data-shortcut-list]");
if (lists.length === 0) return;
const groupedDefinitions = KEYBOARD_SHORTCUT_DEFINITIONS.reduce((groups, definition) => {
if (!groups.has(definition.category)) {
groups.set(definition.category, []);
}
groups.get(definition.category).push(definition);
return groups;
}, new Map());
lists.forEach((list) => {
if (!(list instanceof HTMLElement)) return;
list.innerHTML = "";
groupedDefinitions.forEach((definitions, category) => {
const heading = document.createElement("div");
heading.className = "earth-shortcut-category";
heading.textContent = category;
list.appendChild(heading);
definitions.forEach((definition) => {
const shortcut = getShortcutForAction(definition.id);
const row = document.createElement("div");
row.className = "earth-shortcut-row";
row.dataset.shortcutAction = definition.id;
row.innerHTML = `
<div class="earth-shortcut-copy">
<span class="earth-shortcut-label">${definition.label}</span>
<span class="earth-shortcut-alias">${getShortcutAliasesLabel(definition) || " "}</span>
</div>
<button class="earth-shortcut-key" type="button" data-shortcut-capture="${definition.id}">${getShortcutDisplayLabel(shortcut.binding)}</button>
<label class="earth-shortcut-enable" title="启用快捷键">
<input type="checkbox" data-shortcut-enabled="${definition.id}" ${shortcut.enabled ? "checked" : ""}>
<span class="earth-settings-switch-track"></span>
</label>
<button class="earth-shortcut-reset" type="button" data-shortcut-reset="${definition.id}" aria-label="重置${definition.label}" title="重置">
<span class="material-symbols-rounded">restart_alt</span>
</button>
`;
row.classList.toggle("is-disabled", !shortcut.enabled);
list.appendChild(row);
});
});
});
syncShortcutCaptureUi();
}
function syncShortcutCaptureUi() {
document.querySelectorAll("[data-shortcut-capture]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const actionId = button.dataset.shortcutCapture;
const isCapturing = Boolean(actionId && actionId === capturingShortcutActionId);
button.classList.toggle("is-capturing", isCapturing);
if (isCapturing) {
button.textContent = "按键...";
} else if (actionId) {
button.textContent = getShortcutDisplayLabel(getShortcutForAction(actionId).binding);
}
});
}
function setShortcutBinding(actionId, binding, { persist = true } = {}) {
if (!KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has(actionId)) return false;
const normalizedBinding = normalizeShortcutBinding(binding);
if (!normalizedBinding) return false;
const owner = getShortcutOwnerByBinding(normalizedBinding, { excludeActionId: actionId });
if (owner) {
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
return false;
}
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
...nextShortcuts[actionId],
binding: normalizedBinding,
enabled: true,
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
return true;
}
function resetShortcutBinding(actionId, { persist = true } = {}) {
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(actionId);
if (!definition) return;
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
binding: definition.defaultBinding,
enabled: true,
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
}
function setShortcutEnabled(actionId, enabled, { persist = true } = {}) {
if (!KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has(actionId)) return false;
const currentShortcut = getShortcutForAction(actionId);
if (enabled) {
const owner = getShortcutOwnerByBinding(currentShortcut.binding, { excludeActionId: actionId });
if (owner) {
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
renderShortcutSettings();
return false;
}
}
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
...nextShortcuts[actionId],
enabled: Boolean(enabled),
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
return true;
}
function resetAllShortcutBindings() {
keyboardShortcuts = getDefaultKeyboardShortcuts();
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
capturingShortcutActionId = null;
renderShortcutSettings();
persistEarthSettings();
showStatusMessage("快捷键已恢复默认", "info");
}
function setupShortcutSettingsControls() {
document.querySelectorAll("[data-shortcut-list]").forEach((list) => {
bindListener(list, "click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const captureButton = target?.closest("[data-shortcut-capture]");
const resetButton = target?.closest("[data-shortcut-reset]");
if (captureButton instanceof HTMLButtonElement) {
capturingShortcutActionId = captureButton.dataset.shortcutCapture || null;
syncShortcutCaptureUi();
} else if (resetButton instanceof HTMLButtonElement) {
const actionId = resetButton.dataset.shortcutReset;
if (actionId) resetShortcutBinding(actionId);
}
});
bindListener(list, "change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const actionId = target.dataset.shortcutEnabled;
if (!actionId) return;
setShortcutEnabled(actionId, target.checked);
});
});
document.querySelectorAll("[data-shortcut-reset-all]").forEach((button) => {
bindListener(button, "click", () => {
resetAllShortcutBindings();
});
});
renderShortcutSettings();
}
function setupSettingsControls() {
@@ -2936,6 +3618,9 @@ function setupSettingsControls() {
resetEarthSettings();
});
setupSettingsTabs();
setupShortcutSettingsControls();
bindListener(settingsModal, "click", (event) => {
const sheet = event.target.closest(".earth-settings-sheet");
if (!sheet) {
@@ -4008,34 +4693,53 @@ function setupTerrainControls() {
function setupKeyboardControls() {
bindListener(document, "keydown", (event) => {
if (event.key !== "Escape") return;
if (isSearchPanelOpen()) {
closeSearchPanel();
if (capturingShortcutActionId) {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") {
capturingShortcutActionId = null;
syncShortcutCaptureUi();
return;
}
const nextBinding = getShortcutChordFromEvent(event);
if (!nextBinding) return;
if (setShortcutBinding(capturingShortcutActionId, nextBinding)) {
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(capturingShortcutActionId);
showStatusMessage(`${definition?.label || "快捷键"}已设置为 ${getShortcutDisplayLabel(nextBinding)}`, "info");
capturingShortcutActionId = null;
syncShortcutCaptureUi();
}
return;
}
if (isSettingsModalOpen()) {
closeSettingsModal();
const chord = getShortcutChordFromEvent(event);
const definition = getShortcutDefinitionForChord(chord);
if (!definition) return;
if (isEditableShortcutTarget(event.target) && definition.id !== "closeFocus") return;
if (
definition.id !== "closeFocus" &&
(isShortcutSuppressedBySettingsUi(event.target) ||
isSettingsModalOpen() ||
(isMobileLayout() && mobileDrawerOpen && mobileDrawerCard === "settings"))
) {
return;
}
if (isMobileLayout() && mobileDrawerOpen) {
setMobileDrawerState({ open: false });
return;
}
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return;
}
if (toolbarHubController?.isOpen?.()) {
toolbarHubController.close();
return;
}
clearLockedObjectAndInfo();
event.preventDefault();
event.stopPropagation();
executeKeyboardShortcut(definition.id);
});
bindListener(document, "keyup", (event) => {
const chord = getShortcutChordFromEvent(event);
const definition = getShortcutDefinitionForChord(chord);
if (!definition || !isKeyboardRotationAction(definition.id)) return;
stopKeyboardRotationControl({ actionId: definition.id });
});
bindListener(window, "blur", () => {
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
});
cleanupFns.push(() => {
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
});
}

View File

@@ -255,7 +255,7 @@ import {
refreshLegend,
setLegendItems,
} from "./legend.js";
import { mountBrand } from "./brand.js";
import { fetchEarthBrandConfig, mountBrand } from "./brand.js";
import { initTVPanel } from "./tv.js";
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
import { initSearchPanel } from "./search.js";
@@ -3569,6 +3569,13 @@ export function init() {
updateHudScale();
const brandRoot = document.getElementById("brand-root");
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
fetchEarthBrandConfig()
.then((brandConfig) => {
mountBrand(brandRoot, brandConfig);
})
.catch((error) => {
console.warn("Earth brand config unavailable, using defaults.", error);
});
initTVPanel();
initNewsPanel();
initSearchPanel({

View File

@@ -328,7 +328,7 @@ function renderTicker(nextPayload) {
.map((item) => `
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
<span>${escapeTickerText(getNewsDisplayTitle(item))}</span>
<span>${escapeTickerText(getNewsDisplaySummary(item))}</span>
</span>
`)
.join("");
@@ -434,11 +434,12 @@ function renderPayload(nextPayload) {
: "news-story-card";
const title = getNewsDisplayTitle(item);
const summaryText = getNewsDisplaySummary(item);
const leadText = summaryText || title;
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
const feedLabel = getNewsFeedLabel(item.feed_name);
const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status);
const summary = summaryText
? `<div class="news-story-summary">${escapeNewsHtml(summaryText)}</div>`
const summary = title && title !== leadText
? `<div class="news-story-summary">${escapeNewsHtml(title)}</div>`
: "";
return `
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
@@ -446,7 +447,7 @@ function renderPayload(nextPayload) {
<span class="news-story-source">${escapeNewsHtml(item.source || "NEWS")}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
</div>
<div class="news-story-title">${escapeNewsHtml(title)}</div>
<div class="news-story-title">${escapeNewsHtml(leadText)}</div>
${summary}
<div class="news-story-tags">
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>

View File

@@ -1,4 +1,4 @@
import { ReactNode, useMemo, useState } from 'react'
import { ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Layout, Menu, Typography, Button, Space } from 'antd'
import {
AlertOutlined,
@@ -28,6 +28,7 @@ const { Sider, Content } = Layout
const { Text } = Typography
const DEFAULT_OPEN_MENU_KEY = 'collection'
let cachedOpenKeys: string[] = [DEFAULT_OPEN_MENU_KEY]
let cachedMenuScrollTop = 0
interface AppLayoutProps {
children: ReactNode
@@ -39,6 +40,7 @@ function AppLayout({ children }: AppLayoutProps) {
const { user, logout } = useAuthStore()
const [collapsed, setCollapsed] = useState(false)
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
const menuViewportRef = useRef<HTMLDivElement>(null)
const showBanner = true
const appVersion = `v${packageJson.version}`
const isSuperAdmin = user?.role === 'super_admin'
@@ -105,6 +107,37 @@ function AppLayout({ children }: AppLayoutProps) {
setOpenKeys(nextKeys)
}
useLayoutEffect(() => {
const viewport = menuViewportRef.current
if (!viewport) return
const restoreScroll = () => {
viewport.scrollTop = cachedMenuScrollTop
}
restoreScroll()
const frameId = window.requestAnimationFrame(restoreScroll)
return () => {
window.cancelAnimationFrame(frameId)
}
}, [collapsed, location.pathname, openKeys])
useLayoutEffect(() => {
const viewport = menuViewportRef.current
if (!viewport) return
const cacheScroll = () => {
cachedMenuScrollTop = viewport.scrollTop
}
viewport.addEventListener('scroll', cacheScroll, { passive: true })
return () => {
viewport.removeEventListener('scroll', cacheScroll)
}
}, [])
return (
<Layout className="dashboard-layout">
<Sider
@@ -139,7 +172,7 @@ function AppLayout({ children }: AppLayoutProps) {
<Text strong style={{ color: 'white', fontSize: 18 }}></Text>
) : null}
</div>
<Scrollbar className="dashboard-sider-menu-scroll">
<Scrollbar className="dashboard-sider-menu-scroll" viewportRef={menuViewportRef}>
<Menu
theme="dark"
mode="inline"
@@ -150,6 +183,7 @@ function AppLayout({ children }: AppLayoutProps) {
updateOpenKeys(keys as string[])
}}
onClick={({ key }) => {
cachedMenuScrollTop = menuViewportRef.current?.scrollTop ?? cachedMenuScrollTop
if (key !== selectedKey) {
navigate(key)
}

View File

@@ -2743,6 +2743,17 @@ body {
margin-bottom: 12px;
}
.settings-tabs .ant-tabs-extra-content {
display: flex;
align-items: center;
}
.settings-tab-actions .ant-btn {
width: 32px;
height: 32px;
padding: 0;
}
.settings-tabs .ant-tabs-content-holder,
.settings-tabs .ant-tabs-content,
.settings-tabs .ant-tabs-tabpane {
@@ -2801,6 +2812,159 @@ body {
min-width: 0;
}
.earth-brand-settings {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(280px, 0.95fr);
gap: 20px;
align-items: start;
}
.earth-brand-settings__form,
.earth-brand-settings__preview {
min-width: 0;
}
.earth-brand-settings__uploads {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.earth-brand-settings__preview {
position: sticky;
top: 0;
}
.earth-brand-settings__preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.earth-brand-preview-stage {
--hud-scale: 1;
--brand-scale: 0.88;
--brand-copy-width: 160px;
--hud-text: rgba(235, 245, 255, 0.96);
--hud-text-muted: rgba(214, 229, 247, 0.86);
--hud-text-soft: rgba(183, 204, 230, 0.68);
min-height: 190px;
padding: 26px 24px;
overflow: hidden;
background:
radial-gradient(circle at 22% 42%, rgba(69, 139, 255, 0.18), transparent 32%),
linear-gradient(135deg, #07111d 0%, #102033 52%, #17271e 100%);
}
.earth-brand-preview-stage .hud-panel-brand {
padding: calc(10px * var(--hud-scale)) calc(4px * var(--hud-scale)) calc(12px * var(--hud-scale)) 0;
display: flex;
align-items: center;
justify-content: flex-start;
min-height: calc(66px * var(--hud-scale));
background: transparent;
border: 0;
box-shadow: none;
isolation: isolate;
}
.earth-brand-preview-stage .earth-brand {
display: flex;
align-items: center;
gap: calc(10px * var(--hud-scale) * var(--brand-scale));
min-width: 0;
position: relative;
}
.earth-brand-preview-stage .earth-brand::after {
content: "";
position: absolute;
inset: calc(4px * var(--hud-scale)) calc(-10px * var(--hud-scale)) calc(6px * var(--hud-scale)) calc(-10px * var(--hud-scale));
background:
radial-gradient(circle at 18% 50%, rgba(145, 186, 255, 0.14), transparent 32%),
linear-gradient(90deg, rgba(145, 186, 255, 0.05), transparent 58%);
opacity: 0.75;
pointer-events: none;
filter: blur(14px);
z-index: -1;
}
.earth-brand-preview-stage .earth-brand__logo {
display: block;
flex: 0 0 auto;
width: calc(128px * var(--hud-scale) * var(--brand-scale));
height: calc(128px * var(--hud-scale) * var(--brand-scale));
object-fit: contain;
}
.earth-brand-preview-stage .earth-brand__copy {
display: flex;
flex: 0 0 auto;
flex-direction: column;
justify-content: center;
gap: calc(5px * var(--hud-scale) * var(--brand-scale));
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
}
.earth-brand-preview-stage .earth-brand__title {
display: block;
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
max-width: 100%;
height: auto;
min-height: calc(20px * var(--hud-scale) * var(--brand-scale));
object-fit: contain;
}
.earth-brand-preview-stage .earth-brand__title-text {
color: var(--hud-text);
font-size: calc(1rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.18;
font-weight: 700;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.earth-brand-preview-stage .earth-brand__meta {
display: flex;
flex-direction: column;
gap: calc(2px * var(--hud-scale) * var(--brand-scale));
width: fit-content;
}
.earth-brand-preview-stage .earth-brand__subtitle {
color: var(--hud-text-muted);
font-size: calc(0.74rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.3;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.earth-brand-preview-stage .earth-brand__description {
color: var(--hud-text-soft);
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.3;
letter-spacing: 0.08em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.earth-brand-settings,
.earth-brand-settings__uploads {
grid-template-columns: 1fr;
}
.earth-brand-settings__preview {
position: static;
}
}
.settings-pane .data-source-table-region .ant-table-container {
display: flex;
flex-direction: column;

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import {
ApiOutlined,
CheckOutlined,
EyeInvisibleOutlined,
EyeOutlined,
SaveOutlined,
ReloadOutlined,
SyncOutlined,
ToolOutlined,
UndoOutlined,
} from '@ant-design/icons'
import {
Alert,
@@ -853,9 +853,6 @@ export default function AISettings() {
</Form.Item>
</Card>
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
AI
</Button>
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
</Form>
</AISettingsPanel>
@@ -1062,9 +1059,6 @@ export default function AISettings() {
</>
) : null}
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
Tool
</Button>
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
</Form>
</AISettingsPanel>
@@ -1136,19 +1130,6 @@ export default function AISettings() {
style={{ marginBottom: 12 }}
/>
<Space wrap>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={savingPrompt}>
</Button>
<Button
icon={<UndoOutlined />}
loading={resettingPrompt}
disabled={!selectedPrompt.is_custom}
onClick={() => { void resetSelectedPrompt() }}
>
</Button>
</Space>
</>
) : (
<Alert showIcon type="warning" message="暂无可配置提示词。" />
@@ -1167,6 +1148,48 @@ export default function AISettings() {
},
]
const renderTabActions = () => {
if (activeTab === 'playground') return null
if (activeTab === 'prompts') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingPrompt}
disabled={!selectedPrompt}
onClick={() => promptForm.submit()}
aria-label="保存"
/>
</Tooltip>
<Tooltip title="重置">
<Button
icon={<ReloadOutlined />}
loading={resettingPrompt}
disabled={!selectedPrompt?.is_custom}
onClick={() => { void resetSelectedPrompt() }}
aria-label="重置"
/>
</Tooltip>
</Space>
)
}
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={saving}
onClick={() => form.submit()}
aria-label="保存"
/>
</Tooltip>
</Space>
)
}
return (
<AppLayout>
<div className="page-shell settings-shell">
@@ -1181,6 +1204,7 @@ export default function AISettings() {
className="settings-tabs"
activeKey={activeTab}
onChange={(tabKey) => setSearchParams(tabKey === 'providers' ? {} : { tab: tabKey }, { replace: true })}
tabBarExtraContent={renderTabActions()}
items={tabItems}
/>
</div>

View File

@@ -19,6 +19,7 @@ import {
} from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
@@ -252,7 +253,7 @@ export function BGPAlertsPanel() {
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
</Descriptions>
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
<MarkdownRenderer markdown={brief.content_markdown} />
</Scrollbar>
) : (
<Text type="secondary"> BGP </Text>

View File

@@ -16,6 +16,7 @@ import {
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
import {
@@ -28,6 +29,17 @@ const { Text } = Typography
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
const gateway = getSituationalAwarenessGateway()
function getAxiosErrorDetail(error: unknown, fallback: string): string {
if (!axios.isAxiosError(error)) return fallback
const detail = error.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail.map((item) => item?.msg || item?.message || JSON.stringify(item)).join('')
}
if (detail && typeof detail === 'object') return JSON.stringify(detail)
return fallback
}
interface AlertStatsResponse {
critical: number
warning: number
@@ -84,8 +96,7 @@ export function SituationalAlertsPanel() {
setBriefResult(response.data)
} catch (error) {
console.error('Failed to generate situational alert brief:', error)
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setBriefError(detail || '生成态势告警 AI 简报失败')
setBriefError(getAxiosErrorDetail(error, '生成态势告警 AI 简报失败'))
messageApi.error('生成态势告警 AI 简报失败')
} finally {
setBriefLoading(false)
@@ -191,7 +202,7 @@ export function SituationalAlertsPanel() {
</Space>
</Card>
<Card size="small" title="AI 简报">
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
<MarkdownRenderer markdown={briefResult.content} />
</Card>
</Space>
) : null}

View File

@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
Table, Tag, Space, Card, Select, Input, Button, Segmented,
Modal, Spin, Empty, Tooltip, Typography, Grid
@@ -17,6 +17,7 @@ import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../util
const { Title, Text } = Typography
const { useBreakpoint } = Grid
const DATA_LIST_SUMMARY_DELAY_MS = 250
interface CollectedData {
id: number
@@ -321,6 +322,7 @@ function DataList() {
const [sourceFilter, setSourceFilter] = useState<string[]>([])
const [typeFilter, setTypeFilter] = useState<string[]>([])
const [searchText, setSearchText] = useState('')
const [submittedSearchText, setSubmittedSearchText] = useState('')
const [sources, setSources] = useState<SourceOption[]>([])
const [types, setTypes] = useState<string[]>([])
const [detailVisible, setDetailVisible] = useState(false)
@@ -398,16 +400,17 @@ function DataList() {
window.addEventListener('mouseup', onUp)
}
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
include_metadata: 'false',
})
if (sourceFilter.length > 0) params.append('source', sourceFilter.join(','))
if (typeFilter.length > 0) params.append('data_type', typeFilter.join(','))
if (searchText) params.append('search', searchText)
if (submittedSearchText) params.append('search', submittedSearchText)
const res = await axios.get(`/api/v1/collected?${params}`)
setData(res.data.data)
@@ -417,14 +420,14 @@ function DataList() {
} finally {
setLoading(false)
}
}
}, [page, pageSize, submittedSearchText, sourceFilter, typeFilter])
const fetchSummary = async () => {
const fetchSummary = useCallback(async () => {
try {
const params = new URLSearchParams()
if (sourceFilter.length > 0) params.append('source', sourceFilter.join(','))
if (typeFilter.length > 0) params.append('data_type', typeFilter.join(','))
if (searchText) params.append('search', searchText)
if (submittedSearchText) params.append('search', submittedSearchText)
const query = params.toString()
const res = await axios.get(query ? `/api/v1/collected/summary?${query}` : '/api/v1/collected/summary')
@@ -432,9 +435,9 @@ function DataList() {
} catch (error) {
console.error('Failed to fetch summary:', error)
}
}
}, [submittedSearchText, sourceFilter, typeFilter])
const fetchFilters = async () => {
const fetchFilters = useCallback(async () => {
try {
const [sourcesRes, typesRes] = await Promise.all([
axios.get('/api/v1/collected/sources'),
@@ -445,30 +448,34 @@ function DataList() {
} catch (error) {
console.error('Failed to fetch filters:', error)
}
}
useEffect(() => {
fetchFilters()
}, [])
useEffect(() => {
fetchData()
fetchSummary()
}, [page, pageSize, sourceFilter, typeFilter])
void fetchFilters()
}, [fetchFilters])
useEffect(() => {
void fetchData()
}, [fetchData])
useEffect(() => {
const timer = window.setTimeout(() => {
void fetchSummary()
}, DATA_LIST_SUMMARY_DELAY_MS)
return () => window.clearTimeout(timer)
}, [fetchSummary])
const handleSearch = () => {
setSubmittedSearchText(searchText.trim())
setPage(1)
fetchData()
fetchSummary()
}
const handleReset = () => {
setSourceFilter([])
setTypeFilter([])
setSearchText('')
setSubmittedSearchText('')
setPage(1)
setTimeout(fetchData, 0)
setTimeout(fetchSummary, 0)
}
const handleViewDetail = async (id: number) => {
@@ -589,8 +596,8 @@ function DataList() {
}
const activeFilterCount = useMemo(
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, searchText]
() => [sourceFilter.length > 0, typeFilter.length > 0, submittedSearchText].filter(Boolean).length,
[sourceFilter, typeFilter, submittedSearchText]
)
const summaryKpis = useMemo(

View File

@@ -40,6 +40,7 @@ const { Text } = Typography
const COLLECTION_REFRESH_DELAY_MS = 800
const COLLECTION_STATUS_POLL_MS = 900
const COLLECTION_STATUS_MAX_POLLS = 30
const DATASOURCE_SEARCH_DEBOUNCE_MS = 350
interface BuiltInDataSource {
id: number
@@ -309,6 +310,7 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
const [activeFilter, setActiveFilter] = useState<ActiveFilter>('enabled')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [collectedFilter, setCollectedFilter] = useState<CollectedFilter>('all')
const [searchInput, setSearchInput] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
const [runningTasksVisible, setRunningTasksVisible] = useState(false)
@@ -316,6 +318,8 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
const [recordCount, setRecordCount] = useState<number | null>(null)
const [tableHeight, setTableHeight] = useState(360)
const tableRegionRef = useRef<HTMLDivElement | null>(null)
const customOverridesLoadedRef = useRef(false)
const realtimeSourcesLoadedRef = useRef(false)
const taskBuiltInSources = useMemo(
() => builtInSources.filter((source) => !isRealtimeBuiltinSource(source)),
@@ -352,12 +356,8 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
collected: collectedFilter === 'all' ? undefined : collectedFilter === 'collected',
q: searchQuery.trim() || undefined,
}
const [builtinRes, customRes] = await Promise.all([
axios.get('/api/v1/datasources', { params }),
axios.get('/api/v1/datasources/configs'),
])
const builtinRes = await axios.get('/api/v1/datasources', { params })
setBuiltInSources(builtinRes.data.data || [])
setCustomOverrides(customRes.data.data || [])
} catch (error) {
console.error('Failed to fetch data:', error)
messageApi.error('获取数据源列表失败')
@@ -371,6 +371,7 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
try {
const response = await axios.get('/api/v1/realtime-sources')
setRealtimeSources(response.data.data || [])
realtimeSourcesLoadedRef.current = true
} catch (error) {
console.error('Failed to fetch realtime sources:', error)
messageApi.error('获取实时流状态失败')
@@ -384,8 +385,17 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
}, [fetchData])
useEffect(() => {
void fetchRealtimeSources()
}, [fetchRealtimeSources])
if (activeTab === 'realtime' && !realtimeSourcesLoadedRef.current) {
void fetchRealtimeSources()
}
}, [activeTab, fetchRealtimeSources])
useEffect(() => {
const timer = window.setTimeout(() => {
setSearchQuery(searchInput.trim())
}, DATASOURCE_SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [searchInput])
useEffect(() => {
const visibleKeys = new Set(allSources.map((source) => source.key))
@@ -562,7 +572,14 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
const handleViewSource = async (source: UnifiedDataSource) => {
try {
{
const override = customOverrides.find((item) => item.name === source.source)
let overrides = customOverrides
if (!customOverridesLoadedRef.current) {
const customRes = await axios.get('/api/v1/datasources/configs')
overrides = customRes.data.data || []
setCustomOverrides(overrides)
customOverridesLoadedRef.current = true
}
const override = overrides.find((item) => item.name === source.source)
const [detailRes, statsRes, overrideDetail] = await Promise.all([
axios.get(`/api/v1/datasources/${source.id}`),
axios.get(`/api/v1/datasources/${source.id}/stats`),
@@ -1006,8 +1023,9 @@ function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
className="data-source-filter-toolbar__search"
allowClear
placeholder="搜索名称、标识或采集器"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
onSearch={(value) => setSearchQuery(value.trim())}
/>
</div>
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region">

View File

@@ -4,6 +4,7 @@ import { TableActions, actionCellProps } from '../../components/TableActions/Tab
import {
ApiOutlined,
BookOutlined,
CheckOutlined,
CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
@@ -15,6 +16,7 @@ import {
RobotOutlined,
StopOutlined,
SyncOutlined,
UploadOutlined,
} from '@ant-design/icons'
import {
Alert,
@@ -35,6 +37,7 @@ import {
Tag,
Tooltip,
Typography,
Upload,
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
@@ -48,9 +51,19 @@ import SmtpPanel from './SmtpPanel'
import DataSources from '../DataSources/DataSources'
const { Title, Text } = Typography
const { Dragger } = Upload
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
const DEFAULT_PROVIDER_MAX_TOKENS = 4096
const CUSTOM_STREAM_STATUS_POLL_MS = 5000
const DEFAULT_EARTH_BRAND_SETTINGS: EarthBrandSettings = {
logo_src: '/earth/assets/brand/earth-logo.png',
title_src: '/earth/assets/brand/title-zh.png',
title_text: '智能星球计划',
subtitle: '现实层宇宙全息感知系统',
description: '卫星 · 海底光缆 · 算力基础设施',
aria_label: '智能星球计划品牌标识',
title_alt: '智能星球计划',
}
interface SystemSettings {
system_name: string
@@ -160,6 +173,21 @@ interface EarthBoundaryStatus {
last_build?: Record<string, unknown>
}
interface EarthBrandSettings {
logo_src: string
title_src: string
title_text: string
subtitle: string
description: string
aria_label: string
title_alt: string
}
interface EarthBrandResponse {
brand: EarthBrandSettings
is_default: boolean
}
interface SecretStatus {
configured: boolean
preview: string
@@ -357,6 +385,14 @@ const formatBytes = (value: number | null | undefined) => {
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
const normalizeEarthBrandSettings = (value?: Partial<EarthBrandSettings> | null): EarthBrandSettings => ({
...DEFAULT_EARTH_BRAND_SETTINGS,
...(value || {}),
title_text: value?.title_text?.trim() || DEFAULT_EARTH_BRAND_SETTINGS.title_text,
aria_label: value?.aria_label?.trim() || value?.title_text?.trim() || DEFAULT_EARTH_BRAND_SETTINGS.aria_label,
title_alt: value?.title_alt?.trim() || value?.title_text?.trim() || DEFAULT_EARTH_BRAND_SETTINGS.title_alt,
})
function SettingsPanel({
loading,
children,
@@ -392,8 +428,12 @@ function Settings() {
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
const [earthBrandSettings, setEarthBrandSettings] = useState<EarthBrandSettings>(DEFAULT_EARTH_BRAND_SETTINGS)
const [earthBrandIsDefault, setEarthBrandIsDefault] = useState(true)
const [earthBoundaryStatus, setEarthBoundaryStatus] = useState<EarthBoundaryStatus | null>(null)
const [earthBoundaryConfigText, setEarthBoundaryConfigText] = useState('')
const [savingEarthBrand, setSavingEarthBrand] = useState(false)
const [uploadingEarthBrandField, setUploadingEarthBrandField] = useState<keyof EarthBrandSettings | null>(null)
const [savingEarthBoundaryConfig, setSavingEarthBoundaryConfig] = useState(false)
const [buildingEarthBoundary, setBuildingEarthBoundary] = useState(false)
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
@@ -428,12 +468,15 @@ function Settings() {
const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>()
const [earthBrandForm] = Form.useForm<EarthBrandSettings>()
const [integrationForm] = Form.useForm()
const [collectorConfigForm] = Form.useForm()
const [customSourceForm] = Form.useForm()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], integrationForm)
const earthBrandFormValues = Form.useWatch([], earthBrandForm) as Partial<EarthBrandSettings> | undefined
const earthBrandPreview = normalizeEarthBrandSettings(earthBrandFormValues || earthBrandSettings)
const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], integrationForm)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
@@ -471,7 +514,7 @@ function Settings() {
const selectedAisRuntimeHealth = selectedCollector?.ais_health || null
const settingsTabKeysByMode: Record<string, Set<string>> = {
settings: new Set(['display', 'notifications', 'security', 'smtp']),
earth: new Set(['tv', 'earth_assets', 'basemap', 'layer_resources', 'models_3d', 'news_anchor_strategy']),
earth: new Set(['tv', 'brand', 'earth_assets', 'basemap', 'layer_resources', 'models_3d', 'news_anchor_strategy']),
collection: new Set(['collector_credentials', 'collectors', 'collection_history']),
}
const defaultTabByMode: Record<string, string> = {
@@ -504,6 +547,7 @@ function Settings() {
webSearchPresetsResponse,
collectorConfigsResponse,
customConfigsResponse,
earthBrandResponse,
earthBoundaryResponse,
] = await Promise.all([
axios.get('/api/v1/settings'),
@@ -511,6 +555,7 @@ function Settings() {
axios.get('/api/v1/settings/integrations/web-search/presets'),
axios.get('/api/v1/datasources/configs/all'),
axios.get('/api/v1/datasources/configs'),
axios.get<EarthBrandResponse>('/api/v1/earth/brand'),
axios.get('/api/v1/earth/boundaries/status'),
])
setSystemSettings(response.data.system)
@@ -518,6 +563,10 @@ function Settings() {
setSecuritySettings(response.data.security)
setTvSettings(response.data.tv || null)
setIntegrations(response.data.integrations || null)
const nextEarthBrand = normalizeEarthBrandSettings(earthBrandResponse.data.brand)
setEarthBrandSettings(nextEarthBrand)
setEarthBrandIsDefault(earthBrandResponse.data.is_default)
earthBrandForm.setFieldsValue(nextEarthBrand)
setEarthBoundaryStatus(earthBoundaryResponse.data)
setEarthBoundaryConfigText(JSON.stringify(earthBoundaryResponse.data.config || {}, null, 2))
setCollectors(response.data.collectors || [])
@@ -1601,6 +1650,94 @@ function Settings() {
}
}
const saveEarthBrandSettings = async () => {
try {
const values = await earthBrandForm.validateFields()
const payload = normalizeEarthBrandSettings(values)
setSavingEarthBrand(true)
const response = await axios.put<EarthBrandResponse>('/api/v1/earth/brand', payload)
const nextBrand = normalizeEarthBrandSettings(response.data.brand)
setEarthBrandSettings(nextBrand)
setEarthBrandIsDefault(response.data.is_default)
earthBrandForm.setFieldsValue(nextBrand)
message.success('Earth 品牌标识已保存')
} catch (error) {
if (error instanceof Error) {
message.error(error.message || 'Earth 品牌标识保存失败')
} else {
message.error('Earth 品牌标识保存失败')
}
console.error(error)
} finally {
setSavingEarthBrand(false)
}
}
const resetEarthBrandSettings = () => {
Modal.confirm({
title: '重置为缺省品牌标识?',
content: '这会清除当前自定义品牌配置Earth 页面将恢复使用内置静态资产。',
okText: '重置',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
try {
setSavingEarthBrand(true)
const response = await axios.delete<EarthBrandResponse>('/api/v1/earth/brand')
const nextBrand = normalizeEarthBrandSettings(response.data.brand)
setEarthBrandSettings(nextBrand)
setEarthBrandIsDefault(true)
earthBrandForm.setFieldsValue(nextBrand)
message.success('Earth 品牌标识已重置为缺省')
} catch (error) {
message.error('Earth 品牌标识重置失败')
console.error(error)
} finally {
setSavingEarthBrand(false)
}
},
})
}
const uploadEarthBrandAsset = async (field: 'logo_src' | 'title_src', file: File) => {
const formData = new FormData()
formData.append('file', file)
setUploadingEarthBrandField(field)
try {
const response = await axios.post<{ url: string }>('/api/v1/earth/brand/assets', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
earthBrandForm.setFieldValue(field, response.data.url)
message.success('品牌图片已上传')
return response.data.url
} finally {
setUploadingEarthBrandField(null)
}
}
const createEarthBrandUploadProps = (field: 'logo_src' | 'title_src') => ({
name: 'file',
multiple: false,
showUploadList: false,
accept: '.png,.jpg,.jpeg,.webp,.svg,image/png,image/jpeg,image/webp,image/svg+xml',
customRequest: (options: {
file: string | Blob | File
onSuccess?: (response: unknown) => void
onError?: (error: Error) => void
}) => {
const file = options.file instanceof File
? options.file
: new File([options.file], 'earth-brand-asset')
uploadEarthBrandAsset(field, file)
.then((url) => options.onSuccess?.({ url }))
.catch((error: unknown) => {
const uploadError = error instanceof Error ? error : new Error('品牌图片上传失败')
message.error(uploadError.message)
options.onError?.(uploadError)
})
},
})
const saveEarthBoundaryConfig = async () => {
try {
setSavingEarthBoundaryConfig(true)
@@ -1731,9 +1868,15 @@ function Settings() {
width: 92,
fixed: 'right' as const,
render: (_: unknown, record: CollectorSettings) => (
<Button type="primary" loading={savingCollectorId === record.id} onClick={() => saveCollector(record)}>
</Button>
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingCollectorId === record.id}
onClick={() => saveCollector(record)}
aria-label="保存"
/>
</Tooltip>
),
},
]
@@ -1899,7 +2042,6 @@ function Settings() {
<Form.Item name="auto_refresh" label="自动刷新" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</SettingsPanel>
),
@@ -1926,7 +2068,6 @@ function Settings() {
<Form.Item name="daily_summary" label="每日摘要" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</SettingsPanel>
),
@@ -1953,7 +2094,6 @@ function Settings() {
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</SettingsPanel>
),
@@ -2075,6 +2215,99 @@ function Settings() {
</div>
),
},
{
key: 'brand',
label: '品牌标识',
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<div className="earth-brand-settings">
<div className="earth-brand-settings__form">
<Form form={earthBrandForm} layout="vertical">
<div className="earth-brand-settings__uploads">
<Form.Item label="图形 logo">
<Dragger
{...createEarthBrandUploadProps('logo_src')}
disabled={uploadingEarthBrandField !== null}
>
<p className="ant-upload-drag-icon"><UploadOutlined /></p>
<p className="ant-upload-text"> logo</p>
</Dragger>
</Form.Item>
<Form.Item label="文字 logo 图片">
<Dragger
{...createEarthBrandUploadProps('title_src')}
disabled={uploadingEarthBrandField !== null}
>
<p className="ant-upload-drag-icon"><UploadOutlined /></p>
<p className="ant-upload-text"> logo</p>
</Dragger>
</Form.Item>
</div>
<Form.Item name="logo_src" label="图形 logo 地址" rules={[{ required: true, message: '请填写图形 logo 地址' }]}>
<Input placeholder="/earth/assets/brand/earth-logo.png" />
</Form.Item>
<Form.Item name="title_src" label="文字 logo 图片地址">
<Input placeholder="/earth/assets/brand/title-zh.png" />
</Form.Item>
<Form.Item name="title_text" label="标题文本" rules={[{ required: true, message: '请填写标题文本' }]}>
<Input />
</Form.Item>
<Form.Item name="subtitle" label="副标题">
<Input />
</Form.Item>
<Form.Item name="description" label="说明文案">
<Input />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="aria_label" label="品牌区域无障碍文本">
<Input />
</Form.Item>
<Form.Item name="title_alt" label="文字 logo 替代文本">
<Input />
</Form.Item>
</div>
</Form>
</div>
<div className="earth-brand-settings__preview">
<div className="earth-brand-settings__preview-header">
<Text strong></Text>
<Tag color={earthBrandIsDefault ? 'default' : 'blue'}>
{earthBrandIsDefault ? '缺省' : '自定义'}
</Tag>
</div>
<div className="earth-brand-preview-stage">
<div className="hud-panel-brand">
<div className="earth-brand" aria-label={earthBrandPreview.aria_label}>
<img
className="earth-brand__logo"
src={earthBrandPreview.logo_src}
alt=""
aria-hidden="true"
/>
<div className="earth-brand__copy">
{earthBrandPreview.title_src ? (
<img
className="earth-brand__title"
src={earthBrandPreview.title_src}
alt={earthBrandPreview.title_alt}
/>
) : (
<div className="earth-brand__title-text">{earthBrandPreview.title_text}</div>
)}
<div className="earth-brand__meta">
<span className="earth-brand__subtitle">{earthBrandPreview.subtitle}</span>
<span className="earth-brand__description">{earthBrandPreview.description}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</SettingsPanel>
),
},
{
key: 'earth_assets',
label: '国界精度',
@@ -2130,13 +2363,6 @@ function Settings() {
spellCheck={false}
/>
<Space style={{ marginTop: 12 }}>
<Button
type="primary"
loading={savingEarthBoundaryConfig}
onClick={() => { void saveEarthBoundaryConfig() }}
>
</Button>
<Button
icon={<PlayCircleOutlined />}
loading={buildingEarthBoundary}
@@ -2145,12 +2371,6 @@ function Settings() {
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => { void fetchSettings() }}
>
</Button>
</Space>
</Card>
</Space>
@@ -2426,14 +2646,6 @@ function Settings() {
) : null}
</Card>
<Button
type="primary"
htmlType="submit"
loading={savingIntegrations}
style={{ marginTop: 16 }}
>
AI
</Button>
{aiProviderSaveFeedback ? (
<Alert
showIcon
@@ -2753,13 +2965,6 @@ function Settings() {
</Card>
<Space>
<Button
type="primary"
loading={savingCollectorConfig || savingIntegrations}
onClick={() => { void saveSelectedCollectorSettings() }}
>
</Button>
{selectedCollector?.is_custom && selectedCollectorConfig?.source_type === 'websocket' ? (
<>
<Button
@@ -2863,6 +3068,109 @@ function Settings() {
const visibleTabItems = tabItems.filter((item) => settingsTabKeysByMode[pageMode].has(String(item.key)))
const renderTabActions = () => {
if (activeSettingsTab === 'display') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button type="primary" icon={<CheckOutlined />} onClick={() => systemForm.submit()} aria-label="保存" />
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'notifications') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button type="primary" icon={<CheckOutlined />} onClick={() => notificationForm.submit()} aria-label="保存" />
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'security') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button type="primary" icon={<CheckOutlined />} onClick={() => securityForm.submit()} aria-label="保存" />
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'earth_assets') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingEarthBoundaryConfig}
onClick={() => { void saveEarthBoundaryConfig() }}
aria-label="保存"
/>
</Tooltip>
<Tooltip title="刷新">
<Button icon={<ReloadOutlined />} onClick={() => { void fetchSettings() }} aria-label="刷新" />
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'brand') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingEarthBrand}
onClick={() => { void saveEarthBrandSettings() }}
aria-label="保存"
/>
</Tooltip>
<Tooltip title="重置为缺省">
<Button
icon={<ReloadOutlined />}
loading={savingEarthBrand}
onClick={resetEarthBrandSettings}
aria-label="重置为缺省"
/>
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'ai') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingIntegrations}
onClick={() => integrationForm.submit()}
aria-label="保存"
/>
</Tooltip>
</Space>
)
}
if (activeSettingsTab === 'collector_credentials') {
return (
<Space size={8} className="settings-tab-actions">
<Tooltip title="保存">
<Button
type="primary"
icon={<CheckOutlined />}
loading={savingCollectorConfig || savingIntegrations}
disabled={!selectedCollector}
onClick={() => { void saveSelectedCollectorSettings() }}
aria-label="保存"
/>
</Tooltip>
</Space>
)
}
return null
}
return (
<AppLayout>
<div className="page-shell settings-shell">
@@ -2878,6 +3186,7 @@ function Settings() {
className="settings-tabs"
activeKey={activeSettingsTab}
onChange={updateSettingsTab}
tabBarExtraContent={renderTabActions()}
items={visibleTabItems}
/>
</div>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Space, Switch, Typography, message } from 'antd'
import { CheckOutlined } from '@ant-design/icons'
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Space, Switch, Tooltip, Typography, message } from 'antd'
import axios from 'axios'
interface SecretStatus {
@@ -180,7 +181,9 @@ export default function SmtpPanel() {
STARTTLS TLS 587 465
</Typography.Paragraph>
<Space>
<Button type="primary" htmlType="submit" loading={saving}> SMTP </Button>
<Tooltip title="保存">
<Button type="primary" htmlType="submit" icon={<CheckOutlined />} loading={saving} aria-label="保存" />
</Tooltip>
<Button onClick={() => setTestOpen(true)}></Button>
</Space>
</Form>