release: bump version to 0.27.7
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 懒加载动态挂载,页面初始不再有隐藏节点 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.27.6",
|
||||
"version": "0.27.7",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
26
frontend/src/components/TableActions/TableActions.tsx
Normal file
26
frontend/src/components/TableActions/TableActions.tsx
Normal 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>
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { useCollapsedActions } from './useCollapsedActions'
|
||||
export { useWebSocket } from './useWebSocket'
|
||||
|
||||
39
frontend/src/hooks/useCollapsedActions.ts
Normal file
39
frontend/src/hooks/useCollapsedActions.ts
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}>
|
||||
添加数据源
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user