release: bump version to 0.60.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.60.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">浏览器摄像头适合网页/SaaS;Motion 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">浏览器摄像头适合网页/SaaS;Motion 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
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
852
frontend/public/earth/js/controls.js
vendored
852
frontend/public/earth/js/controls.js
vendored
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user