release: bump version to 0.27.7

This commit is contained in:
linkong
2026-04-16 10:04:14 +08:00
parent f8b43a995b
commit 8f3ab88743
13 changed files with 532 additions and 269 deletions

View File

@@ -1 +1 @@
0.27.6 0.27.7

View File

@@ -8,6 +8,21 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `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 ## [0.27.6] — 2026-04-15
### 🔧 Improvements ### 🔧 Improvements

View File

@@ -16,12 +16,13 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.27.6` - `dev` 当前开发分支历史推导到:`0.27.7`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复Playground 响应式按钮与输入框收起优化 | | `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复Playground 响应式按钮与输入框收起优化 |
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 | | `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 | | `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |

View File

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

View File

@@ -21,8 +21,11 @@ let refreshPromise = null;
let hlsPlayer = null; let hlsPlayer = null;
let hlsRecoveryAttempts = 0; let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null; let metaAutoCollapseTimer = null;
const failedSourceIds = new Set();
let probeTimer = null;
const META_AUTO_COLLAPSE_DELAY = 2500; const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
const HLS_MAX_RECOVERY_ATTEMPTS = 3; const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = { const HLS_RETRY_CONFIG = {
@@ -392,7 +395,7 @@ function attachVideoSource(video, source) {
} }
} }
if (!showEmbeddedFallback(source)) { if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError); setPanelMessage(TV_STATUS_MESSAGE.videoError);
} }
}); });
@@ -425,6 +428,59 @@ function findSourceById(sourceId) {
return tvPayload?.sources?.find((source) => source.id === sourceId) || null; 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() { function getCurrentSource() {
return findSourceById(currentSourceId); return findSourceById(currentSourceId);
} }
@@ -487,10 +543,11 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
sources.forEach((source) => { 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"); const option = document.createElement("option");
option.value = source.id; option.value = source.id;
option.textContent = `${source.name}${marker}`; option.textContent = `${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option); fragment.appendChild(option);
}); });
@@ -660,17 +717,19 @@ export function initTVPanel() {
iframe?.addEventListener("load", () => { iframe?.addEventListener("load", () => {
if (iframe.hidden) return; if (iframe.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady); setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
}); });
video?.addEventListener("loadedmetadata", () => { video?.addEventListener("loadedmetadata", () => {
if (video.hidden) return; if (video.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady); setPanelMessage(TV_STATUS_MESSAGE.videoReady);
}); });
video?.addEventListener("error", () => { video?.addEventListener("error", () => {
const currentSource = getCurrentSource(); const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource)) { if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError); setPanelMessage(TV_STATUS_MESSAGE.videoError);
} }
}); });

View File

@@ -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 (
<Dropdown trigger={['click']} menu={{ items }}>
<Button type="text" size="small" icon={<MoreOutlined />} />
</Dropdown>
)
}
return <div style={{ display: 'inline-flex', gap: 4 }}>{children}</div>
}

View File

@@ -1 +1,2 @@
export { useCollapsedActions } from './useCollapsedActions'
export { useWebSocket } from './useWebSocket' export { useWebSocket } from './useWebSocket'

View File

@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* 监听容器宽度,宽时展开操作按钮,窄时收入 Dropdown。
* @param threshold 折叠阈值px默认 700
* @returns [collapsed, callbackRef]
*/
export function useCollapsedActions(threshold = 700) {
const [collapsed, setCollapsed] = useState(false)
const observerRef = useRef<ResizeObserver | null>(null)
const elementRef = useRef<HTMLElement | null>(null)
const ref = useCallback(
(el: HTMLElement | null) => {
observerRef.current?.disconnect()
observerRef.current = null
elementRef.current = el
if (!el || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(([entry]) => {
setCollapsed(entry.contentRect.width < threshold)
})
observer.observe(el)
observerRef.current = observer
},
[threshold],
)
useEffect(() => {
return () => {
observerRef.current?.disconnect()
observerRef.current = null
elementRef.current = null
}
}, [])
return [collapsed, ref] as const
}

View File

@@ -2575,34 +2575,25 @@ body {
max-height: none !important; max-height: none !important;
} }
.settings-tv-toolbar {
.settings-tv-edit-modal .ant-modal-content {
overflow: hidden;
}
.settings-tv-edit-modal__body {
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
justify-content: space-between; height: min(80vh, 640px);
gap: 16px; min-height: 0;
flex-wrap: wrap; padding: 16px 0 0 24px;
} }
.settings-tv-toolbar__controls { .settings-tv-edit-modal__scroll {
display: flex; flex: 1 1 auto;
flex-wrap: wrap; min-height: 0;
gap: 16px; padding-right: 20px;
align-items: flex-end;
} }
.settings-tv-toolbar__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.settings-tv-field {
display: grid;
gap: 8px;
}
.data-list-workspace { .data-list-workspace {
min-height: 0; min-height: 0;

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import { import {
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal, Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
@@ -213,6 +215,8 @@ function DataSources() {
const customTableRegionRef = useRef<HTMLDivElement | null>(null) const customTableRegionRef = useRef<HTMLDivElement | null>(null)
const [builtinTableHeight, setBuiltinTableHeight] = useState(360) const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
const [customTableHeight, setCustomTableHeight] = useState(360) const [customTableHeight, setCustomTableHeight] = useState(360)
const [builtinActionsCollapsed, builtinContainerRef] = useCollapsedActions()
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
const [form] = Form.useForm() const [form] = Form.useForm()
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
@@ -940,10 +944,29 @@ function DataSources() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 200,
fixed: 'right' as const, fixed: 'right' as const,
width: builtinActionsCollapsed ? 40 : 164,
onCell: () => actionCellProps,
render: (_: unknown, record: BuiltInDataSource) => ( render: (_: unknown, record: BuiltInDataSource) => (
<Space size="small"> <TableActions
collapsed={builtinActionsCollapsed}
items={[
{
key: 'trigger',
label: '触发',
icon: <SyncOutlined />,
disabled: !record.is_active,
onClick: () => handleTrigger(record.id),
},
{
key: 'toggle',
label: record.is_active ? '禁用' : '启用',
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
danger: record.is_active,
onClick: () => handleToggle(record.id, record.is_active),
},
]}
>
<Button <Button
type="link" type="link"
size="small" size="small"
@@ -963,7 +986,7 @@ function DataSources() {
> >
{record.is_active ? '禁用' : '启用'} {record.is_active ? '禁用' : '启用'}
</Button> </Button>
</Space> </TableActions>
), ),
}, },
] ]
@@ -1001,30 +1024,56 @@ function DataSources() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 150,
fixed: 'right' as const, fixed: 'right' as const,
width: customActionsCollapsed ? 40 : 228,
onCell: () => actionCellProps,
render: (_: unknown, record: CustomDataSource) => ( render: (_: unknown, record: CustomDataSource) => (
<Space size="small"> <TableActions
<Tooltip title="编辑"> collapsed={customActionsCollapsed}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)} /> items={[
</Tooltip> {
<Tooltip title={record.is_active ? '禁用' : '启用'}> key: 'edit',
<Button label: '编辑',
type="link" icon: <EditOutlined />,
size="small" onClick: () => openDrawer(record),
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />} },
onClick={() => handleToggleCustom(record.id, record.is_active)} {
/> key: 'toggle',
</Tooltip> label: record.is_active ? '禁用' : '启用',
<Popconfirm icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
title="确定删除此配置?" danger: record.is_active,
onConfirm={() => handleDelete(record.id)} onClick: () => handleToggleCustom(record.id, record.is_active),
},
{ type: 'divider' },
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确定删除此配置?',
onOk: () => handleDelete(record.id),
})
},
},
]}
>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}></Button>
<Button
type="link"
size="small"
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
danger={record.is_active}
style={record.is_active ? undefined : { color: '#52c41a' }}
onClick={() => handleToggleCustom(record.id, record.is_active)}
> >
<Tooltip title="删除"> {record.is_active ? '禁用' : '启用'}
<Button type="link" size="small" danger icon={<DeleteOutlined />} /> </Button>
</Tooltip> <Popconfirm title="确定删除此配置?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm> </Popconfirm>
</Space> </TableActions>
), ),
}, },
] ]
@@ -1034,7 +1083,7 @@ function DataSources() {
key: 'builtin', key: 'builtin',
label: '内置数据源', label: '内置数据源',
children: ( children: (
<div className="page-shell__body data-source-builtin-tab"> <div className="page-shell__body data-source-builtin-tab" ref={builtinContainerRef}>
<div className="data-source-bulk-toolbar"> <div className="data-source-bulk-toolbar">
<div className="data-source-bulk-toolbar__meta"> <div className="data-source-bulk-toolbar__meta">
<div className="data-source-bulk-toolbar__title"></div> <div className="data-source-bulk-toolbar__title"></div>
@@ -1120,7 +1169,7 @@ function DataSources() {
</span> </span>
), ),
children: ( children: (
<div className="page-shell__body data-source-custom-tab"> <div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
<div className="data-source-custom-toolbar"> <div className="data-source-custom-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}> <Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>

View File

@@ -1,4 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { import {
Button, Button,
Card, Card,
@@ -6,11 +9,13 @@ import {
Input, Input,
InputNumber, InputNumber,
message, message,
Modal,
Select, Select,
Switch, Switch,
Table, Table,
Tabs, Tabs,
Tag, Tag,
Tooltip,
Typography, Typography,
} from 'antd' } from 'antd'
import axios from 'axios' import axios from 'axios'
@@ -108,11 +113,14 @@ function Settings() {
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null) const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null) const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
const [savingTvSettings, setSavingTvSettings] = useState(false) const [savingTvSettings, setSavingTvSettings] = useState(false)
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null) const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
const [collectorTableHeight, setCollectorTableHeight] = useState(360) const [collectorTableHeight, setCollectorTableHeight] = useState(360)
const [systemForm] = Form.useForm<SystemSettings>() const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>() const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>() const [securityForm] = Form.useForm<SecuritySettings>()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
@@ -206,90 +214,95 @@ function Settings() {
} }
} }
const updateTvSetting = <K extends keyof TVSettings>(field: K, value: TVSettings[K]) => { const setDefaultSource = (sourceId: string) => {
setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev)) if (!tvSettings) return
} const next = { ...tvSettings, default_source_id: sourceId }
setTvSettings(next)
const updateTvSourceField = <K extends keyof TVStreamSource>( saveTvSettings(next)
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 = () => { const addTvSource = () => {
setTvSettings((prev) => { const nextIndex = (tvSettings?.sources.length || 0) + 1
if (!prev) return prev const newSource: TVStreamSource = {
const nextIndex = prev.sources.length + 1 id: `manual-tv-${Date.now()}`,
const newSource: TVStreamSource = { name: `新闻直播源 ${nextIndex}`,
id: `manual-tv-${Date.now()}`, provider: 'Manual',
name: `新闻直播源 ${nextIndex}`, region: 'Global',
provider: 'Manual', language: 'und',
region: 'Global', source_type: 'iframe',
language: 'und', embed_url: '',
source_type: 'iframe', stream_url: '',
embed_url: '', homepage_url: '',
stream_url: '', poster_url: '',
homepage_url: '', youtube_video_id: '',
poster_url: '', youtube_channel: '',
youtube_video_id: '', is_enabled: true,
youtube_channel: '', is_fallback: false,
is_enabled: true, sort_order: nextIndex * 10,
is_fallback: false, collector_source: null,
sort_order: nextIndex * 10, notes: '',
collector_source: null, }
notes: '', setEditingSource(newSource)
} tvEditForm.setFieldsValue(newSource)
return {
...prev,
sources: [...prev.sources, newSource],
}
})
} }
const removeTvSource = (sourceId: string) => { const confirmEditSource = async () => {
setTvSettings((prev) => { if (!editingSource || !tvSettings) return
if (!prev) return prev const values = tvEditForm.getFieldsValue()
const nextSources = prev.sources.filter((source) => source.id !== sourceId) const nextSources = tvSettings.sources
const nextDefaultSourceId = .map((source) => {
prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id if (source.id === editingSource.id) return { ...source, ...values }
return { if (values.is_fallback) return { ...source, is_fallback: false }
...prev, return source
default_source_id: nextDefaultSourceId, })
sources: nextSources,
if (!tvSettings.sources.some((source) => source.id === editingSource.id)) {
nextSources.push({
...editingSource,
...values,
})
if (values.is_fallback) {
for (let index = 0; index < nextSources.length - 1; index += 1) {
nextSources[index] = { ...nextSources[index], is_fallback: false }
}
} }
}) }
const nextDefaultSourceId =
values.is_enabled === false && tvSettings.default_source_id === editingSource.id
? nextSources.find((s) => s.id !== editingSource.id && s.is_enabled)?.id || ''
: tvSettings.default_source_id
const next = { ...tvSettings, default_source_id: nextDefaultSourceId, sources: nextSources }
setTvSettings(next)
setEditingSource(null)
await saveTvSettings(next)
} }
const saveTvSettings = async () => { const removeTvSource = async (sourceId: string) => {
if (!tvSettings) return if (!tvSettings) return
const nextSources = tvSettings.sources.filter((source) => source.id !== sourceId)
const nextDefaultSourceId =
tvSettings.default_source_id === sourceId ? nextSources[0]?.id || '' : tvSettings.default_source_id
const next = {
...tvSettings,
default_source_id: nextDefaultSourceId,
sources: nextSources,
}
setTvSettings(next)
if (editingSource?.id === sourceId) {
setEditingSource(null)
}
await saveTvSettings(next)
}
const saveTvSettings = async (next?: TVSettings) => {
const toSave = next ?? tvSettings
if (!toSave) return
try { try {
setSavingTvSettings(true) setSavingTvSettings(true)
await axios.put('/api/v1/settings/tv', tvSettings) await axios.put('/api/v1/settings/tv', toSave)
message.success('电视直播配置已保存') message.success('电视直播配置已保存')
await fetchSettings() await fetchSettings()
} catch (error) { } catch (error) {
@@ -402,105 +415,39 @@ function Settings() {
const tvSourceColumns = [ const tvSourceColumns = [
{ {
title: '频道', title: '频道',
dataIndex: 'name',
key: 'name', key: 'name',
width: 220, width: 180,
render: (_: string, record: TVStreamSource) => ( render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}> <div>
<Input value={record.name} onChange={(event) => updateTvSourceField(record.id, 'name', event.target.value)} /> <div style={{ fontWeight: 500 }}>{record.name}</div>
<Input <Text type="secondary" style={{ fontSize: 12 }}>{record.provider}</Text>
value={record.provider}
placeholder="提供方"
onChange={(event) => updateTvSourceField(record.id, 'provider', event.target.value)}
/>
</div> </div>
), ),
}, },
{ {
title: '区域 / 语言', title: '区域 / 语言',
key: 'locale', key: 'locale',
width: 160, width: 130,
render: (_: unknown, record: TVStreamSource) => ( render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}> <Text type="secondary">{record.region} · {record.language}</Text>
<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: '类型', title: '类型',
dataIndex: 'source_type', dataIndex: 'source_type',
key: 'source_type', key: 'source_type',
width: 120, width: 90,
render: (value: TVStreamSource['source_type'], record: TVStreamSource) => ( render: (value: string) => <Tag>{value}</Tag>,
<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: '状态', title: '状态',
key: 'status', key: 'status',
width: 110, width: 130,
render: (_: unknown, record: TVStreamSource) => ( render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' as const }}>
<Switch checked={record.is_enabled} onChange={(checked) => updateTvSourceField(record.id, 'is_enabled', checked)} /> <Tag color={record.is_enabled ? 'success' : 'default'}>{record.is_enabled ? '启用' : '禁用'}</Tag>
<Switch checked={record.is_fallback} onChange={(checked) => updateTvSourceField(record.id, 'is_fallback', checked)} /> {record.id === tvSettings?.default_source_id && <Tag color="gold"></Tag>}
{record.is_fallback && <Tag color="blue"></Tag>}
</div> </div>
), ),
}, },
@@ -508,20 +455,82 @@ function Settings() {
title: '备注', title: '备注',
dataIndex: 'notes', dataIndex: 'notes',
key: 'notes', key: 'notes',
width: 220, width: 200,
render: (value: string, record: TVStreamSource) => ( ellipsis: true,
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} /> render: (value: string) => <Text type="secondary">{value || '—'}</Text>,
),
}, },
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 90,
fixed: 'right' as const, fixed: 'right' as const,
width: tvActionsCollapsed ? 40 : 258,
onCell: () => actionCellProps,
render: (_: unknown, record: TVStreamSource) => ( render: (_: unknown, record: TVStreamSource) => (
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}> <TableActions
collapsed={tvActionsCollapsed}
</Button> items={[
{
key: 'default',
label: '设为默认',
icon: <CheckCircleOutlined />,
disabled: record.id === tvSettings?.default_source_id,
onClick: () => setDefaultSource(record.id),
},
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => {
setEditingSource(record)
tvEditForm.setFieldsValue(record)
},
},
{ type: 'divider' },
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
disabled: record.id === tvSettings?.default_source_id,
onClick: () => {
void removeTvSource(record.id)
},
},
]}
>
<Button
type="link"
size="small"
icon={<CheckCircleOutlined />}
disabled={record.id === tvSettings?.default_source_id}
onClick={() => setDefaultSource(record.id)}
>
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditingSource(record)
tvEditForm.setFieldsValue(record)
}}
>
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={record.id === tvSettings?.default_source_id}
onClick={() => {
void removeTvSource(record.id)
}}
>
</Button>
</TableActions>
), ),
}, },
] ]
@@ -612,51 +621,111 @@ function Settings() {
key: 'tv', key: 'tv',
label: '电视直播', label: '电视直播',
children: ( children: (
<div className="settings-pane"> <div className="settings-pane" ref={tvTableRef}>
<Card className="settings-panel-card settings-panel-card--table" loading={loading}> <Card
<div className="settings-panel-scroll" style={{ display: 'grid', gap: 16 }}> className="settings-panel-card settings-panel-card--table"
<div className="settings-tv-toolbar"> loading={loading}
<div className="settings-tv-toolbar__controls"> styles={{ body: { padding: 0 } }}
<div className="settings-tv-field"> >
<Text type="secondary"></Text> <TableScrollRegion
<Select className="data-source-table-region"
value={tvSettings?.default_source_id} style={{ flex: '1 1 auto', minHeight: 0 }}
style={{ minWidth: 260 }} >
options={(tvSettings?.sources || []).map((source) => ({ <Table
value: source.id, rowKey="id"
label: source.name, columns={tvSourceColumns}
}))} dataSource={tvSettings?.sources || []}
onChange={(value) => updateTvSetting('default_source_id', value)} pagination={false}
/> scroll={{ x: 'max-content', y: 420 }}
</div> tableLayout="fixed"
<div className="settings-tv-field"> size="small"
<Text type="secondary">退</Text> />
<Switch </TableScrollRegion>
checked={tvSettings?.auto_fallback || false} <Tooltip title="新增直播源">
onChange={(checked) => updateTvSetting('auto_fallback', checked)} <Button
/> type="text"
</div> icon={<PlusOutlined />}
</div> onClick={addTvSource}
<div className="settings-tv-toolbar__actions"> style={{ width: '100%', borderRadius: 0, borderTop: '1px solid rgba(0,0,0,0.06)' }}
<Button onClick={addTvSource}></Button> />
<Button type="primary" loading={savingTvSettings} onClick={saveTvSettings}> </Tooltip>
</Button>
</div>
</div>
<TableScrollRegion className="data-source-table-region">
<Table
rowKey="id"
columns={tvSourceColumns}
dataSource={tvSettings?.sources || []}
pagination={false}
scroll={{ x: 1500, y: 420 }}
tableLayout="fixed"
size="small"
/>
</TableScrollRegion>
</div>
</Card> </Card>
<Modal
title={editingSource?.id.startsWith('manual-tv-') ? '新增直播源' : '编辑直播源'}
open={editingSource !== null}
onOk={confirmEditSource}
onCancel={() => {
setEditingSource(null)
tvEditForm.resetFields()
}}
okText="保存"
okButtonProps={{ loading: savingTvSettings }}
cancelText="取消"
width={560}
centered
destroyOnHidden
className="settings-tv-edit-modal"
styles={{ body: { padding: 0 } }}
>
<div className="settings-tv-edit-modal__body">
<Scrollbar className="settings-tv-edit-modal__scroll">
<Form form={tvEditForm} layout="vertical" style={{ paddingBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="name" label="频道名称" rules={[{ required: true, message: '请输入频道名称' }]}>
<Input />
</Form.Item>
<Form.Item name="provider" label="提供方">
<Input />
</Form.Item>
<Form.Item name="region" label="区域">
<Input />
</Form.Item>
<Form.Item name="language" label="语言">
<Input />
</Form.Item>
<Form.Item name="source_type" label="类型">
<Select options={[
{ value: 'iframe', label: 'iframe' },
{ value: 'hls', label: 'HLS' },
{ value: 'video', label: 'video' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'external', label: 'external仅外部打开' },
]} />
</Form.Item>
<Form.Item name="sort_order" label="排序">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</div>
<Form.Item name="embed_url" label="嵌入地址 / iframe 地址">
<Input />
</Form.Item>
<Form.Item name="stream_url" label="流地址 / HLS 地址">
<Input />
</Form.Item>
<Form.Item name="youtube_video_id" label="YouTube 视频 ID">
<Input />
</Form.Item>
<Form.Item name="youtube_channel" label="YouTube 频道 Handle / URL">
<Input />
</Form.Item>
<Form.Item name="homepage_url" label="官网地址">
<Input />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="is_fallback" label="设为备用源" valuePropName="checked">
<Switch />
</Form.Item>
</div>
</Form>
</Scrollbar>
</div>
</Modal>
</div> </div>
), ),
}, },

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd' import { Table, Button, Tag, message, Modal, Form, Input, Select } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion' import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
@@ -20,6 +22,7 @@ function Users() {
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null) const [editingUser, setEditingUser] = useState<User | null>(null)
const [form] = Form.useForm() const [form] = Form.useForm()
const [actionsCollapsed, containerRef] = useCollapsedActions()
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true) setLoading(true)
@@ -107,12 +110,21 @@ function Users() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 180, fixed: 'right' as const,
width: actionsCollapsed ? 56 : 172,
onCell: () => actionCellProps,
render: (_: unknown, record: User) => ( render: (_: unknown, record: User) => (
<Space> <TableActions
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button> collapsed={actionsCollapsed}
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button> items={[
</Space> { key: 'edit', label: '编辑', icon: <EditOutlined />, onClick: () => handleEdit(record) },
{ type: 'divider' },
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, onClick: () => handleDelete(record.id) },
]}
>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
</TableActions>
), ),
}, },
] ]
@@ -124,15 +136,16 @@ function Users() {
<h2 style={{ margin: 0 }}></h2> <h2 style={{ margin: 0 }}></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div> </div>
<div className="page-shell__body"> <div className="page-shell__body" ref={containerRef}>
<TableScrollRegion className="data-source-table-region users-table-region"> <TableScrollRegion className="data-source-table-region users-table-region">
<Table <Table
columns={columns} columns={columns}
dataSource={users} dataSource={users}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
scroll={{ x: 960, y: 10000 }} scroll={{ x: 'max-content' }}
pagination={false} pagination={false}
size="small"
tableLayout="fixed" tableLayout="fixed"
/> />
</TableScrollRegion> </TableScrollRegion>

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "planet" name = "planet"
version = "0.27.6" version = "0.27.7"
description = "智能星球计划 - 态势感知系统" description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [