release: bump version to 0.26.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
222
frontend/public/earth/css/tv-panel.css
Normal file
222
frontend/public/earth/css/tv-panel.css
Normal file
@@ -0,0 +1,222 @@
|
||||
/* tv-panel */
|
||||
|
||||
.hud-panel-tv {
|
||||
top: calc(96px * var(--hud-scale));
|
||||
right: calc(92px * var(--hud-scale));
|
||||
width: min(calc(460px * var(--hud-scale)), calc(100vw - 32px));
|
||||
min-width: calc(360px * var(--hud-scale));
|
||||
min-height: calc(340px * var(--hud-scale));
|
||||
padding: calc(18px * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
z-index: 18;
|
||||
}
|
||||
|
||||
.tv-panel-header-copy {
|
||||
display: grid;
|
||||
gap: calc(3px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-status {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tv-panel-controls {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-sm);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tv-panel-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
color-scheme: dark;
|
||||
border: 1px solid rgba(201, 225, 247, 0.14);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--hud-text);
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-select option,
|
||||
.tv-panel-select optgroup {
|
||||
background: #0a1422;
|
||||
color: #eef5fc;
|
||||
}
|
||||
|
||||
.tv-panel-actions {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.tv-panel-action {
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--hud-text);
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.tv-panel-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tv-panel-meta {
|
||||
display: grid;
|
||||
gap: calc(4px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-title {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.96rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tv-panel-subtitle {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.tv-panel-notes {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tv-panel-catalog {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tv-panel-player {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(220px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(201, 225, 247, 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 18, 32, 0.94), rgba(5, 11, 22, 0.94));
|
||||
}
|
||||
|
||||
.tv-panel-empty,
|
||||
.tv-panel-iframe,
|
||||
.tv-panel-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tv-panel-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: calc(18px * var(--hud-scale));
|
||||
text-align: center;
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tv-panel-iframe,
|
||||
.tv-panel-video {
|
||||
border: 0;
|
||||
background: #050a14;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle {
|
||||
position: absolute;
|
||||
right: calc(8px * var(--hud-scale));
|
||||
bottom: calc(8px * var(--hud-scale));
|
||||
width: calc(18px * var(--hud-scale));
|
||||
height: calc(18px * var(--hud-scale));
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: nwse-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-right: 2px solid rgba(223, 235, 248, 0.46);
|
||||
border-bottom: 2px solid rgba(223, 235, 248, 0.46);
|
||||
border-bottom-right-radius: calc(10px * var(--hud-scale));
|
||||
opacity: 0.78;
|
||||
transition: opacity 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle:hover::before {
|
||||
opacity: 1;
|
||||
border-color: rgba(244, 249, 255, 0.78);
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-resizing {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-tv {
|
||||
top: calc(50% + 12px);
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: min(calc(760px * var(--hud-scale)), calc(100vw - 48px));
|
||||
min-height: calc(410px * var(--hud-scale));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hud-panel-tv {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
top: auto;
|
||||
bottom: 16px;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: min(48vh, 360px);
|
||||
}
|
||||
|
||||
.tv-panel-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tv-panel-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tv-panel-action {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
"imports": {
|
||||
"three": "https://esm.sh/three@0.128.0",
|
||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
||||
"satellite.js": "https://esm.sh/satellite.js@5.0.0"
|
||||
"satellite.js": "https://esm.sh/satellite.js@5.0.0",
|
||||
"hls.js": "https://esm.sh/hls.js@1.6.15"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -34,6 +35,7 @@
|
||||
<link rel="stylesheet" href="css/coordinates-display.css">
|
||||
<link rel="stylesheet" href="css/legend.css">
|
||||
<link rel="stylesheet" href="css/earth-stats.css">
|
||||
<link rel="stylesheet" href="css/tv-panel.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
|
||||
</head>
|
||||
@@ -118,6 +120,12 @@
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">隐藏线缆</span>
|
||||
</button>
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
@@ -259,6 +267,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable hud-panel-hidden" data-panel-key="tv-panel">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<div class="tv-panel-header-copy">
|
||||
<h3 class="hud-panel-title">新闻直播</h3>
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
</div>
|
||||
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tv-panel-controls">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-actions">
|
||||
<button id="tv-refresh" class="tv-panel-action" type="button">刷新</button>
|
||||
<button id="tv-open-external" class="tv-panel-action" type="button">官网</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tv-panel-meta">
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
<button
|
||||
id="tv-resize-handle"
|
||||
class="tv-panel-resize-handle"
|
||||
type="button"
|
||||
aria-label="调整电视直播窗口大小"
|
||||
title="调整大小"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="earth-loading">
|
||||
<div id="loading-spinner" class="earth-loading-spinner"></div>
|
||||
<div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div>
|
||||
@@ -312,6 +364,16 @@
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-tv">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">电视直播</span>
|
||||
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="tv-panel">
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
20
frontend/public/earth/js/controls.js
vendored
20
frontend/public/earth/js/controls.js
vendored
@@ -17,6 +17,7 @@ import {
|
||||
} from "./satellites.js";
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import { ensureTVPanelReady } from "./tv.js";
|
||||
|
||||
export let autoRotate = true;
|
||||
export let zoomLevel = 1.0;
|
||||
@@ -30,6 +31,7 @@ const HUD_PANEL_IDS = [
|
||||
"legend",
|
||||
"coordinates-display",
|
||||
"earth-stats",
|
||||
"tv-panel",
|
||||
];
|
||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||
const PANEL_LAYOUT_ANIMATION_MS = 420;
|
||||
@@ -88,6 +90,14 @@ function setHudPanelVisibility(panelId, visible) {
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
syncSettingsToggle(panelId, visible);
|
||||
if (panelId === "tv-panel") {
|
||||
updateTVToggleUI(visible);
|
||||
if (visible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsToggle(panelId, visible) {
|
||||
@@ -273,6 +283,13 @@ function bindFloatingMenu(trigger, group) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateTVToggleUI(visible) {
|
||||
const btn = document.getElementById("toggle-tv");
|
||||
if (!btn) return;
|
||||
btn.classList.toggle("active", visible);
|
||||
setButtonTooltip(btn, visible ? "关闭新闻直播" : "打开新闻直播");
|
||||
}
|
||||
|
||||
function bindListener(element, eventName, handler, options) {
|
||||
if (!element) return;
|
||||
element.addEventListener(eventName, handler, options);
|
||||
@@ -656,6 +673,9 @@ function setupTerrainControls() {
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
});
|
||||
|
||||
updateTVToggleUI(
|
||||
!document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden"),
|
||||
);
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ import {
|
||||
setLegendItems,
|
||||
} from "./legend.js";
|
||||
import { mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
|
||||
export let scene;
|
||||
export let camera;
|
||||
@@ -187,6 +188,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
||||
"#legend *",
|
||||
"#earth-stats",
|
||||
"#earth-stats *",
|
||||
"#tv-panel",
|
||||
"#tv-panel *",
|
||||
];
|
||||
|
||||
function bindListener(target, eventName, handler, options) {
|
||||
@@ -856,6 +859,7 @@ export function init() {
|
||||
updateHudScale();
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
initTVPanel();
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
|
||||
596
frontend/public/earth/js/tv.js
Normal file
596
frontend/public/earth/js/tv.js
Normal file
@@ -0,0 +1,596 @@
|
||||
import Hls from "hls.js";
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
|
||||
const TV_STREAMS_API = "/api/v1/tv/streams";
|
||||
const TV_PROXY_API = "/api/v1/tv/proxy";
|
||||
const TV_STATUS_MESSAGE = {
|
||||
idle: "等待加载直播源",
|
||||
syncing: "正在同步直播源...",
|
||||
empty: "暂无可播放直播源",
|
||||
iframeReady: "直播页已加载",
|
||||
videoReady: "视频流已加载",
|
||||
videoError: "当前视频流不可播放,请尝试其他频道",
|
||||
externalOnly: "当前频道仅支持外部打开",
|
||||
loadFailed: "电视直播源加载失败",
|
||||
};
|
||||
|
||||
let tvPayload = null;
|
||||
let currentSourceId = "";
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let hlsPlayer = null;
|
||||
let hlsRecoveryAttempts = 0;
|
||||
|
||||
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
|
||||
const HLS_RETRY_CONFIG = {
|
||||
maxNumRetry: 4,
|
||||
retryDelayMs: 1500,
|
||||
maxRetryDelayMs: 8000,
|
||||
backoff: "exponential",
|
||||
};
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
panel: document.getElementById("tv-panel"),
|
||||
toggleBtn: document.getElementById("toggle-tv"),
|
||||
resizeHandle: document.getElementById("tv-resize-handle"),
|
||||
select: document.getElementById("tv-source-select"),
|
||||
title: document.getElementById("tv-source-title"),
|
||||
meta: document.getElementById("tv-source-meta"),
|
||||
catalog: document.getElementById("tv-source-catalog"),
|
||||
status: document.getElementById("tv-source-status"),
|
||||
notes: document.getElementById("tv-source-notes"),
|
||||
iframe: document.getElementById("tv-iframe"),
|
||||
video: document.getElementById("tv-video"),
|
||||
empty: document.getElementById("tv-empty-state"),
|
||||
refreshBtn: document.getElementById("tv-refresh"),
|
||||
openBtn: document.getElementById("tv-open-external"),
|
||||
};
|
||||
}
|
||||
|
||||
function clearPanelPositioningForResize(panel) {
|
||||
panel.style.left = `${panel.offsetLeft}px`;
|
||||
panel.style.top = `${panel.offsetTop}px`;
|
||||
panel.style.right = "auto";
|
||||
panel.style.bottom = "auto";
|
||||
panel.style.transform = "none";
|
||||
panel.dataset.dragged = "true";
|
||||
}
|
||||
|
||||
function getHudScale() {
|
||||
const scale = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
);
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
function setupResizeHandle() {
|
||||
const { panel, resizeHandle } = getElements();
|
||||
const container = document.getElementById("container");
|
||||
if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resizing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startWidth = 0;
|
||||
let startHeight = 0;
|
||||
|
||||
const stopResize = () => {
|
||||
resizing = false;
|
||||
panel.classList.remove("is-resizing");
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
resizeHandle.addEventListener("pointerdown", (event) => {
|
||||
if (document.getElementById("container")?.classList.contains("layout-expanded")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizing = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
|
||||
clearPanelPositioningForResize(panel);
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
startWidth = rect.width;
|
||||
startHeight = rect.height;
|
||||
panel.classList.add("is-resizing");
|
||||
document.body.style.userSelect = "none";
|
||||
resizeHandle.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
resizeHandle.addEventListener("pointermove", (event) => {
|
||||
if (!resizing) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const currentLeft = panelRect.left - containerRect.left;
|
||||
const currentTop = panelRect.top - containerRect.top;
|
||||
const hudScale = getHudScale();
|
||||
const minWidth = Math.max(320, Math.round(360 * hudScale));
|
||||
const minHeight = Math.max(260, Math.round(340 * hudScale));
|
||||
const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12);
|
||||
const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12);
|
||||
const nextWidth = Math.min(
|
||||
maxWidth,
|
||||
Math.max(minWidth, startWidth + (event.clientX - startX)),
|
||||
);
|
||||
const nextHeight = Math.min(
|
||||
maxHeight,
|
||||
Math.max(minHeight, startHeight + (event.clientY - startY)),
|
||||
);
|
||||
|
||||
panel.style.width = `${nextWidth}px`;
|
||||
panel.style.minHeight = `${nextHeight}px`;
|
||||
});
|
||||
|
||||
resizeHandle.addEventListener("pointerup", stopResize);
|
||||
resizeHandle.addEventListener("pointercancel", stopResize);
|
||||
resizeHandle.addEventListener("lostpointercapture", stopResize);
|
||||
}
|
||||
|
||||
function updateToggleButton(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
toggleBtn.classList.toggle("active", visible);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播";
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsToggle(visible) {
|
||||
const input = document.querySelector('[data-settings-panel="tv-panel"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = visible;
|
||||
}
|
||||
}
|
||||
|
||||
function setPanelVisible(visible) {
|
||||
const { panel } = getElements();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
updateToggleButton(visible);
|
||||
syncSettingsToggle(visible);
|
||||
}
|
||||
|
||||
function getEmbeddedUrl(source) {
|
||||
if (!source) return "";
|
||||
if (source.source_type === "youtube" && source.youtube_video_id) {
|
||||
const videoId = encodeURIComponent(source.youtube_video_id);
|
||||
return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&playsinline=1&rel=0`;
|
||||
}
|
||||
if (source.source_type === "external") return "";
|
||||
if (source.source_type === "video" || source.source_type === "hls") {
|
||||
return "";
|
||||
}
|
||||
return source.embed_url || source.homepage_url || "";
|
||||
}
|
||||
|
||||
function buildProxyUrl(url) {
|
||||
if (!url) return "";
|
||||
return `${TV_PROXY_API}?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
function getVideoUrl(source) {
|
||||
if (!source) return "";
|
||||
if (source.source_type !== "video" && source.source_type !== "hls") {
|
||||
return "";
|
||||
}
|
||||
return buildProxyUrl(source.stream_url || source.embed_url || "");
|
||||
}
|
||||
|
||||
function destroyHlsPlayer() {
|
||||
if (hlsPlayer) {
|
||||
hlsPlayer.destroy();
|
||||
hlsPlayer = null;
|
||||
}
|
||||
hlsRecoveryAttempts = 0;
|
||||
}
|
||||
|
||||
function showEmbeddedFallback(source, reasonMessage = TV_STATUS_MESSAGE.videoError) {
|
||||
const { iframe, video, empty } = getElements();
|
||||
const embeddedUrl = getEmbeddedUrl(source);
|
||||
if (!embeddedUrl) {
|
||||
setPanelMessage(reasonMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
destroyHlsPlayer();
|
||||
|
||||
if (video) {
|
||||
video.removeAttribute("src");
|
||||
video.hidden = true;
|
||||
video.load();
|
||||
}
|
||||
|
||||
if (iframe) {
|
||||
iframe.hidden = false;
|
||||
if (iframe.src !== embeddedUrl) {
|
||||
iframe.src = embeddedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
empty.hidden = true;
|
||||
}
|
||||
|
||||
setPanelMessage("直播放流不可用,已回退到官网直播页");
|
||||
return true;
|
||||
}
|
||||
|
||||
function canPlayNativeHls(video, sourceUrl) {
|
||||
if (!(video instanceof HTMLVideoElement) || !sourceUrl) return false;
|
||||
const isLikelyHls = sourceUrl.includes(".m3u8") || sourceUrl.includes("mpegurl");
|
||||
if (!isLikelyHls) return false;
|
||||
return video.canPlayType("application/vnd.apple.mpegurl") !== "";
|
||||
}
|
||||
|
||||
function tryStartPlayback(video) {
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
video.autoplay = true;
|
||||
video.muted = true;
|
||||
const playPromise = video.play();
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
playPromise.catch((error) => {
|
||||
console.warn("TV 自动播放未成功:", error);
|
||||
setPanelMessage("已加载视频流,点击播放继续");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function attachVideoSource(video, source) {
|
||||
const sourceUrl = getVideoUrl(source);
|
||||
if (!(video instanceof HTMLVideoElement) || !sourceUrl) return;
|
||||
|
||||
destroyHlsPlayer();
|
||||
video.autoplay = true;
|
||||
video.muted = true;
|
||||
|
||||
if (source.source_type === "hls") {
|
||||
if (canPlayNativeHls(video, sourceUrl)) {
|
||||
video.src = sourceUrl;
|
||||
video.load();
|
||||
tryStartPlayback(video);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hlsPlayer = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
manifestLoadingTimeOut: 20000,
|
||||
levelLoadingTimeOut: 20000,
|
||||
fragLoadingTimeOut: 25000,
|
||||
fragLoadingMaxRetry: 3,
|
||||
fragLoadingRetryDelay: 1500,
|
||||
levelLoadingMaxRetry: 3,
|
||||
levelLoadingRetryDelay: 1500,
|
||||
manifestLoadingMaxRetry: 2,
|
||||
manifestLoadingRetryDelay: 1500,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 10,
|
||||
manifestLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: 12000,
|
||||
maxLoadTimeMs: 20000,
|
||||
timeoutRetry: {
|
||||
...HLS_RETRY_CONFIG,
|
||||
maxNumRetry: 2,
|
||||
},
|
||||
errorRetry: {
|
||||
...HLS_RETRY_CONFIG,
|
||||
maxNumRetry: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
playlistLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: 12000,
|
||||
maxLoadTimeMs: 20000,
|
||||
timeoutRetry: HLS_RETRY_CONFIG,
|
||||
errorRetry: HLS_RETRY_CONFIG,
|
||||
},
|
||||
},
|
||||
fragLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: 12000,
|
||||
maxLoadTimeMs: 30000,
|
||||
timeoutRetry: HLS_RETRY_CONFIG,
|
||||
errorRetry: HLS_RETRY_CONFIG,
|
||||
},
|
||||
},
|
||||
});
|
||||
hlsPlayer.loadSource(sourceUrl);
|
||||
hlsPlayer.attachMedia(video);
|
||||
hlsPlayer.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
hlsRecoveryAttempts = 0;
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||
tryStartPlayback(video);
|
||||
});
|
||||
hlsPlayer.on(Hls.Events.ERROR, (_event, data) => {
|
||||
console.error("HLS 播放失败:", data);
|
||||
if (!data?.fatal) {
|
||||
if (data?.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
setPanelMessage("直播流网络波动,正在重试...");
|
||||
return;
|
||||
}
|
||||
if (data?.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
setPanelMessage("直播流正在恢复...");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.fatal && hlsRecoveryAttempts < HLS_MAX_RECOVERY_ATTEMPTS) {
|
||||
hlsRecoveryAttempts += 1;
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
setPanelMessage(`直播流连接异常,正在重试 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`);
|
||||
hlsPlayer?.startLoad();
|
||||
return;
|
||||
}
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
setPanelMessage(`直播流解码异常,正在恢复 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`);
|
||||
hlsPlayer?.recoverMediaError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmbeddedFallback(source)) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
video.src = sourceUrl;
|
||||
video.load();
|
||||
tryStartPlayback(video);
|
||||
}
|
||||
|
||||
function getExternalUrl(source) {
|
||||
return source?.homepage_url || source?.youtube_channel || source?.embed_url || source?.stream_url || "";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function findSourceById(sourceId) {
|
||||
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
|
||||
}
|
||||
|
||||
function setPanelMessage(message) {
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = message || TV_STATUS_MESSAGE.idle;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSourceOptions() {
|
||||
const { select } = getElements();
|
||||
if (!select) return;
|
||||
const sources = tvPayload?.sources || [];
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
sources.forEach((source) => {
|
||||
const marker = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
const option = document.createElement("option");
|
||||
option.value = source.id;
|
||||
option.textContent = `${source.name}${marker}`;
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
select.replaceChildren(fragment);
|
||||
if (currentSourceId) {
|
||||
select.value = currentSourceId;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSource(source) {
|
||||
const { title, meta, catalog, notes, iframe, video, empty } = getElements();
|
||||
const embeddedUrl = getEmbeddedUrl(source);
|
||||
const videoUrl = getVideoUrl(source);
|
||||
const externalUrl = getExternalUrl(source);
|
||||
const isVideo = Boolean(videoUrl);
|
||||
const isExternalOnly = Boolean(source) && !embeddedUrl && !videoUrl && Boolean(externalUrl);
|
||||
|
||||
if (title) {
|
||||
title.textContent = source?.name || "暂无可用频道";
|
||||
}
|
||||
if (meta) {
|
||||
meta.textContent = source
|
||||
? `${source.provider} · ${source.region} · ${source.language} · ${source.source_type}`
|
||||
: "当前未配置可播放新闻直播源";
|
||||
}
|
||||
if (catalog) {
|
||||
const sourceCount = tvPayload?.source_count || tvPayload?.sources?.length || 0;
|
||||
const latestUpdatedAt = tvPayload?.latest_updated_at || tvPayload?.generated_at || "";
|
||||
const latestLabel = latestUpdatedAt
|
||||
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
|
||||
: "尚未同步";
|
||||
const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : "";
|
||||
catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`;
|
||||
}
|
||||
if (notes) {
|
||||
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
||||
}
|
||||
|
||||
if (!source || (!embeddedUrl && !videoUrl)) {
|
||||
destroyHlsPlayer();
|
||||
if (iframe) {
|
||||
iframe.removeAttribute("src");
|
||||
iframe.hidden = true;
|
||||
}
|
||||
if (video) {
|
||||
video.removeAttribute("src");
|
||||
video.hidden = true;
|
||||
video.load();
|
||||
}
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = isExternalOnly
|
||||
? "当前频道仅支持跳转官网或外部播放器打开。"
|
||||
: "暂无可播放直播源,请先在系统配置中添加频道。";
|
||||
}
|
||||
setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty);
|
||||
updateOpenButton(source);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVideo && videoUrl) {
|
||||
if (iframe) {
|
||||
iframe.removeAttribute("src");
|
||||
iframe.hidden = true;
|
||||
}
|
||||
if (video) {
|
||||
video.hidden = false;
|
||||
attachVideoSource(video, source);
|
||||
}
|
||||
} else {
|
||||
destroyHlsPlayer();
|
||||
if (video) {
|
||||
video.removeAttribute("src");
|
||||
video.hidden = true;
|
||||
video.load();
|
||||
}
|
||||
if (iframe) {
|
||||
iframe.hidden = false;
|
||||
if (iframe.src !== embeddedUrl) {
|
||||
iframe.src = embeddedUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
empty.hidden = true;
|
||||
}
|
||||
|
||||
setPanelMessage(
|
||||
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
|
||||
);
|
||||
updateOpenButton(source);
|
||||
}
|
||||
|
||||
function resolveInitialSourceId() {
|
||||
if (findSourceById(currentSourceId)) {
|
||||
return currentSourceId;
|
||||
}
|
||||
return tvPayload?.selected_source?.id || tvPayload?.default_source_id || tvPayload?.sources?.[0]?.id || "";
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
renderSourceOptions();
|
||||
currentSourceId = resolveInitialSourceId();
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
}
|
||||
|
||||
export async function loadTVStreams() {
|
||||
const response = await fetch(TV_STREAMS_API, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load TV streams: ${response.status}`);
|
||||
}
|
||||
tvPayload = await response.json();
|
||||
return tvPayload;
|
||||
}
|
||||
|
||||
export async function refreshTVPanel() {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
setPanelMessage(TV_STATUS_MESSAGE.syncing);
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
await loadTVStreams();
|
||||
renderPanel();
|
||||
} catch (error) {
|
||||
console.error("加载电视直播源失败:", error);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.loadFailed);
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function ensureTVPanelReady() {
|
||||
if (!initialized) {
|
||||
initTVPanel();
|
||||
}
|
||||
if (!tvPayload) {
|
||||
await refreshTVPanel();
|
||||
return;
|
||||
}
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
export function initTVPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements();
|
||||
|
||||
updateToggleButton(!panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
toggleBtn?.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true;
|
||||
setPanelVisible(nextVisible);
|
||||
if (nextVisible) {
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("新闻直播窗口已打开", "info");
|
||||
} else {
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
}
|
||||
});
|
||||
|
||||
select?.addEventListener("change", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLSelectElement)) return;
|
||||
currentSourceId = target.value;
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", () => {
|
||||
refreshTVPanel();
|
||||
});
|
||||
|
||||
iframe?.addEventListener("load", () => {
|
||||
if (iframe.hidden) return;
|
||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("loadedmetadata", () => {
|
||||
if (video.hidden) return;
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("error", () => {
|
||||
const currentSource = findSourceById(currentSourceId);
|
||||
if (!showEmbeddedFallback(currentSource)) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
|
||||
setupResizeHandle();
|
||||
}
|
||||
@@ -2315,6 +2315,32 @@ body {
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-tv-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,32 @@ interface CollectorSettings {
|
||||
next_run_at: string | null
|
||||
}
|
||||
|
||||
interface TVStreamSource {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
region: string
|
||||
language: string
|
||||
source_type: 'iframe' | 'hls' | 'video' | 'external' | 'youtube'
|
||||
embed_url: string
|
||||
stream_url: string
|
||||
homepage_url: string
|
||||
poster_url: string
|
||||
youtube_video_id: string
|
||||
youtube_channel: string
|
||||
is_enabled: boolean
|
||||
is_fallback: boolean
|
||||
sort_order: number
|
||||
collector_source: string | null
|
||||
notes: string
|
||||
}
|
||||
|
||||
interface TVSettings {
|
||||
default_source_id: string
|
||||
auto_fallback: boolean
|
||||
sources: TVStreamSource[]
|
||||
}
|
||||
|
||||
function SettingsPanel({
|
||||
loading,
|
||||
children,
|
||||
@@ -78,6 +104,8 @@ function Settings() {
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings | null>(null)
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
|
||||
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
||||
const [savingTvSettings, setSavingTvSettings] = useState(false)
|
||||
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
|
||||
const [systemForm] = Form.useForm<SystemSettings>()
|
||||
@@ -91,6 +119,7 @@ function Settings() {
|
||||
setSystemSettings(response.data.system)
|
||||
setNotificationSettings(response.data.notifications)
|
||||
setSecuritySettings(response.data.security)
|
||||
setTvSettings(response.data.tv || null)
|
||||
setCollectors(response.data.collectors || [])
|
||||
} catch (error) {
|
||||
message.error('获取系统配置失败')
|
||||
@@ -175,6 +204,100 @@ function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateTvSetting = <K extends keyof TVSettings>(field: K, value: TVSettings[K]) => {
|
||||
setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev))
|
||||
}
|
||||
|
||||
const updateTvSourceField = <K extends keyof TVStreamSource>(
|
||||
sourceId: string,
|
||||
field: K,
|
||||
value: TVStreamSource[K]
|
||||
) => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextSources = prev.sources.map((source) => {
|
||||
if (field === 'is_fallback' && value === true) {
|
||||
return { ...source, is_fallback: source.id === sourceId }
|
||||
}
|
||||
if (source.id === sourceId) {
|
||||
return { ...source, [field]: value }
|
||||
}
|
||||
return source
|
||||
})
|
||||
|
||||
const nextDefaultSourceId =
|
||||
field === 'is_enabled' && value === false && prev.default_source_id === sourceId
|
||||
? nextSources.find((source) => source.id !== sourceId && source.is_enabled)?.id || ''
|
||||
: prev.default_source_id
|
||||
|
||||
return {
|
||||
...prev,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addTvSource = () => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextIndex = prev.sources.length + 1
|
||||
const newSource: TVStreamSource = {
|
||||
id: `manual-tv-${Date.now()}`,
|
||||
name: `新闻直播源 ${nextIndex}`,
|
||||
provider: 'Manual',
|
||||
region: 'Global',
|
||||
language: 'und',
|
||||
source_type: 'iframe',
|
||||
embed_url: '',
|
||||
stream_url: '',
|
||||
homepage_url: '',
|
||||
poster_url: '',
|
||||
youtube_video_id: '',
|
||||
youtube_channel: '',
|
||||
is_enabled: true,
|
||||
is_fallback: false,
|
||||
sort_order: nextIndex * 10,
|
||||
collector_source: null,
|
||||
notes: '',
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sources: [...prev.sources, newSource],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeTvSource = (sourceId: string) => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextSources = prev.sources.filter((source) => source.id !== sourceId)
|
||||
const nextDefaultSourceId =
|
||||
prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id
|
||||
return {
|
||||
...prev,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveTvSettings = async () => {
|
||||
if (!tvSettings) return
|
||||
try {
|
||||
setSavingTvSettings(true)
|
||||
await axios.put('/api/v1/settings/tv', tvSettings)
|
||||
message.success('电视直播配置已保存')
|
||||
await fetchSettings()
|
||||
} catch (error) {
|
||||
message.error('电视直播配置保存失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
setSavingTvSettings(false)
|
||||
}
|
||||
}
|
||||
|
||||
const collectorColumns = [
|
||||
{
|
||||
title: '数据源',
|
||||
@@ -274,6 +397,133 @@ function Settings() {
|
||||
},
|
||||
]
|
||||
|
||||
const tvSourceColumns = [
|
||||
{
|
||||
title: '频道',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 220,
|
||||
render: (_: string, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input value={record.name} onChange={(event) => updateTvSourceField(record.id, 'name', event.target.value)} />
|
||||
<Input
|
||||
value={record.provider}
|
||||
placeholder="提供方"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'provider', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '区域 / 语言',
|
||||
key: 'locale',
|
||||
width: 160,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input
|
||||
value={record.region}
|
||||
placeholder="区域"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'region', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.language}
|
||||
placeholder="语言"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'language', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'source_type',
|
||||
key: 'source_type',
|
||||
width: 120,
|
||||
render: (value: TVStreamSource['source_type'], record: TVStreamSource) => (
|
||||
<Select
|
||||
value={value}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(nextValue) => updateTvSourceField(record.id, 'source_type', nextValue)}
|
||||
options={[
|
||||
{ value: 'iframe', label: 'iframe' },
|
||||
{ value: 'hls', label: 'hls' },
|
||||
{ value: 'video', label: 'video' },
|
||||
{ value: 'youtube', label: 'youtube' },
|
||||
{ value: 'external', label: 'external' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '播放地址',
|
||||
key: 'urls',
|
||||
width: 320,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input
|
||||
value={record.embed_url}
|
||||
placeholder="嵌入地址 / iframe 地址"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'embed_url', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.stream_url}
|
||||
placeholder="流地址 / HLS 地址"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'stream_url', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.youtube_video_id}
|
||||
placeholder="YouTube 视频 ID(可选)"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'youtube_video_id', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.youtube_channel}
|
||||
placeholder="YouTube 频道 Handle / URL(可选)"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'youtube_channel', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '官网',
|
||||
dataIndex: 'homepage_url',
|
||||
key: 'homepage_url',
|
||||
width: 220,
|
||||
render: (value: string, record: TVStreamSource) => (
|
||||
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'homepage_url', event.target.value)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Switch checked={record.is_enabled} onChange={(checked) => updateTvSourceField(record.id, 'is_enabled', checked)} />
|
||||
<Switch checked={record.is_fallback} onChange={(checked) => updateTvSourceField(record.id, 'is_fallback', checked)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
key: 'notes',
|
||||
width: 220,
|
||||
render: (value: string, record: TVStreamSource) => (
|
||||
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'system',
|
||||
@@ -356,6 +606,58 @@ function Settings() {
|
||||
</SettingsPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tv',
|
||||
label: '电视直播',
|
||||
children: (
|
||||
<div className="settings-pane">
|
||||
<Card className="settings-panel-card settings-panel-card--table" loading={loading}>
|
||||
<div className="settings-panel-scroll" style={{ display: 'grid', gap: 16 }}>
|
||||
<div className="settings-tv-toolbar">
|
||||
<div className="settings-tv-toolbar__controls">
|
||||
<div className="settings-tv-field">
|
||||
<Text type="secondary">默认直播源</Text>
|
||||
<Select
|
||||
value={tvSettings?.default_source_id}
|
||||
style={{ minWidth: 260 }}
|
||||
options={(tvSettings?.sources || []).map((source) => ({
|
||||
value: source.id,
|
||||
label: source.name,
|
||||
}))}
|
||||
onChange={(value) => updateTvSetting('default_source_id', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-tv-field">
|
||||
<Text type="secondary">自动回退</Text>
|
||||
<Switch
|
||||
checked={tvSettings?.auto_fallback || false}
|
||||
onChange={(checked) => updateTvSetting('auto_fallback', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-tv-toolbar__actions">
|
||||
<Button onClick={addTvSource}>新增直播源</Button>
|
||||
<Button type="primary" loading={savingTvSettings} onClick={saveTvSettings}>
|
||||
保存电视直播配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
dataSource={tvSettings?.sources || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1500, y: 420 }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'collectors',
|
||||
label: '采集调度',
|
||||
|
||||
Reference in New Issue
Block a user