Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3ab88743 |
@@ -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
|
||||||
|
|||||||
@@ -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 懒加载动态挂载,页面初始不再有隐藏节点 |
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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'
|
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;
|
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;
|
||||||
|
|||||||
@@ -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()}>
|
||||||
添加数据源
|
添加数据源
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user