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 + 小功能混合)
- `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

View File

@@ -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 懒加载动态挂载,页面初始不再有隐藏节点 |

View File

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

View File

@@ -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);
}
});

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'

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;
}
.settings-tv-toolbar {
.settings-tv-edit-modal .ant-modal-content {
overflow: hidden;
}
.settings-tv-edit-modal__body {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
flex-direction: column;
height: min(80vh, 640px);
min-height: 0;
padding: 16px 0 0 24px;
}
.settings-tv-toolbar__controls {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
.settings-tv-edit-modal__scroll {
flex: 1 1 auto;
min-height: 0;
padding-right: 20px;
}
.settings-tv-toolbar__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.settings-tv-field {
display: grid;
gap: 8px;
}
.data-list-workspace {
min-height: 0;

View File

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

View File

@@ -1,4 +1,7 @@
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 {
Button,
Card,
@@ -6,11 +9,13 @@ import {
Input,
InputNumber,
message,
Modal,
Select,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import axios from 'axios'
@@ -108,11 +113,14 @@ function Settings() {
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
const [savingTvSettings, setSavingTvSettings] = useState(false)
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const fetchSettings = async () => {
try {
@@ -206,90 +214,95 @@ 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 setDefaultSource = (sourceId: string) => {
if (!tvSettings) return
const next = { ...tvSettings, default_source_id: sourceId }
setTvSettings(next)
saveTvSettings(next)
}
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 nextIndex = (tvSettings?.sources.length || 0) + 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: '',
}
setEditingSource(newSource)
tvEditForm.setFieldsValue(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 confirmEditSource = async () => {
if (!editingSource || !tvSettings) return
const values = tvEditForm.getFieldsValue()
const nextSources = tvSettings.sources
.map((source) => {
if (source.id === editingSource.id) return { ...source, ...values }
if (values.is_fallback) return { ...source, is_fallback: false }
return source
})
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
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 {
setSavingTvSettings(true)
await axios.put('/api/v1/settings/tv', tvSettings)
await axios.put('/api/v1/settings/tv', toSave)
message.success('电视直播配置已保存')
await fetchSettings()
} catch (error) {
@@ -402,105 +415,39 @@ 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)}
/>
width: 180,
render: (_: unknown, record: TVStreamSource) => (
<div>
<div style={{ fontWeight: 500 }}>{record.name}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{record.provider}</Text>
</div>
),
},
{
title: '区域 / 语言',
key: 'locale',
width: 160,
width: 130,
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>
<Text type="secondary">{record.region} · {record.language}</Text>
),
},
{
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)} />
),
width: 90,
render: (value: string) => <Tag>{value}</Tag>,
},
{
title: '状态',
key: 'status',
width: 110,
width: 130,
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 style={{ display: 'flex', gap: 4, flexWrap: 'wrap' as const }}>
<Tag color={record.is_enabled ? 'success' : 'default'}>{record.is_enabled ? '启用' : '禁用'}</Tag>
{record.id === tvSettings?.default_source_id && <Tag color="gold"></Tag>}
{record.is_fallback && <Tag color="blue"></Tag>}
</div>
),
},
@@ -508,20 +455,82 @@ function Settings() {
title: '备注',
dataIndex: 'notes',
key: 'notes',
width: 220,
render: (value: string, record: TVStreamSource) => (
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} />
),
width: 200,
ellipsis: true,
render: (value: string) => <Text type="secondary">{value || '—'}</Text>,
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right' as const,
width: tvActionsCollapsed ? 40 : 258,
onCell: () => actionCellProps,
render: (_: unknown, record: TVStreamSource) => (
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}>
</Button>
<TableActions
collapsed={tvActionsCollapsed}
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',
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>
<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>
<div className="settings-pane" ref={tvTableRef}>
<Card
className="settings-panel-card settings-panel-card--table"
loading={loading}
styles={{ body: { padding: 0 } }}
>
<TableScrollRegion
className="data-source-table-region"
style={{ flex: '1 1 auto', minHeight: 0 }}
>
<Table
rowKey="id"
columns={tvSourceColumns}
dataSource={tvSettings?.sources || []}
pagination={false}
scroll={{ x: 'max-content', y: 420 }}
tableLayout="fixed"
size="small"
/>
</TableScrollRegion>
<Tooltip title="新增直播源">
<Button
type="text"
icon={<PlusOutlined />}
onClick={addTvSource}
style={{ width: '100%', borderRadius: 0, borderTop: '1px solid rgba(0,0,0,0.06)' }}
/>
</Tooltip>
</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>
),
},

View File

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

View File

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