release: bump version to 0.38.0
This commit is contained in:
@@ -421,8 +421,10 @@
|
||||
|
||||
.earth-mobile-drawer-sheet {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(240px + var(--safe-bottom));
|
||||
max-height: min(78vh, calc(100vh - 72px - var(--safe-top)));
|
||||
height: min(78vh, calc(100vh - 72px - var(--safe-top)));
|
||||
padding: 10px 14px calc(14px + var(--safe-bottom)) 14px;
|
||||
border-top-left-radius: 28px;
|
||||
border-top-right-radius: 28px;
|
||||
@@ -438,6 +440,8 @@
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-header {
|
||||
order: 1;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 4px 8px;
|
||||
cursor: ns-resize;
|
||||
user-select: none;
|
||||
@@ -452,45 +456,173 @@
|
||||
background: rgba(225, 239, 255, 0.18);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs {
|
||||
.earth-mobile-drawer-nav {
|
||||
order: 3;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
margin-top: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(214, 231, 247, 0.08);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-nav-copy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-nav-kicker {
|
||||
color: rgba(167, 194, 223, 0.58);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-nav-title {
|
||||
color: var(--hud-title);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs-shell {
|
||||
position: relative;
|
||||
padding: 8px 8px 2px;
|
||||
border: 1px solid rgba(207, 226, 245, 0.06);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)),
|
||||
rgba(6, 16, 31, 0.32);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 10px 22px rgba(2, 8, 18, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 2px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs-fade {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 22px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs-fade--left {
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, rgba(6, 16, 31, 0.92), rgba(6, 16, 31, 0));
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs-fade--right {
|
||||
right: 0;
|
||||
background: linear-gradient(270deg, rgba(6, 16, 31, 0.92), rgba(6, 16, 31, 0));
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab {
|
||||
min-height: 40px;
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
flex: 0 0 auto;
|
||||
min-width: 68px;
|
||||
min-height: 58px;
|
||||
padding: 8px 11px 9px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 1px solid rgba(201, 225, 247, 0.06);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.015)),
|
||||
rgba(7, 18, 34, 0.38);
|
||||
color: var(--hud-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
scroll-snap-align: start;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab.is-active {
|
||||
color: var(--hud-title);
|
||||
background: rgba(122, 180, 255, 0.14);
|
||||
border-color: rgba(122, 180, 255, 0.24);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(111, 174, 255, 0.18), rgba(74, 126, 210, 0.08)),
|
||||
rgba(10, 26, 52, 0.58);
|
||||
border-color: rgba(122, 180, 255, 0.22);
|
||||
box-shadow:
|
||||
0 8px 18px rgba(11, 22, 40, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab:hover {
|
||||
color: var(--hud-text);
|
||||
border-color: rgba(201, 225, 247, 0.14);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
font-size: 17px;
|
||||
color: rgba(221, 235, 248, 0.88);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab.is-active .earth-mobile-drawer-tab-icon {
|
||||
color: #f6fbff;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(161, 207, 255, 0.26), rgba(95, 154, 237, 0.18)),
|
||||
rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-tab-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-content {
|
||||
order: 2;
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
max-height: calc(78vh - 108px - var(--safe-bottom));
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-slot {
|
||||
display: none;
|
||||
min-height: 0;
|
||||
max-height: inherit;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.earth-mobile-drawer-slot.is-active {
|
||||
@@ -615,6 +747,14 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-page--tv {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--tv .earth-mobile-page-intro {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.earth-mobile-page-intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -634,6 +774,11 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.earth-mobile-page--tv .earth-mobile-page-summary {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.earth-mobile-layer-list,
|
||||
.earth-mobile-news-board-list,
|
||||
.earth-mobile-search-results {
|
||||
@@ -765,6 +910,10 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stats-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-stat-card,
|
||||
.earth-mobile-situation-card,
|
||||
.earth-mobile-news-focus,
|
||||
@@ -785,12 +934,23 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-card {
|
||||
min-width: 0;
|
||||
padding: 12px 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.earth-mobile-stat-num {
|
||||
color: var(--hud-title);
|
||||
font-size: 1.26rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-num {
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.earth-mobile-stat-label,
|
||||
.earth-mobile-situation-card-subtitle,
|
||||
.earth-mobile-news-focus-kicker,
|
||||
@@ -802,6 +962,52 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-label {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-height: 540px) {
|
||||
.earth-mobile-page--situation {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-page-intro {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-page-kicker {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-page-summary {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stats-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-card {
|
||||
padding: 10px 10px;
|
||||
gap: 3px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-num {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.earth-mobile-page--situation .earth-mobile-stat-label {
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
}
|
||||
|
||||
.earth-mobile-situation-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -857,17 +1063,124 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-kicker {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-headline {
|
||||
color: var(--hud-title);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-summary {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-tags {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--hud-text);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-tag--status {
|
||||
color: var(--hud-accent-strong);
|
||||
border-color: rgba(122, 180, 255, 0.2);
|
||||
background: rgba(122, 180, 255, 0.12);
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-overview-bar:focus-visible {
|
||||
outline: 2px solid rgba(122, 180, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-meta-wrap {
|
||||
overflow: hidden;
|
||||
max-height: 240px;
|
||||
opacity: 1;
|
||||
margin-top: 10px;
|
||||
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-meta-wrap.is-collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-select {
|
||||
border: 1px solid rgba(201, 225, 247, 0.14);
|
||||
border-radius: 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--hud-text);
|
||||
padding: 12px 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-player {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 0;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(201, 225, 247, 0.1);
|
||||
@@ -912,6 +1225,28 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.earth-mobile-action-btn:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earth-mobile-action-btn--compact {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(212, 227, 244, 0.14);
|
||||
color: var(--hud-text);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.earth-mobile-action-btn--compact .material-symbols-rounded {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.earth-mobile-action-btn--ghost {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(212, 227, 244, 0.1);
|
||||
@@ -961,6 +1296,43 @@
|
||||
background: rgba(122, 180, 255, 0.14);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip {
|
||||
border: 1px solid rgba(212, 227, 244, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--hud-text-soft);
|
||||
padding: 9px 14px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
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-chip:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.24);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.18), transparent 58%),
|
||||
linear-gradient(180deg, rgba(122, 180, 255, 0.16), rgba(82, 123, 186, 0.22));
|
||||
}
|
||||
|
||||
.earth-mobile-settings-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -1042,6 +1414,81 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-kicker {
|
||||
color: rgba(255, 215, 122, 0.82);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-title {
|
||||
color: var(--hud-title);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary-shell {
|
||||
position: relative;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid rgba(255, 215, 122, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)),
|
||||
radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%),
|
||||
rgba(7, 15, 29, 0.42);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%);
|
||||
opacity: 0.42;
|
||||
transform: translateX(-100%);
|
||||
animation: infoCardNewsScan 3.2s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary-label {
|
||||
color: rgba(188, 212, 238, 0.72);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary {
|
||||
color: #d7e6f7;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.72;
|
||||
min-height: 5.2em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.earth-mobile-news-detail-summary.is-typing::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.58em;
|
||||
height: 1.05em;
|
||||
margin-left: 0.16em;
|
||||
vertical-align: -0.14em;
|
||||
background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78));
|
||||
box-shadow: 0 0 10px rgba(255, 215, 122, 0.28);
|
||||
animation: infoCardNewsCaret 0.9s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.earth-mobile-detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1065,14 +1512,28 @@
|
||||
|
||||
.layout-mode-mobile .earth-status-message,
|
||||
.layout-mode-mobile .earth-error-message {
|
||||
top: calc(var(--safe-top) + 130px);
|
||||
position: fixed;
|
||||
top: calc(var(--safe-top) + 10px);
|
||||
left: auto;
|
||||
right: calc(8px + var(--safe-right));
|
||||
transform: translate(0, -10px);
|
||||
right: calc(10px + var(--safe-right));
|
||||
transform: translate(12px, 0);
|
||||
min-width: 0;
|
||||
max-width: min(200px, 52vw);
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 14px 6px 10px;
|
||||
max-width: min(220px, 46vw);
|
||||
border-radius: 16px;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.28;
|
||||
padding: 7px 12px 7px 10px;
|
||||
gap: 8px;
|
||||
border-color: rgba(214, 230, 247, 0.1);
|
||||
border-left-color: transparent;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.06), transparent 52%),
|
||||
linear-gradient(180deg, rgba(17, 29, 46, 0.92), rgba(7, 14, 24, 0.9));
|
||||
box-shadow:
|
||||
0 12px 28px rgba(0, 0, 0, 0.22),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-message.visible,
|
||||
@@ -1080,6 +1541,34 @@
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-error-message {
|
||||
top: calc(var(--safe-top) + 60px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-indicator {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
box-shadow:
|
||||
0 0 6px rgba(145, 186, 255, 0.48),
|
||||
0 0 14px rgba(145, 186, 255, 0.16);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-message.loading {
|
||||
max-width: min(240px, 52vw);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-status-message.loading .earth-status-text {
|
||||
color: rgba(232, 242, 252, 0.92);
|
||||
}
|
||||
|
||||
.hud-panel-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1754,6 +2243,50 @@
|
||||
0 8px 18px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.earth-settings-chip-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.earth-settings-chip {
|
||||
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(6px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
font: inherit;
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-chip:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-chip.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.24);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.earth-settings-slider {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
|
||||
@@ -381,6 +381,113 @@
|
||||
}
|
||||
.info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); }
|
||||
|
||||
.info-card.news .info-card-header {
|
||||
background: rgba(255, 196, 92, 0.12);
|
||||
border-bottom-color: rgba(255, 196, 92, 0.16);
|
||||
}
|
||||
.info-card.news .info-card-header h3 { color: #ffd77a; }
|
||||
|
||||
.info-card.news .info-card-content {
|
||||
padding-top: calc(10px * var(--hud-scale));
|
||||
padding-bottom: calc(12px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-layout {
|
||||
display: grid;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-kicker {
|
||||
color: rgba(255, 215, 122, 0.82);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-card-news-title {
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.96rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
font-weight: 700;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.info-card-news-summary-shell {
|
||||
position: relative;
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
border: 1px solid rgba(255, 215, 122, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)),
|
||||
radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%),
|
||||
rgba(7, 15, 29, 0.42);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-card-news-summary-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%);
|
||||
opacity: 0.42;
|
||||
transform: translateX(-100%);
|
||||
animation: infoCardNewsScan 3.2s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-card-news-summary-label {
|
||||
color: rgba(188, 212, 238, 0.72);
|
||||
font-size: calc(0.6rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: calc(6px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-summary {
|
||||
position: relative;
|
||||
color: #d7e6f7;
|
||||
font-size: calc(0.8rem * var(--hud-scale));
|
||||
line-height: 1.65;
|
||||
min-height: calc(4.8em * var(--hud-scale));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.info-card-news-summary.is-typing::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.58em;
|
||||
height: 1.05em;
|
||||
margin-left: 0.16em;
|
||||
vertical-align: -0.14em;
|
||||
background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78));
|
||||
box-shadow: 0 0 10px rgba(255, 215, 122, 0.28);
|
||||
animation: infoCardNewsCaret 0.9s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes infoCardNewsScan {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes infoCardNewsCaret {
|
||||
0%, 49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layout-expanded: slide left column off-screen ────────────── */
|
||||
|
||||
.earth-app.layout-expanded .earth-left-column {
|
||||
|
||||
@@ -135,6 +135,15 @@
|
||||
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
||||
}
|
||||
|
||||
.news-story-card--cruise {
|
||||
border-color: rgba(255, 213, 128, 0.42);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 248, 220, 0.1), rgba(255, 184, 77, 0.08));
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 213, 128, 0.18) inset,
|
||||
0 0 18px rgba(255, 184, 77, 0.12);
|
||||
}
|
||||
|
||||
.news-story-meta,
|
||||
.news-story-tags {
|
||||
display: flex;
|
||||
|
||||
@@ -475,13 +475,41 @@
|
||||
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
|
||||
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
|
||||
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">图层</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">搜索</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">态势</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">新闻</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">TV</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">设置</button>
|
||||
<div class="earth-mobile-drawer-nav">
|
||||
<div class="earth-mobile-drawer-nav-copy">
|
||||
<span class="earth-mobile-drawer-nav-kicker">Earth Menu</span>
|
||||
<span class="earth-mobile-drawer-nav-title">模块切换</span>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-tabs-shell">
|
||||
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--left" aria-hidden="true"></div>
|
||||
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--right" aria-hidden="true"></div>
|
||||
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
|
||||
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">layers</span>
|
||||
<span class="earth-mobile-drawer-tab-label">图层</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">search</span>
|
||||
<span class="earth-mobile-drawer-tab-label">搜索</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">travel_explore</span>
|
||||
<span class="earth-mobile-drawer-tab-label">态势</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">article</span>
|
||||
<span class="earth-mobile-drawer-tab-label">新闻</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">live_tv</span>
|
||||
<span class="earth-mobile-drawer-tab-label">TV</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">tune</span>
|
||||
<span class="earth-mobile-drawer-tab-label">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-content">
|
||||
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
|
||||
@@ -589,13 +617,6 @@
|
||||
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
|
||||
</div>
|
||||
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
|
||||
<div class="earth-mobile-tv-meta">
|
||||
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
|
||||
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
|
||||
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
|
||||
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-player">
|
||||
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
@@ -608,9 +629,32 @@
|
||||
></iframe>
|
||||
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-actions">
|
||||
<button id="mobile-tv-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
|
||||
<button id="mobile-tv-open-external" class="earth-mobile-action-btn" type="button">访问官网</button>
|
||||
<div id="mobile-tv-overview" class="earth-mobile-tv-overview">
|
||||
<div id="mobile-tv-overview-bar" class="earth-mobile-tv-overview-bar" role="button" tabindex="0" aria-expanded="false" aria-controls="mobile-tv-meta-wrap">
|
||||
<div class="earth-mobile-tv-overview-copy">
|
||||
<span class="earth-mobile-tv-overview-kicker">频道信息</span>
|
||||
<span id="mobile-tv-overview-headline" class="earth-mobile-tv-overview-headline">暂无可用频道</span>
|
||||
<span id="mobile-tv-overview-summary" class="earth-mobile-tv-overview-summary">展开查看当前频道来源、目录和补充说明</span>
|
||||
<div id="mobile-tv-overview-tags" class="earth-mobile-tv-overview-tags" aria-label="频道摘要标签"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-overview-actions">
|
||||
<button id="mobile-tv-refresh" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="刷新频道列表" title="刷新频道列表">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">refresh</span>
|
||||
</button>
|
||||
<button id="mobile-tv-open-external" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="访问频道官网" title="访问频道官网">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-tv-meta-wrap" class="earth-mobile-tv-meta-wrap">
|
||||
<div class="earth-mobile-tv-meta">
|
||||
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
|
||||
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
|
||||
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
|
||||
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -625,13 +669,23 @@
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">旋转模式</span>
|
||||
<span class="earth-mobile-settings-subtitle">巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
<span class="earth-mobile-settings-subtitle">巡航模式会按已启用模块的目标队列轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">巡航模块</span>
|
||||
<span class="earth-mobile-settings-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻可按需加入。</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-chip-group" role="group" aria-label="移动端选择巡航模块">
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-cruise-module-toggle="bgp" aria-pressed="true">BGP</button>
|
||||
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="news" aria-pressed="false">新闻</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">视图</div>
|
||||
@@ -776,7 +830,7 @@
|
||||
<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">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按已启用模块的目标队列轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
||||
<button
|
||||
@@ -797,6 +851,30 @@
|
||||
</button>
|
||||
</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">选择哪些业务模块参与巡航队列。默认 BGP,新闻会按发生地与时间加入巡航目标并显示新闻卡片。</span>
|
||||
</div>
|
||||
<div class="earth-settings-chip-group" role="group" aria-label="选择巡航模块">
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-chip is-active"
|
||||
data-cruise-module-toggle="bgp"
|
||||
aria-pressed="true"
|
||||
>
|
||||
BGP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-chip"
|
||||
data-cruise-module-toggle="news"
|
||||
aria-pressed="false"
|
||||
>
|
||||
新闻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
|
||||
96
frontend/public/earth/js/client-logs.js
Normal file
96
frontend/public/earth/js/client-logs.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { PATHS } from "./constants.js";
|
||||
|
||||
const RECENT_EVENT_TTL_MS = 15_000;
|
||||
const recentEventMap = new Map();
|
||||
|
||||
function normalizeErrorDetail(detail) {
|
||||
if (!detail) return "";
|
||||
if (detail instanceof Error) {
|
||||
return detail.stack || detail.message || String(detail);
|
||||
}
|
||||
if (typeof detail === "string") {
|
||||
return detail;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(detail);
|
||||
} catch {
|
||||
return String(detail);
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeKey(level, message, detail, category) {
|
||||
return `${level}::${category || ""}::${message}::${detail}`;
|
||||
}
|
||||
|
||||
function shouldSkip(level, message, detail, category) {
|
||||
const key = dedupeKey(level, message, detail, category);
|
||||
const now = Date.now();
|
||||
const lastSeenAt = recentEventMap.get(key);
|
||||
recentEventMap.set(key, now);
|
||||
|
||||
for (const [entryKey, entryTime] of recentEventMap.entries()) {
|
||||
if (now - entryTime > RECENT_EVENT_TTL_MS) {
|
||||
recentEventMap.delete(entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
return lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS;
|
||||
}
|
||||
|
||||
export async function reportEarthClientLog({
|
||||
level = "error",
|
||||
message,
|
||||
category = "runtime",
|
||||
module = "earth",
|
||||
detail = "",
|
||||
}) {
|
||||
if (!message) return;
|
||||
const normalizedDetail = normalizeErrorDetail(detail);
|
||||
if (shouldSkip(level, message, normalizedDetail, category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(PATHS.earthClientLogsApi, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
level,
|
||||
message,
|
||||
category,
|
||||
module,
|
||||
url: window.location.href,
|
||||
detail: normalizedDetail.slice(0, 4000),
|
||||
}),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Swallow reporting failures to avoid recursive log noise.
|
||||
}
|
||||
}
|
||||
|
||||
export function registerEarthClientErrorHandlers() {
|
||||
window.addEventListener("error", (event) => {
|
||||
console.error("全局错误:", event.error);
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "window-error",
|
||||
module: "main",
|
||||
message: event.message || "Earth 页面发生未捕获错误",
|
||||
detail: event.error || `${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
console.error("未处理的 Promise 错误:", event.reason);
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "unhandledrejection",
|
||||
module: "main",
|
||||
message: "Earth 页面发生未处理 Promise 错误",
|
||||
detail: event.reason,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,13 @@ export const ROTATION_MODE = {
|
||||
CRUISE: "cruise",
|
||||
};
|
||||
|
||||
export const CRUISE_MODULES = {
|
||||
BGP: "bgp",
|
||||
NEWS: "news",
|
||||
};
|
||||
|
||||
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];
|
||||
|
||||
export const CRUISE_CONFIG = {
|
||||
dwellMs: 7_000,
|
||||
focusDurationMs: 1_400,
|
||||
@@ -160,6 +167,7 @@ export const PATHS = {
|
||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||
earthClientLogsApi: '/api/v1/system/logs/earth-client',
|
||||
};
|
||||
|
||||
export const COMPUTE_CENTER_CONFIG = {
|
||||
|
||||
116
frontend/public/earth/js/controls.js
vendored
116
frontend/public/earth/js/controls.js
vendored
@@ -1,7 +1,13 @@
|
||||
// controls.js - Zoom, rotate and toggle controls
|
||||
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js";
|
||||
import {
|
||||
CONFIG,
|
||||
CRUISE_MODULES,
|
||||
DEFAULT_CRUISE_MODULES,
|
||||
EARTH_CONFIG,
|
||||
ROTATION_MODE,
|
||||
} from "./constants.js";
|
||||
import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js";
|
||||
import {
|
||||
toggleTerrain,
|
||||
@@ -109,6 +115,7 @@ let activeMobileDrawerId = null;
|
||||
let mobileDrawerOpen = false;
|
||||
let mobileDrawerCard = "layers";
|
||||
let mobileDrawerHintTimer = null;
|
||||
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
|
||||
|
||||
function detectLayoutMode() {
|
||||
const width = window.innerWidth;
|
||||
@@ -633,6 +640,7 @@ function getCurrentPanelVisibilitySnapshot() {
|
||||
function getCurrentSharedSettingsSnapshot() {
|
||||
return {
|
||||
rotationMode,
|
||||
cruiseModules: getCruiseModules(),
|
||||
layerVisibility: Object.fromEntries(
|
||||
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
|
||||
),
|
||||
@@ -667,6 +675,7 @@ function cloneEarthSettings(settings) {
|
||||
version: 2,
|
||||
shared: {
|
||||
rotationMode: settings.shared.rotationMode,
|
||||
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
|
||||
terrainOpacity: settings.shared.terrainOpacity,
|
||||
dayNightEnabled: settings.shared.dayNightEnabled,
|
||||
defaultEarthZoom: settings.shared.defaultEarthZoom,
|
||||
@@ -738,6 +747,14 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
|
||||
? ROTATION_MODE.CRUISE
|
||||
: defaults.shared.rotationMode;
|
||||
const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules)
|
||||
? sharedSettings.cruiseModules
|
||||
: defaults.shared.cruiseModules;
|
||||
const nextCruiseModules = Array.from(
|
||||
new Set(
|
||||
requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)),
|
||||
),
|
||||
);
|
||||
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
|
||||
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
|
||||
? sharedSettings.dayNightEnabled
|
||||
@@ -750,6 +767,9 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
version: 2,
|
||||
shared: {
|
||||
rotationMode: nextRotationMode,
|
||||
cruiseModules: nextCruiseModules.length > 0
|
||||
? nextCruiseModules
|
||||
: [...DEFAULT_CRUISE_MODULES],
|
||||
layerVisibility: normalizedLayerVisibility,
|
||||
terrainOpacity: Number.isFinite(nextTerrainOpacity)
|
||||
? nextTerrainOpacity
|
||||
@@ -832,6 +852,79 @@ function persistEarthSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchCruiseModulesChange() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("earth:cruise-modules-change", {
|
||||
detail: {
|
||||
modules: getCruiseModules(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCruiseModules(nextModules) {
|
||||
const sourceModules = Array.isArray(nextModules) ? nextModules : DEFAULT_CRUISE_MODULES;
|
||||
const normalizedModules = Array.from(
|
||||
new Set(sourceModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId))),
|
||||
);
|
||||
return normalizedModules.length > 0
|
||||
? normalizedModules
|
||||
: [...DEFAULT_CRUISE_MODULES];
|
||||
}
|
||||
|
||||
function syncCruiseModuleControls() {
|
||||
const enabledModules = new Set(getCruiseModules());
|
||||
document.querySelectorAll("[data-cruise-module-toggle]").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const moduleId = button.dataset.cruiseModuleToggle || "";
|
||||
const active = enabledModules.has(moduleId);
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
export function getCruiseModules() {
|
||||
const configuredModules = earthSettingsState?.shared?.cruiseModules;
|
||||
return normalizeCruiseModules(configuredModules);
|
||||
}
|
||||
|
||||
export function isCruiseModuleEnabled(moduleId) {
|
||||
return getCruiseModules().includes(moduleId);
|
||||
}
|
||||
|
||||
export function setCruiseModules(nextModules, { persist = true, suppressStatus = false } = {}) {
|
||||
const normalizedModules = normalizeCruiseModules(nextModules);
|
||||
const previousModules = getCruiseModules();
|
||||
const changed =
|
||||
normalizedModules.length !== previousModules.length ||
|
||||
normalizedModules.some((moduleId, index) => previousModules[index] !== moduleId);
|
||||
|
||||
if (!changed) {
|
||||
syncCruiseModuleControls();
|
||||
return normalizedModules;
|
||||
}
|
||||
|
||||
earthSettingsState = cloneEarthSettings(
|
||||
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
|
||||
);
|
||||
earthSettingsState.shared.cruiseModules = [...normalizedModules];
|
||||
syncCruiseModuleControls();
|
||||
dispatchCruiseModulesChange();
|
||||
|
||||
if (persist) {
|
||||
persistEarthSettings();
|
||||
}
|
||||
|
||||
if (!suppressStatus) {
|
||||
const labels = normalizedModules.map((moduleId) =>
|
||||
moduleId === CRUISE_MODULES.NEWS ? "新闻" : "BGP",
|
||||
);
|
||||
showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info");
|
||||
}
|
||||
|
||||
return normalizedModules;
|
||||
}
|
||||
|
||||
function syncDefaultEarthZoomUi(nextZoom) {
|
||||
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
|
||||
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
|
||||
@@ -894,6 +987,7 @@ async function applyEarthSettings(settings) {
|
||||
});
|
||||
|
||||
setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true });
|
||||
setCruiseModules(settings.shared.cruiseModules, { persist: false, suppressStatus: true });
|
||||
|
||||
if (typeof settings.shared.dayNightEnabled === "boolean") {
|
||||
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
|
||||
@@ -1702,6 +1796,7 @@ function setupSettingsControls() {
|
||||
const terrainOpacityValues = document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]");
|
||||
const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
|
||||
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
|
||||
const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]");
|
||||
const syncTerrainOpacityUi = (nextOpacity) => {
|
||||
const safeOpacity = Math.round(nextOpacity * 100);
|
||||
terrainOpacitySliders.forEach((slider) => {
|
||||
@@ -1756,6 +1851,24 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
cruiseModuleButtons.forEach((button) => {
|
||||
bindListener(button, "click", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
const moduleId = target.dataset.cruiseModuleToggle;
|
||||
if (!moduleId) return;
|
||||
|
||||
const currentModules = new Set(getCruiseModules());
|
||||
if (currentModules.has(moduleId)) {
|
||||
currentModules.delete(moduleId);
|
||||
} else {
|
||||
currentModules.add(moduleId);
|
||||
}
|
||||
|
||||
setCruiseModules(Array.from(currentModules));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
|
||||
if (!(dayNightToggle instanceof HTMLInputElement)) return;
|
||||
bindListener(dayNightToggle, "change", () => {
|
||||
@@ -1772,6 +1885,7 @@ function setupSettingsControls() {
|
||||
applyEarthSettings(loadEarthSettings());
|
||||
syncAllHudPanelToggles();
|
||||
syncRotationModeButtons();
|
||||
syncCruiseModuleControls();
|
||||
syncDayNightToggle(dayNightEnabled);
|
||||
}
|
||||
|
||||
|
||||
@@ -201,7 +201,6 @@ export function createClouds(scene, earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
transparent: true,
|
||||
linewidth: 2,
|
||||
opacity: 0.15,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
@@ -239,7 +238,6 @@ export function createTerrain(earthObj) {
|
||||
specular: TERRAIN_CONFIG.specular,
|
||||
shininess: TERRAIN_CONFIG.shininess,
|
||||
vertexColors: true,
|
||||
vertexAlphas: true,
|
||||
transparent: true,
|
||||
opacity: TERRAIN_CONFIG.opacity,
|
||||
flatShading: false,
|
||||
|
||||
@@ -3,6 +3,186 @@ import { showStatusMessage } from './ui.js';
|
||||
|
||||
let currentType = null;
|
||||
let cardMounted = false;
|
||||
let typewriterTimerId = null;
|
||||
let typewriterToken = 0;
|
||||
let pendingMobileDetailState = null;
|
||||
let mobileDetailsListenerBound = false;
|
||||
let renderedMobileDetailKey = null;
|
||||
|
||||
function getNewsSummaryText(data) {
|
||||
return (data?.summary || data?.title || '').trim() || '暂无摘要';
|
||||
}
|
||||
|
||||
function getNewsSummaryPreview(data, maxLength = 34) {
|
||||
const text = getNewsSummaryText(data).replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||
}
|
||||
|
||||
function stopTypewriterAnimation() {
|
||||
typewriterToken += 1;
|
||||
if (typewriterTimerId) {
|
||||
window.clearTimeout(typewriterTimerId);
|
||||
typewriterTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startTypewriterAnimation(target, text, options = {}) {
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
stopTypewriterAnimation();
|
||||
|
||||
const content = typeof text === 'string' ? text : '';
|
||||
const token = typewriterToken;
|
||||
const stepMs = Number.isFinite(options.stepMs) ? options.stepMs : 22;
|
||||
const startDelayMs = Number.isFinite(options.startDelayMs) ? options.startDelayMs : 90;
|
||||
|
||||
target.textContent = '';
|
||||
target.classList.add('is-typing');
|
||||
|
||||
let index = 0;
|
||||
const tick = () => {
|
||||
if (token !== typewriterToken) return;
|
||||
index += 1;
|
||||
target.textContent = content.slice(0, index);
|
||||
if (index < content.length) {
|
||||
typewriterTimerId = window.setTimeout(tick, stepMs);
|
||||
return;
|
||||
}
|
||||
target.classList.remove('is-typing');
|
||||
typewriterTimerId = null;
|
||||
};
|
||||
|
||||
typewriterTimerId = window.setTimeout(() => {
|
||||
if (token !== typewriterToken) return;
|
||||
if (!content) {
|
||||
target.classList.remove('is-typing');
|
||||
typewriterTimerId = null;
|
||||
return;
|
||||
}
|
||||
tick();
|
||||
}, startDelayMs);
|
||||
}
|
||||
|
||||
function renderNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
content.innerHTML = `
|
||||
<div class="info-card-news-layout">
|
||||
<div class="info-card-news-kicker">NEWS SIGNAL</div>
|
||||
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="info-card-news-summary-shell">
|
||||
<div class="info-card-news-summary-label">SUMMARY</div>
|
||||
<div class="info-card-news-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const summaryEl = content.querySelector('[data-news-summary]');
|
||||
startTypewriterAnimation(summaryEl, summary);
|
||||
}
|
||||
|
||||
function renderMobileNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
content.innerHTML = `
|
||||
<div class="earth-mobile-news-detail">
|
||||
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
|
||||
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="earth-mobile-news-detail-summary-shell">
|
||||
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
|
||||
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const summaryEl = content.querySelector('[data-news-summary]');
|
||||
startTypewriterAnimation(summaryEl, summary, { stepMs: 20, startDelayMs: 70 });
|
||||
}
|
||||
|
||||
function renderMobileDetailContent(type, config, data) {
|
||||
const content = document.getElementById('mobile-info-card-content');
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
|
||||
stopTypewriterAnimation();
|
||||
if (type === 'news') {
|
||||
renderMobileNewsCardContent(content, data);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
if (field.unit && value !== '-') value = value + ' ' + field.unit;
|
||||
html += `
|
||||
<div class="earth-mobile-detail-row">
|
||||
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||
<span class="earth-mobile-detail-row-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
function getMobileDetailRenderKey(type, data) {
|
||||
if (type !== 'news') return null;
|
||||
return [
|
||||
type,
|
||||
data?.id ?? '',
|
||||
data?.url ?? '',
|
||||
data?.published_at ?? '',
|
||||
data?.title ?? '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function ensureMobileDetailsListener() {
|
||||
if (mobileDetailsListenerBound) return;
|
||||
mobileDetailsListenerBound = true;
|
||||
|
||||
window.addEventListener('earth:open-details-tab', () => {
|
||||
if (!document.body.classList.contains('layout-mode-mobile')) return;
|
||||
if (!pendingMobileDetailState) return;
|
||||
const nextKey = getMobileDetailRenderKey(
|
||||
pendingMobileDetailState.type,
|
||||
pendingMobileDetailState.data,
|
||||
);
|
||||
if (nextKey && nextKey === renderedMobileDetailKey) return;
|
||||
renderMobileDetailContent(
|
||||
pendingMobileDetailState.type,
|
||||
pendingMobileDetailState.config,
|
||||
pendingMobileDetailState.data,
|
||||
);
|
||||
renderedMobileDetailKey = nextKey;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDefaultCardContent(content, config, data) {
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Mobile popup ─────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +192,7 @@ function getMobilePopupTitle(type, data) {
|
||||
case 'landing_point': return data.name || '登陆点';
|
||||
case 'satellite': return data.name || '卫星';
|
||||
case 'bgp': return data.anomaly_type || 'BGP事件';
|
||||
case 'news': return data.title || '新闻事件';
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
case 'gpu_cluster': return data.name || 'GPU集群';
|
||||
@@ -25,6 +206,7 @@ function getMobilePopupSubtitle(type, data) {
|
||||
case 'landing_point': return data.country || '登陆点';
|
||||
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
|
||||
case 'bgp': return data.severity || 'BGP路由异常';
|
||||
case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻';
|
||||
case 'bgp_collector': return data.location || 'BGP观测站';
|
||||
case 'supercomputer': return data.country || '超级计算机';
|
||||
case 'gpu_cluster': return data.country || 'GPU集群';
|
||||
@@ -280,6 +462,20 @@ const CARD_CONFIG = {
|
||||
{ key: 'summary', label: '摘要' }
|
||||
]
|
||||
},
|
||||
news: {
|
||||
icon: '📰',
|
||||
title: '新闻事件详情',
|
||||
className: 'news',
|
||||
fields: [
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'published_at_display', label: '发布时间' },
|
||||
{ key: 'location_label', label: '发生地' },
|
||||
{ key: 'region_label', label: '区域' },
|
||||
{ key: 'feed_name', label: '聚合源' },
|
||||
{ key: 'summary', label: '摘要' },
|
||||
{ key: 'url', label: '原文链接' }
|
||||
]
|
||||
},
|
||||
bgp_collector: {
|
||||
icon: '📍',
|
||||
title: 'BGP观测站详情',
|
||||
@@ -616,6 +812,8 @@ export function showInfoCard(type, data, options = {}) {
|
||||
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
currentType = type;
|
||||
pendingMobileDetailState = { type, config, data };
|
||||
ensureMobileDetailsListener();
|
||||
|
||||
// Fill drawer details slot (accessible when user taps popup → opens details tab)
|
||||
const icon = document.getElementById('mobile-info-card-icon');
|
||||
@@ -624,27 +822,20 @@ export function showInfoCard(type, data, options = {}) {
|
||||
const content = document.getElementById('mobile-info-card-content');
|
||||
|
||||
if (icon) icon.textContent = config.icon;
|
||||
if (title) title.textContent = config.title;
|
||||
if (typeLabel) typeLabel.textContent = type.replaceAll('_', ' ');
|
||||
if (title) {
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
: config.title;
|
||||
}
|
||||
if (typeLabel) {
|
||||
typeLabel.textContent = type === 'news'
|
||||
? 'news signal'
|
||||
: type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
if (content) {
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
if (field.unit && value !== '-') value = value + ' ' + field.unit;
|
||||
html += `
|
||||
<div class="earth-mobile-detail-row">
|
||||
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||
<span class="earth-mobile-detail-row-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
if (content && type !== 'news') {
|
||||
renderMobileDetailContent(type, config, data);
|
||||
renderedMobileDetailKey = null;
|
||||
}
|
||||
|
||||
// Show the floating mini popup near the touch point (requires coordinates)
|
||||
@@ -667,37 +858,23 @@ export function showInfoCard(type, data, options = {}) {
|
||||
const title = document.getElementById('info-card-title');
|
||||
const content = document.getElementById('info-card-content');
|
||||
|
||||
stopTypewriterAnimation();
|
||||
card.className = 'info-card ' + config.className;
|
||||
icon.textContent = config.icon;
|
||||
title.textContent = config.title;
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
: config.title;
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
if (type === 'news') {
|
||||
renderNewsCardContent(content, data);
|
||||
} else {
|
||||
renderDefaultCardContent(content, config, data);
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
showPanel(options.x, options.y, options);
|
||||
}
|
||||
|
||||
export function hideInfoCard() {
|
||||
stopTypewriterAnimation();
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
hideMobilePopup();
|
||||
document.body.classList.remove('earth-info-open');
|
||||
@@ -705,6 +882,8 @@ export function hideInfoCard() {
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||
);
|
||||
currentType = null;
|
||||
pendingMobileDetailState = null;
|
||||
renderedMobileDetailKey = null;
|
||||
return;
|
||||
}
|
||||
hidePanel();
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
||||
|
||||
import {
|
||||
CONFIG,
|
||||
CRUISE_MODULES,
|
||||
HUD_CONFIG,
|
||||
CABLE_CONFIG,
|
||||
CABLE_STATE,
|
||||
@@ -147,6 +148,7 @@ import {
|
||||
import {
|
||||
setupControls,
|
||||
getAutoRotate,
|
||||
getCruiseModules,
|
||||
getRotationMode,
|
||||
getShowTerrain,
|
||||
getStartupLoadLayers,
|
||||
@@ -167,6 +169,7 @@ import {
|
||||
import { CalloutConnector } from "./callout-connector.js";
|
||||
import { CruiseSequencer } from "./cruise-sequencer.js";
|
||||
import { createBGPCruiseAdapter } from "./bgp-cruise-adapter.js";
|
||||
import { createNewsCruiseAdapter } from "./news-cruise-adapter.js";
|
||||
import {
|
||||
initInfoCard,
|
||||
showInfoCard,
|
||||
@@ -182,6 +185,10 @@ import { mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
|
||||
import { initSearchPanel } from "./search.js";
|
||||
import {
|
||||
registerEarthClientErrorHandlers,
|
||||
reportEarthClientLog,
|
||||
} from "./client-logs.js";
|
||||
|
||||
export let scene;
|
||||
export let camera;
|
||||
@@ -224,6 +231,7 @@ let sceneLights = null;
|
||||
let cruisePollTimerId = null;
|
||||
let calloutConnector = null;
|
||||
let cruiseBGPAdapter = null;
|
||||
let cruiseNewsAdapter = null;
|
||||
let cruiseSequencer = null;
|
||||
let activeDragPointerId = null;
|
||||
let activeTouchPoints = new Map();
|
||||
@@ -1256,6 +1264,143 @@ function ensureBGPCruiseAdapter() {
|
||||
return cruiseBGPAdapter;
|
||||
}
|
||||
|
||||
function ensureNewsCruiseAdapter() {
|
||||
if (cruiseNewsAdapter) return cruiseNewsAdapter;
|
||||
|
||||
cruiseNewsAdapter = createNewsCruiseAdapter({
|
||||
camera,
|
||||
earth: () => getEarth(),
|
||||
connector: ensureCalloutConnector(),
|
||||
focusView: (options) => focusEarthView(camera, options),
|
||||
});
|
||||
|
||||
return cruiseNewsAdapter;
|
||||
}
|
||||
|
||||
function getCruiseModuleDefinitions() {
|
||||
return [
|
||||
{
|
||||
id: CRUISE_MODULES.BGP,
|
||||
ensureAdapter: ensureBGPCruiseAdapter,
|
||||
isQueueSourceActive: () => getShowBGP(),
|
||||
getSortedQueueItems: () =>
|
||||
ensureBGPCruiseAdapter()
|
||||
.getSortedMarkers()
|
||||
.map((marker) => {
|
||||
const markerId = marker?.userData?.id;
|
||||
if (!markerId) return null;
|
||||
const parsed = marker?.userData?.created_at_raw || marker?.userData?.created_at;
|
||||
const timestamp = parsed ? new Date(parsed).getTime() : 0;
|
||||
return {
|
||||
id: `bgp:${markerId}`,
|
||||
moduleId: CRUISE_MODULES.BGP,
|
||||
sortTimestamp: Number.isFinite(timestamp) ? timestamp : 0,
|
||||
payload: marker,
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
focusQueueItem: (item, options = {}) =>
|
||||
ensureBGPCruiseAdapter().focusMarker(item?.payload, options),
|
||||
presentQueueItem: (item, options = {}) =>
|
||||
ensureBGPCruiseAdapter().presentMarker(item?.payload, options),
|
||||
hidePresentation: (options = {}) =>
|
||||
ensureBGPCruiseAdapter().hidePresentation(options),
|
||||
clearCurrentHighlight: () => ensureBGPCruiseAdapter().clearCurrentHighlight(),
|
||||
resetPresentation: () => ensureBGPCruiseAdapter().resetPresentation(),
|
||||
repositionPresentation: (item) =>
|
||||
ensureBGPCruiseAdapter().repositionConnector(item?.payload),
|
||||
syncKnownEventIds: () => ensureBGPCruiseAdapter().syncKnownEventIds(),
|
||||
pollForNewQueueItemIds: async () => {
|
||||
const nextIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds();
|
||||
return nextIds.map((id) => `bgp:${id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: CRUISE_MODULES.NEWS,
|
||||
ensureAdapter: ensureNewsCruiseAdapter,
|
||||
isQueueSourceActive: () => true,
|
||||
ensureItemsLoaded: () => ensureNewsCruiseAdapter().ensureItemsLoaded(),
|
||||
getSortedQueueItems: () =>
|
||||
ensureNewsCruiseAdapter()
|
||||
.getSortedItems()
|
||||
.map((item) => ({
|
||||
...item,
|
||||
moduleId: CRUISE_MODULES.NEWS,
|
||||
payload: item,
|
||||
})),
|
||||
focusQueueItem: (item, options = {}) =>
|
||||
ensureNewsCruiseAdapter().focusItem(item?.payload, options),
|
||||
presentQueueItem: (item, options = {}) =>
|
||||
ensureNewsCruiseAdapter().presentItem(item?.payload, options),
|
||||
hidePresentation: (options = {}) =>
|
||||
ensureNewsCruiseAdapter().hidePresentation(options),
|
||||
clearCurrentHighlight: () => ensureNewsCruiseAdapter().clearCurrentHighlight(),
|
||||
resetPresentation: () => ensureNewsCruiseAdapter().resetPresentation(),
|
||||
repositionPresentation: (item) =>
|
||||
ensureNewsCruiseAdapter().repositionConnector(item?.payload),
|
||||
syncKnownEventIds: () => ensureNewsCruiseAdapter().syncKnownEventIds(),
|
||||
externalEventName: "earth:news-payload-updated",
|
||||
extractQueuedItemIdsFromEvent: (event) => {
|
||||
const itemIds = Array.isArray(event?.detail?.itemIds) ? event.detail.itemIds : [];
|
||||
if (itemIds.length === 0) return [];
|
||||
const adapter = ensureNewsCruiseAdapter();
|
||||
const newIds = adapter.diffNewEventIds(itemIds);
|
||||
adapter.syncKnownEventIds();
|
||||
return newIds;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getCruiseModuleDefinition(moduleId) {
|
||||
return getCruiseModuleDefinitions().find((module) => module.id === moduleId) || null;
|
||||
}
|
||||
|
||||
function getEnabledCruiseModuleDefinitions() {
|
||||
const enabledModuleIds = new Set(getCruiseModules());
|
||||
return getCruiseModuleDefinitions().filter((module) => enabledModuleIds.has(module.id));
|
||||
}
|
||||
|
||||
function sortCruiseQueueItems(items) {
|
||||
return items.sort((left, right) => {
|
||||
const timestampDiff = (right?.sortTimestamp || 0) - (left?.sortTimestamp || 0);
|
||||
if (timestampDiff !== 0) return timestampDiff;
|
||||
return String(left?.id || "").localeCompare(String(right?.id || ""));
|
||||
});
|
||||
}
|
||||
|
||||
function getCruiseQueueItemsSorted() {
|
||||
const items = [];
|
||||
getEnabledCruiseModuleDefinitions().forEach((module) => {
|
||||
if (!module.isQueueSourceActive?.()) return;
|
||||
items.push(...(module.getSortedQueueItems?.() || []));
|
||||
});
|
||||
return sortCruiseQueueItems(items);
|
||||
}
|
||||
|
||||
function getCruiseModuleForItem(item) {
|
||||
return getCruiseModuleDefinition(item?.moduleId);
|
||||
}
|
||||
|
||||
function getCurrentCruiseBGPMarker() {
|
||||
const item = cruiseSequencer?.getCurrentItem() ?? null;
|
||||
return item?.moduleId === CRUISE_MODULES.BGP ? item.payload || null : null;
|
||||
}
|
||||
|
||||
async function syncCruiseModuleKnownEventIds() {
|
||||
const modules = getEnabledCruiseModuleDefinitions();
|
||||
await Promise.all(
|
||||
modules.map(async (module) => {
|
||||
try {
|
||||
await module.ensureItemsLoaded?.();
|
||||
module.syncKnownEventIds?.();
|
||||
} catch (error) {
|
||||
console.warn(`同步巡航模块失败: ${module.id}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isCruisePresentationPinned() {
|
||||
return cruiseSequencer?.isPresentationPinned() === true;
|
||||
}
|
||||
@@ -1265,22 +1410,23 @@ function setCruisePresentationVisible(visible) {
|
||||
cruiseSequencer.setPresentationVisible(visible);
|
||||
}
|
||||
if (!visible) {
|
||||
ensureBGPCruiseAdapter().resetPresentation();
|
||||
getCruiseModuleDefinitions().forEach((module) => {
|
||||
module.resetPresentation?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearCruiseMarkerHighlight() {
|
||||
ensureBGPCruiseAdapter().clearCurrentHighlight();
|
||||
}
|
||||
|
||||
function getCruiseMarkersSorted() {
|
||||
return ensureBGPCruiseAdapter().getSortedMarkers();
|
||||
function clearCruiseHighlights() {
|
||||
getCruiseModuleDefinitions().forEach((module) => {
|
||||
module.clearCurrentHighlight?.();
|
||||
});
|
||||
}
|
||||
|
||||
function repositionCruiseConnector() {
|
||||
if (!isCruisePresentationPinned()) return;
|
||||
const marker = cruiseSequencer?.getCurrentItem() ?? null;
|
||||
ensureBGPCruiseAdapter().repositionConnector(marker);
|
||||
const item = cruiseSequencer?.getCurrentItem() ?? null;
|
||||
const module = getCruiseModuleForItem(item);
|
||||
module?.repositionPresentation?.(item);
|
||||
}
|
||||
|
||||
function isCruiseModeActive() {
|
||||
@@ -1292,12 +1438,12 @@ function ensureCruiseSequencer() {
|
||||
|
||||
cruiseSequencer = new CruiseSequencer({
|
||||
isActive: () => isCruiseModeActive() && getAutoRotate(),
|
||||
getItems: () => getCruiseMarkersSorted(),
|
||||
getItemId: (marker) => marker?.userData?.id || null,
|
||||
getItems: () => getCruiseQueueItemsSorted(),
|
||||
getItemId: (item) => item?.id || null,
|
||||
dwellMs: CRUISE_CONFIG.dwellMs,
|
||||
transitionGapMs: CRUISE_TRANSITION_GAP_MS,
|
||||
clearCurrent: () => {
|
||||
clearCruiseMarkerHighlight();
|
||||
clearCruiseHighlights();
|
||||
clearLockedObject();
|
||||
hideInfoCard();
|
||||
setCruisePresentationVisible(false);
|
||||
@@ -1308,11 +1454,11 @@ function ensureCruiseSequencer() {
|
||||
hideInfoCard();
|
||||
}
|
||||
},
|
||||
focusItem: async (marker, { interrupt }) =>
|
||||
ensureBGPCruiseAdapter().focusMarker(marker, { interrupt }),
|
||||
presentItem: async (marker, { context }) => {
|
||||
focusItem: async (item, { interrupt }) =>
|
||||
getCruiseModuleForItem(item)?.focusQueueItem?.(item, { interrupt }),
|
||||
presentItem: async (item, { context }) => {
|
||||
setCruisePresentationVisible(true);
|
||||
const presented = await ensureBGPCruiseAdapter().presentMarker(marker, {
|
||||
const presented = await getCruiseModuleForItem(item)?.presentQueueItem?.(item, {
|
||||
context,
|
||||
});
|
||||
if (!presented) {
|
||||
@@ -1320,8 +1466,8 @@ function ensureCruiseSequencer() {
|
||||
}
|
||||
return presented;
|
||||
},
|
||||
hideItem: async (_marker, { context }) => {
|
||||
await ensureBGPCruiseAdapter().hidePresentation({ context });
|
||||
hideItem: async (item, { context }) => {
|
||||
await getCruiseModuleForItem(item)?.hidePresentation?.({ context });
|
||||
setCruisePresentationVisible(false);
|
||||
},
|
||||
});
|
||||
@@ -1347,10 +1493,23 @@ async function advanceCruiseEvent({ interrupt = false } = {}) {
|
||||
}
|
||||
|
||||
async function pollCruiseEventsIfNeeded() {
|
||||
if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return;
|
||||
if (!isCruiseModeActive() || !getAutoRotate()) return;
|
||||
|
||||
try {
|
||||
const newIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds();
|
||||
const modules = getEnabledCruiseModuleDefinitions().filter(
|
||||
(module) => module.isQueueSourceActive?.() && typeof module.pollForNewQueueItemIds === "function",
|
||||
);
|
||||
const results = await Promise.all(
|
||||
modules.map(async (module) => {
|
||||
try {
|
||||
return await module.pollForNewQueueItemIds();
|
||||
} catch (error) {
|
||||
console.warn(`巡航模式轮询失败: ${module.id}`, error);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
const newIds = results.flat();
|
||||
if (newIds.length === 0) return;
|
||||
|
||||
ensureCruiseSequencer().enqueue(newIds);
|
||||
@@ -1358,7 +1517,7 @@ async function pollCruiseEventsIfNeeded() {
|
||||
await advanceCruiseEvent({ interrupt: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("巡航模式轮询 BGP 事件失败:", error);
|
||||
console.warn("巡航模式轮询失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1390,16 +1549,63 @@ function handleRotationModeChange(event) {
|
||||
}
|
||||
|
||||
ensureCruisePolling();
|
||||
ensureBGPCruiseAdapter().syncKnownEventIds();
|
||||
|
||||
if (!detailActive) {
|
||||
stopCruiseMode({ preserveCard: true });
|
||||
return;
|
||||
}
|
||||
|
||||
advanceCruiseEvent({ interrupt: true }).catch((error) => {
|
||||
console.warn("启动巡航模式失败:", error);
|
||||
syncCruiseModuleKnownEventIds()
|
||||
.catch((error) => {
|
||||
console.warn("同步巡航模块失败:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
advanceCruiseEvent({ interrupt: true }).catch((error) => {
|
||||
console.warn("启动巡航模式失败:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleCruiseModulesChange() {
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearBGPSelection();
|
||||
|
||||
const syncPromise = syncCruiseModuleKnownEventIds().catch((error) => {
|
||||
console.warn("刷新巡航模块失败:", error);
|
||||
});
|
||||
|
||||
if (!isCruiseModeActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getAutoRotate()) {
|
||||
stopCruiseMode({ preserveCard: true });
|
||||
return;
|
||||
}
|
||||
|
||||
syncPromise.finally(() => {
|
||||
advanceCruiseEvent({ interrupt: true }).catch((error) => {
|
||||
console.warn("切换巡航模块后推进失败:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleCruiseModuleItemsUpdated(moduleId, event) {
|
||||
const module = getCruiseModuleDefinition(moduleId);
|
||||
if (!module) return;
|
||||
|
||||
const newIds = module.extractQueuedItemIdsFromEvent?.(event) || [];
|
||||
if (newIds.length === 0) return;
|
||||
|
||||
if (!isCruiseModeActive() || !getAutoRotate() || !getCruiseModules().includes(moduleId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureCruiseSequencer().enqueue(newIds);
|
||||
if (!ensureCruiseSequencer().isBusy()) {
|
||||
advanceCruiseEvent({ interrupt: true }).catch((error) => {
|
||||
console.warn(`推进巡航模块失败: ${moduleId}`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelectionAndInfo() {
|
||||
@@ -1726,13 +1932,7 @@ function getCurrentViewCenterCoords() {
|
||||
return vector3ToLatLon(scratchViewCenterWorld);
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
console.error("全局错误:", event.error);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
console.error("未处理的 Promise 错误:", event.reason);
|
||||
});
|
||||
registerEarthClientErrorHandlers();
|
||||
|
||||
export function init() {
|
||||
if (initialized && !destroyed) return;
|
||||
@@ -1984,6 +2184,13 @@ async function loadData() {
|
||||
getSatelliteHydrationToken: () => satelliteHydrationToken,
|
||||
reportError: (label, reason) => {
|
||||
errors.push({ label, reason });
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "startup-load",
|
||||
module: "layer-startup",
|
||||
message: `${label}加载失败: ${reason?.message || String(reason)}`,
|
||||
detail: reason,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2087,6 +2294,13 @@ export async function setCablesEnabled(
|
||||
clearCableData(getEarth());
|
||||
updateCableToggleUi(false);
|
||||
const message = `线缆加载失败: ${error?.message || String(error)}`;
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "layer-toggle",
|
||||
module: "cables",
|
||||
message,
|
||||
detail: error,
|
||||
});
|
||||
if (!suppressLoadingUi) {
|
||||
showError(message);
|
||||
}
|
||||
@@ -2133,6 +2347,13 @@ export async function setSatellitesEnabled(
|
||||
resetSatelliteState();
|
||||
updateSatelliteToggleUi(false, 0);
|
||||
const message = `卫星加载失败: ${error?.message || String(error)}`;
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "layer-toggle",
|
||||
module: "satellites",
|
||||
message,
|
||||
detail: error,
|
||||
});
|
||||
if (!suppressLoadingUi) {
|
||||
showError(message);
|
||||
}
|
||||
@@ -2156,13 +2377,21 @@ function setupEventListeners() {
|
||||
const handleClick = (event) => onClick(event);
|
||||
const handlePageHide = () => destroy();
|
||||
const handleRotationMode = (event) => handleRotationModeChange(event);
|
||||
const handleCruiseModules = () => handleCruiseModulesChange();
|
||||
const handleInfoCardDrag = () => repositionCruiseConnector();
|
||||
|
||||
bindListener(window, "resize", handleResize);
|
||||
bindListener(window, "pagehide", handlePageHide);
|
||||
bindListener(window, "beforeunload", handlePageHide);
|
||||
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
|
||||
bindListener(window, "earth:cruise-modules-change", handleCruiseModules);
|
||||
bindListener(window, "earth:info-card-drag", handleInfoCardDrag);
|
||||
getCruiseModuleDefinitions().forEach((module) => {
|
||||
if (!module.externalEventName) return;
|
||||
bindListener(window, module.externalEventName, (event) => {
|
||||
handleCruiseModuleItemsUpdated(module.id, event);
|
||||
});
|
||||
});
|
||||
bindListener(renderer.domElement, "pointerdown", handlePointerDown);
|
||||
bindListener(window, "pointermove", handlePointerMove);
|
||||
bindListener(window, "pointerup", handlePointerUp);
|
||||
@@ -2860,7 +3089,7 @@ function animate() {
|
||||
applyCableVisualState();
|
||||
const activeCruiseMarker =
|
||||
isCruiseModeActive() && isCruisePresentationPinned()
|
||||
? cruiseSequencer?.getCurrentItem() ?? null
|
||||
? getCurrentCruiseBGPMarker()
|
||||
: null;
|
||||
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
||||
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
|
||||
|
||||
357
frontend/public/earth/js/news-cruise-adapter.js
Normal file
357
frontend/public/earth/js/news-cruise-adapter.js
Normal file
@@ -0,0 +1,357 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
|
||||
import { showInfoCard, hideInfoCard } from "./info-card.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import {
|
||||
createConnectorPath,
|
||||
resolveConnectorAnchor,
|
||||
} from "./callout-connector.js";
|
||||
import {
|
||||
ensureNewsPanelReady,
|
||||
getNewsPayload,
|
||||
selectNewsItem,
|
||||
clearSelectedNewsItem,
|
||||
} from "./news.js";
|
||||
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||
const MOBILE_CARD_MARGIN_PX = 14;
|
||||
const MOBILE_CARD_TOP_RATIO = 0.16;
|
||||
const MOBILE_CARD_WIDTH_PX = 220;
|
||||
const scratchNewsWorldPosition = new THREE.Vector3();
|
||||
|
||||
const REGION_LABELS = {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
};
|
||||
|
||||
function getItemTimestamp(item) {
|
||||
const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatPublishedAt(rawValue) {
|
||||
if (!rawValue) return "刚刚同步";
|
||||
const parsed = new Date(rawValue);
|
||||
if (Number.isNaN(parsed.getTime())) return "刚刚同步";
|
||||
return parsed.toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getCardPlacement() {
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const safeBottom =
|
||||
parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
|
||||
) || 0;
|
||||
const width = Math.min(MOBILE_CARD_WIDTH_PX, window.innerWidth - MOBILE_CARD_MARGIN_PX * 2);
|
||||
return {
|
||||
x: Math.max(MOBILE_CARD_MARGIN_PX, window.innerWidth - width - MOBILE_CARD_MARGIN_PX),
|
||||
y: Math.max(
|
||||
MOBILE_CARD_MARGIN_PX,
|
||||
Math.min(
|
||||
window.innerHeight * MOBILE_CARD_TOP_RATIO,
|
||||
window.innerHeight - safeBottom - 120,
|
||||
),
|
||||
),
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
const hudScale =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
) || 1;
|
||||
const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7);
|
||||
const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32);
|
||||
|
||||
return {
|
||||
x: window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5,
|
||||
y: window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function mapNewsItemToCruiseEvent(item) {
|
||||
const latitude = Number(item?.latitude);
|
||||
const longitude = Number(item?.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `news:${item.id}`,
|
||||
sourceId: item.id,
|
||||
type: "news",
|
||||
title: item.title || "新闻事件",
|
||||
summary: item.summary || "",
|
||||
source: item.source || "",
|
||||
feedName: item.feed_name || "",
|
||||
region: item.region || "global",
|
||||
regionLabel: REGION_LABELS[item.region] || item.region || "全球",
|
||||
url: item.url || "",
|
||||
publishedAt: item.published_at || null,
|
||||
publishedAtDisplay: formatPublishedAt(item.published_at),
|
||||
latitude,
|
||||
longitude,
|
||||
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||
sortTimestamp: getItemTimestamp(item),
|
||||
};
|
||||
}
|
||||
|
||||
export function createNewsCruiseAdapter({ camera, earth, connector, focusView }) {
|
||||
let currentItemId = null;
|
||||
let knownEventIds = new Set();
|
||||
let cardPlacement = null;
|
||||
|
||||
function getVisibleMobilePopup() {
|
||||
const mobilePopup = document.getElementById("earth-mobile-popup");
|
||||
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
|
||||
? mobilePopup
|
||||
: null;
|
||||
}
|
||||
|
||||
function getVisibleInfoPanel() {
|
||||
const infoPanel = document.getElementById("info-panel");
|
||||
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
|
||||
? infoPanel
|
||||
: null;
|
||||
}
|
||||
|
||||
function getItemWorldPosition(item) {
|
||||
const earthObj = earth?.();
|
||||
if (!earthObj || !item) return null;
|
||||
scratchNewsWorldPosition.copy(
|
||||
latLonToVector3(item.latitude, item.longitude, CONFIG.earthRadius + 0.3),
|
||||
);
|
||||
earthObj.localToWorld(scratchNewsWorldPosition);
|
||||
return scratchNewsWorldPosition;
|
||||
}
|
||||
|
||||
function getItemScreenCoords(item) {
|
||||
if (!camera || !item) return null;
|
||||
const worldPosition = getItemWorldPosition(item);
|
||||
if (!worldPosition) return null;
|
||||
const projected = worldPosition.clone().project(camera);
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardAnchorTarget() {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
|
||||
return {
|
||||
element: mobilePopup,
|
||||
side: mobilePopup.dataset.dockSide || "left",
|
||||
alignRatio: 0.5,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCardObstacleTarget(fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
|
||||
return { element: mobilePopup };
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
if (infoPanel) {
|
||||
return { element: infoPanel };
|
||||
}
|
||||
|
||||
if (fallbackPlacement) {
|
||||
return {
|
||||
x: fallbackPlacement.x,
|
||||
y: fallbackPlacement.y,
|
||||
width: fallbackPlacement.width ?? 0,
|
||||
height: fallbackPlacement.height ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectorPath(item) {
|
||||
const itemCoords = getItemScreenCoords(item);
|
||||
if (!itemCoords) return null;
|
||||
|
||||
const targetPlacement = cardPlacement || getCardPlacement();
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const anchorTarget = getCardAnchorTarget();
|
||||
const anchorCoords = resolveConnectorAnchor(anchorTarget);
|
||||
if (!anchorCoords) return null;
|
||||
return createConnectorPath(itemCoords, anchorTarget ?? anchorCoords, {
|
||||
routingMode: "adaptive",
|
||||
obstacles: getCardObstacleTarget(targetPlacement),
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
const target =
|
||||
infoPanel ||
|
||||
{
|
||||
x: targetPlacement.x,
|
||||
y: targetPlacement.y,
|
||||
width: targetPlacement.width,
|
||||
height: targetPlacement.height,
|
||||
};
|
||||
|
||||
return createConnectorPath(itemCoords, target, {
|
||||
routingMode: "adaptive",
|
||||
obstacles: getCardObstacleTarget(targetPlacement),
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
function renderConnector(item, { animate = false } = {}) {
|
||||
const path = getConnectorPath(item);
|
||||
if (!path) return false;
|
||||
return connector?.render(path, { animate }) === true;
|
||||
}
|
||||
|
||||
function getSortedItems() {
|
||||
const items = Array.isArray(getNewsPayload()?.items) ? getNewsPayload().items : [];
|
||||
return items
|
||||
.map(mapNewsItemToCruiseEvent)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||
}
|
||||
|
||||
return {
|
||||
async ensureItemsLoaded() {
|
||||
await ensureNewsPanelReady();
|
||||
},
|
||||
getSortedItems,
|
||||
clearCurrentHighlight() {
|
||||
currentItemId = null;
|
||||
clearSelectedNewsItem();
|
||||
},
|
||||
async focusItem(item, { interrupt = false } = {}) {
|
||||
if (!item) return;
|
||||
currentItemId = item.id;
|
||||
await ensureNewsPanelReady();
|
||||
await focusView({
|
||||
lat: item.latitude,
|
||||
lon: item.longitude,
|
||||
rotLon: item.longitude - 270,
|
||||
duration: interrupt
|
||||
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
|
||||
: CRUISE_CONFIG.focusDurationMs,
|
||||
suppressStatus: true,
|
||||
});
|
||||
cardPlacement = getCardPlacement();
|
||||
},
|
||||
async presentItem(item, { context }) {
|
||||
if (!item) return false;
|
||||
|
||||
selectNewsItem(item.sourceId);
|
||||
const placement = cardPlacement || getCardPlacement();
|
||||
cardPlacement = placement;
|
||||
showInfoCard("news", {
|
||||
title: item.title,
|
||||
summary: item.summary || item.title || "",
|
||||
}, {
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
absolute: true,
|
||||
anchorStable: true,
|
||||
reveal: false,
|
||||
});
|
||||
|
||||
await context.nextFrame();
|
||||
if (!context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let connectorReady = false;
|
||||
while (context.isCurrent()) {
|
||||
connectorReady = renderConnector(item, { animate: !connectorReady });
|
||||
if (connectorReady) break;
|
||||
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
|
||||
break;
|
||||
}
|
||||
await context.nextFrame();
|
||||
}
|
||||
|
||||
if (!connectorReady || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
|
||||
if (!connectorDelayCompleted || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
showInfoCard("news", {
|
||||
title: item.title,
|
||||
summary: item.summary || item.title || "",
|
||||
}, {
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
absolute: true,
|
||||
anchorStable: true,
|
||||
});
|
||||
await context.nextFrame();
|
||||
return context.isCurrent();
|
||||
},
|
||||
async hidePresentation({ context }) {
|
||||
clearSelectedNewsItem();
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
await context.wait(CRUISE_PRESENTATION_HIDE_MS, { secondary: true });
|
||||
cardPlacement = null;
|
||||
},
|
||||
repositionConnector(item) {
|
||||
if (!cardPlacement || !item || !connector?.isVisible?.() || connector.isAnimating?.()) {
|
||||
return;
|
||||
}
|
||||
renderConnector(item, { animate: false });
|
||||
},
|
||||
resetPresentation() {
|
||||
clearSelectedNewsItem();
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
},
|
||||
syncKnownEventIds() {
|
||||
knownEventIds = new Set(getSortedItems().map((item) => item.id));
|
||||
return knownEventIds;
|
||||
},
|
||||
diffNewEventIds(itemIds = []) {
|
||||
return itemIds
|
||||
.map((itemId) => `news:${itemId}`)
|
||||
.filter((itemId) => !knownEventIds.has(itemId));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { isTVPanelVisible } from "./tv.js";
|
||||
import { getActiveTVTab, isTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
@@ -17,6 +17,7 @@ let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
let selectedCruiseStoryId = null;
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
return {
|
||||
@@ -154,7 +155,7 @@ function renderPayload(nextPayload) {
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
@@ -169,6 +170,14 @@ function renderPayload(nextPayload) {
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
applyCruiseStorySelection();
|
||||
window.dispatchEvent(new CustomEvent("earth:news-payload-updated", {
|
||||
detail: {
|
||||
payload: nextPayload,
|
||||
itemIds: items.map((item) => item.id),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
@@ -287,6 +296,44 @@ export async function ensureNewsPanelReady() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function getNewsPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function updateBoardSelection(board, { scrollIntoView = false } = {}) {
|
||||
if (!(board instanceof HTMLElement)) return;
|
||||
const cards = board.querySelectorAll("[data-news-id]");
|
||||
cards.forEach((card) => {
|
||||
const matches = card.getAttribute("data-news-id") === selectedCruiseStoryId;
|
||||
card.classList.toggle("news-story-card--cruise", matches);
|
||||
if (matches && scrollIntoView) {
|
||||
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyCruiseStorySelection(options = {}) {
|
||||
const desktopBoard = document.getElementById("news-board-list");
|
||||
const mobileBoard = document.getElementById("mobile-news-board-list");
|
||||
updateBoardSelection(desktopBoard, options);
|
||||
updateBoardSelection(mobileBoard, options);
|
||||
}
|
||||
|
||||
function revealSelectedCruiseStory() {
|
||||
if (!selectedCruiseStoryId) return;
|
||||
applyCruiseStorySelection({ scrollIntoView: true });
|
||||
}
|
||||
|
||||
export function selectNewsItem(itemId, options = {}) {
|
||||
selectedCruiseStoryId = itemId || null;
|
||||
applyCruiseStorySelection(options);
|
||||
}
|
||||
|
||||
export function clearSelectedNewsItem() {
|
||||
selectedCruiseStoryId = null;
|
||||
applyCruiseStorySelection();
|
||||
}
|
||||
|
||||
export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
@@ -296,9 +343,15 @@ export function initNewsPanel() {
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
if (getActiveTVTab() === "news") {
|
||||
revealSelectedCruiseStory();
|
||||
}
|
||||
});
|
||||
window.addEventListener("earth:tv-visibility-change", (event) => {
|
||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||
if (event.detail?.visible && getActiveTVTab() === "news") {
|
||||
revealSelectedCruiseStory();
|
||||
}
|
||||
});
|
||||
|
||||
["news-refresh", "mobile-news-refresh"].forEach((id) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const tabPanelState = {
|
||||
live: null,
|
||||
news: null,
|
||||
};
|
||||
let mobileMetaCollapsed = false;
|
||||
|
||||
const META_AUTO_COLLAPSE_DELAY = 2500;
|
||||
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
|
||||
@@ -72,8 +73,8 @@ function getElements() {
|
||||
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
|
||||
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
|
||||
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
|
||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||
metaToggle: document.getElementById("tv-meta-toggle"),
|
||||
metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"),
|
||||
metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"),
|
||||
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
||||
newsHeaderControls: document.getElementById("tv-header-controls-news"),
|
||||
liveTabBtn: document.getElementById("tv-tab-live"),
|
||||
@@ -84,8 +85,43 @@ function getElements() {
|
||||
};
|
||||
}
|
||||
|
||||
function isMobileLayout() {
|
||||
return document.body.classList.contains("layout-mode-mobile");
|
||||
}
|
||||
|
||||
function syncMetaToggleState(collapsed) {
|
||||
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
|
||||
if (mobileOverviewBar instanceof HTMLElement) {
|
||||
mobileOverviewBar.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
}
|
||||
const desktopToggle = document.getElementById("tv-meta-toggle");
|
||||
if (desktopToggle instanceof HTMLButtonElement) {
|
||||
desktopToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
desktopToggle.setAttribute(
|
||||
"aria-label",
|
||||
collapsed ? "展开新闻直播内容" : "折叠新闻直播内容",
|
||||
);
|
||||
desktopToggle.title = collapsed ? "展开新闻直播内容" : "折叠新闻直播内容";
|
||||
}
|
||||
}
|
||||
|
||||
function isMetaCollapsed() {
|
||||
if (isMobileLayout()) {
|
||||
return mobileMetaCollapsed;
|
||||
}
|
||||
return mediaPanel?.isCollapsed() ?? false;
|
||||
}
|
||||
|
||||
function setMetaCollapsed(collapsed) {
|
||||
if (isMobileLayout()) {
|
||||
const { metaWrap } = getElements();
|
||||
mobileMetaCollapsed = Boolean(collapsed);
|
||||
metaWrap?.classList.toggle("is-collapsed", mobileMetaCollapsed);
|
||||
syncMetaToggleState(mobileMetaCollapsed);
|
||||
return;
|
||||
}
|
||||
mediaPanel?.setCollapsed(collapsed);
|
||||
syncMetaToggleState(Boolean(collapsed));
|
||||
}
|
||||
|
||||
function syncPanelActiveTab(tab = activeTab) {
|
||||
@@ -120,6 +156,10 @@ function syncNewsDefaultMaxHeight() {
|
||||
}
|
||||
|
||||
function autoExpandMeta() {
|
||||
if (isMobileLayout()) {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
return;
|
||||
}
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
setMetaCollapsed(false);
|
||||
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
|
||||
@@ -154,7 +194,7 @@ function captureTabState(tab = activeTab) {
|
||||
tabPanelState[tab] = {
|
||||
layout: readPanelLayoutState(panel),
|
||||
metaCollapsed:
|
||||
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
|
||||
tab === "live" ? isMetaCollapsed() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -749,15 +789,59 @@ function getExternalUrl(source) {
|
||||
}
|
||||
|
||||
function updateOpenButton(source) {
|
||||
const { openBtn } = getElements();
|
||||
if (!openBtn) return;
|
||||
const targetUrl = getExternalUrl(source);
|
||||
openBtn.disabled = !targetUrl;
|
||||
openBtn.onclick = targetUrl
|
||||
? () => {
|
||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
: null;
|
||||
[
|
||||
document.getElementById("mobile-tv-open-external"),
|
||||
document.getElementById("tv-open-external"),
|
||||
].forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
button.disabled = !targetUrl;
|
||||
button.onclick = targetUrl
|
||||
? () => {
|
||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
function syncMobileOverviewSummary(source) {
|
||||
const headline = document.getElementById("mobile-tv-overview-headline");
|
||||
const summary = document.getElementById("mobile-tv-overview-summary");
|
||||
const tags = document.getElementById("mobile-tv-overview-tags");
|
||||
if (headline instanceof HTMLElement) {
|
||||
headline.textContent = source?.name || "暂无可用频道";
|
||||
}
|
||||
if (summary instanceof HTMLElement) {
|
||||
if (!source) {
|
||||
summary.textContent = "点击查看当前频道来源、目录和补充说明";
|
||||
} else {
|
||||
const parts = [
|
||||
source.provider,
|
||||
source.region,
|
||||
source.language,
|
||||
].filter(Boolean);
|
||||
summary.textContent = parts.length
|
||||
? parts.join(" · ")
|
||||
: "点击查看完整频道信息";
|
||||
}
|
||||
}
|
||||
if (tags instanceof HTMLElement) {
|
||||
const tagValues = source
|
||||
? [
|
||||
{ label: source.source_type || "频道", kind: "status" },
|
||||
source.collector_source ? { label: `采集:${source.collector_source}`, kind: "" } : { label: "内置源", kind: "" },
|
||||
source.region ? { label: source.region, kind: "" } : null,
|
||||
].filter(Boolean).slice(0, 3)
|
||||
: [{ label: "待加载", kind: "status" }];
|
||||
tags.replaceChildren(
|
||||
...tagValues.map(({ label, kind }) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = `earth-mobile-tv-overview-tag${kind ? ` earth-mobile-tv-overview-tag--${kind}` : ""}`;
|
||||
chip.textContent = label;
|
||||
return chip;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function findSourceById(sourceId) {
|
||||
@@ -926,6 +1010,7 @@ function renderSource(source) {
|
||||
if (notes) {
|
||||
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
||||
}
|
||||
syncMobileOverviewSummary(source);
|
||||
|
||||
if (!source || (!embeddedUrl && !videoUrl)) {
|
||||
destroyHlsPlayer();
|
||||
@@ -1031,6 +1116,7 @@ export function initTVPanel() {
|
||||
liveTabBtn,
|
||||
newsTabBtn,
|
||||
} = getElements();
|
||||
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
|
||||
|
||||
if (panel && metaToggle) {
|
||||
mediaPanel = createHUDPanel({
|
||||
@@ -1045,6 +1131,10 @@ export function initTVPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
mobileMetaCollapsed = true;
|
||||
syncMetaToggleState(isMetaCollapsed());
|
||||
setMetaCollapsed(isMetaCollapsed());
|
||||
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
@@ -1084,10 +1174,31 @@ export function initTVPanel() {
|
||||
});
|
||||
});
|
||||
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
[metaToggle, document.getElementById("tv-meta-toggle")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
.forEach((toggleEl) => {
|
||||
toggleEl?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !isMetaCollapsed();
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
});
|
||||
|
||||
const toggleMobileMeta = (event) => {
|
||||
const interactiveTarget = event.target instanceof Element
|
||||
? event.target.closest("button, a, select, input, textarea, video, iframe")
|
||||
: null;
|
||||
if (interactiveTarget) return;
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
|
||||
const isNowCollapsed = !isMetaCollapsed();
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
};
|
||||
|
||||
mobileOverviewBar?.addEventListener("click", toggleMobileMeta);
|
||||
mobileOverviewBar?.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
toggleMobileMeta(event);
|
||||
});
|
||||
|
||||
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
|
||||
|
||||
Reference in New Issue
Block a user