diff --git a/VERSION b/VERSION
index c7c49b4b..fb96d142 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.27.6
+0.27.7
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index aa35d78e..a362a008 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -8,6 +8,21 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
+## [0.27.7] — 2026-04-16
+
+### 🔧 Improvements
+- 用户管理、数据源配置、电视直播源表格统一接入可折叠操作列,窄宽度下自动收起到下拉菜单,减少操作区挤压
+- 电视直播设置改为表格总览 + 弹窗编辑模式,主表内容更紧凑,适合控制台一屏浏览
+- Earth TV 面板新增失败源探测与自动回退恢复标记,便于值班时快速识别异常直播源
+
+### 🐛 Fixes
+- 修复 Settings 电视直播源新增后取消编辑会残留未保存草稿的问题
+- 修复 Settings 删除直播源只改本地状态、刷新后恢复的问题,删除现在会立即持久化
+- 修复 Users / DataSources / Settings 表格“备注/状态”和“操作”之间的空白占位列问题
+- 修复 `useCollapsedActions` 未释放 `ResizeObserver` 导致的潜在内存泄漏与重复回调问题
+
+---
+
## [0.27.6] — 2026-04-15
### 🔧 Improvements
diff --git a/docs/version-history.md b/docs/version-history.md
index c1de6049..33c2b468 100644
--- a/docs/version-history.md
+++ b/docs/version-history.md
@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
-- `dev` 当前开发分支历史推导到:`0.27.6`
+- `dev` 当前开发分支历史推导到:`0.27.7`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
+| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复,Playground 响应式按钮与输入框收起优化 |
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |
diff --git a/frontend/package.json b/frontend/package.json
index 1b4946f2..4628325b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
- "version": "0.27.6",
+ "version": "0.27.7",
"private": true,
"packageManager": "bun@1",
"dependencies": {
diff --git a/frontend/public/earth/js/tv.js b/frontend/public/earth/js/tv.js
index e53fb195..17a68e8c 100644
--- a/frontend/public/earth/js/tv.js
+++ b/frontend/public/earth/js/tv.js
@@ -21,8 +21,11 @@ let refreshPromise = null;
let hlsPlayer = null;
let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null;
+const failedSourceIds = new Set();
+let probeTimer = null;
const META_AUTO_COLLAPSE_DELAY = 2500;
+const PROBE_INTERVAL_MS = 2 * 60 * 1000;
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = {
@@ -392,7 +395,7 @@ function attachVideoSource(video, source) {
}
}
- if (!showEmbeddedFallback(source)) {
+ if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
@@ -425,6 +428,59 @@ function findSourceById(sourceId) {
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
}
+function markSourceFailed(sourceId) {
+ if (!sourceId) return;
+ failedSourceIds.add(sourceId);
+ renderSourceOptions();
+ if (!probeTimer) {
+ probeTimer = setInterval(probeFailedSources, PROBE_INTERVAL_MS);
+ }
+}
+
+function clearSourceFailed(sourceId) {
+ if (!failedSourceIds.has(sourceId)) return;
+ failedSourceIds.delete(sourceId);
+ renderSourceOptions();
+ if (failedSourceIds.size === 0 && probeTimer) {
+ clearInterval(probeTimer);
+ probeTimer = null;
+ }
+}
+
+async function probeFailedSources() {
+ if (failedSourceIds.size === 0) {
+ clearInterval(probeTimer);
+ probeTimer = null;
+ return;
+ }
+ for (const sourceId of [...failedSourceIds]) {
+ const source = findSourceById(sourceId);
+ if (!source) { failedSourceIds.delete(sourceId); continue; }
+ const probeUrl = source.stream_url || source.embed_url;
+ if (!probeUrl) continue;
+ try {
+ const resp = await fetch(probeUrl, {
+ method: "HEAD",
+ signal: AbortSignal.timeout(5000),
+ });
+ if (resp.ok) clearSourceFailed(sourceId);
+ } catch {
+ // 仍然失效,保持标记
+ }
+ }
+}
+
+function tryFallbackSource() {
+ const fallback = tvPayload?.fallback_source;
+ if (!fallback || fallback.id === currentSourceId) return false;
+ markSourceFailed(currentSourceId);
+ currentSourceId = fallback.id;
+ const { select } = getElements();
+ if (select) select.value = currentSourceId;
+ renderSource(fallback);
+ return true;
+}
+
function getCurrentSource() {
return findSourceById(currentSourceId);
}
@@ -487,10 +543,11 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment();
sources.forEach((source) => {
- const marker = source.id === tvPayload?.default_source_id ? " · 默认" : "";
+ const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
+ const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
const option = document.createElement("option");
option.value = source.id;
- option.textContent = `${source.name}${marker}`;
+ option.textContent = `${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option);
});
@@ -660,17 +717,19 @@ export function initTVPanel() {
iframe?.addEventListener("load", () => {
if (iframe.hidden) return;
+ clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
video?.addEventListener("loadedmetadata", () => {
if (video.hidden) return;
+ clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
video?.addEventListener("error", () => {
const currentSource = getCurrentSource();
- if (!showEmbeddedFallback(currentSource)) {
+ if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
diff --git a/frontend/src/components/TableActions/TableActions.tsx b/frontend/src/components/TableActions/TableActions.tsx
new file mode 100644
index 00000000..cf7e19f0
--- /dev/null
+++ b/frontend/src/components/TableActions/TableActions.tsx
@@ -0,0 +1,26 @@
+import type { MenuProps } from 'antd'
+import type { ReactNode } from 'react'
+import { Button, Dropdown } from 'antd'
+import { MoreOutlined } from '@ant-design/icons'
+
+interface Props {
+ collapsed: boolean
+ items: MenuProps['items']
+ children: ReactNode
+}
+
+/** onCell style for action columns — prevents overflow ellipsis and text wrapping */
+export const actionCellProps = {
+ style: { whiteSpace: 'nowrap' as const, textOverflow: 'clip' as const },
+}
+
+export function TableActions({ collapsed, items, children }: Props) {
+ if (collapsed) {
+ return (
+