Files
planet/frontend/src/pages/Settings/Settings.tsx
2026-04-16 10:04:14 +08:00

778 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Title, Text } = Typography
interface SystemSettings {
system_name: string
refresh_interval: number
auto_refresh: boolean
data_retention_days: number
max_concurrent_tasks: number
}
interface NotificationSettings {
email_enabled: boolean
email_address: string
critical_alerts: boolean
warning_alerts: boolean
daily_summary: boolean
}
interface SecuritySettings {
session_timeout: number
max_login_attempts: number
password_policy: string
}
interface CollectorSettings {
id: number
name: string
source: string
module: string
priority: string
frequency_minutes: number
frequency: string
is_active: boolean
last_run_at: string | null
last_status: string | null
next_run_at: string | null
}
interface TVStreamSource {
id: string
name: string
provider: string
region: string
language: string
source_type: 'iframe' | 'hls' | 'video' | 'external' | 'youtube'
embed_url: string
stream_url: string
homepage_url: string
poster_url: string
youtube_video_id: string
youtube_channel: string
is_enabled: boolean
is_fallback: boolean
sort_order: number
collector_source: string | null
notes: string
}
interface TVSettings {
default_source_id: string
auto_fallback: boolean
sources: TVStreamSource[]
}
function SettingsPanel({
loading,
children,
}: {
loading: boolean
children: ReactNode
}) {
return (
<div className="settings-pane">
<Card className="settings-panel-card" loading={loading}>
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
</Card>
</div>
)
}
function Settings() {
const [loading, setLoading] = useState(true)
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
const [systemSettings, setSystemSettings] = useState<SystemSettings | null>(null)
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
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 {
setLoading(true)
const response = await axios.get('/api/v1/settings')
setSystemSettings(response.data.system)
setNotificationSettings(response.data.notifications)
setSecuritySettings(response.data.security)
setTvSettings(response.data.tv || null)
setCollectors(response.data.collectors || [])
} catch (error) {
message.error('获取系统配置失败')
console.error(error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSettings()
}, [])
useEffect(() => {
if (!loading && systemSettings) {
systemForm.setFieldsValue(systemSettings)
}
}, [loading, systemForm, systemSettings])
useEffect(() => {
if (!loading && notificationSettings) {
notificationForm.setFieldsValue(notificationSettings)
}
}, [loading, notificationForm, notificationSettings])
useEffect(() => {
if (!loading && securitySettings) {
securityForm.setFieldsValue(securitySettings)
}
}, [loading, securityForm, securitySettings])
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = collectorTableRegionRef.current?.offsetHeight || 0
setCollectorTableHeight(Math.max(220, regionHeight - 56))
}
updateTableHeight()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateTableHeight)
if (collectorTableRegionRef.current) observer.observe(collectorTableRegionRef.current)
return () => observer.disconnect()
}, [collectors.length])
const saveSection = async (section: 'system' | 'notifications' | 'security', values: object) => {
try {
await axios.put(`/api/v1/settings/${section}`, values)
message.success('配置已保存')
await fetchSettings()
} catch (error) {
message.error('保存失败')
console.error(error)
}
}
const updateCollectorField = (id: number, field: keyof CollectorSettings, value: string | number | boolean) => {
setCollectors((prev) =>
prev.map((collector) => (collector.id === id ? { ...collector, [field]: value } : collector))
)
}
const saveCollector = async (collector: CollectorSettings) => {
try {
setSavingCollectorId(collector.id)
await axios.put(`/api/v1/settings/collectors/${collector.id}`, {
is_active: collector.is_active,
priority: collector.priority,
frequency_minutes: collector.frequency_minutes,
})
message.success(`${collector.name} 配置已更新`)
await fetchSettings()
} catch (error) {
message.error('采集调度配置保存失败')
console.error(error)
} finally {
setSavingCollectorId(null)
}
}
const setDefaultSource = (sourceId: string) => {
if (!tvSettings) return
const next = { ...tvSettings, default_source_id: sourceId }
setTvSettings(next)
saveTvSettings(next)
}
const addTvSource = () => {
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 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 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', toSave)
message.success('电视直播配置已保存')
await fetchSettings()
} catch (error) {
message.error('电视直播配置保存失败')
console.error(error)
} finally {
setSavingTvSettings(false)
}
}
const collectorColumns = [
{
title: '数据源',
dataIndex: 'name',
key: 'name',
render: (_: string, record: CollectorSettings) => (
<div>
<div>{record.name}</div>
<Text type="secondary">{record.source}</Text>
</div>
),
},
{
title: '层级',
dataIndex: 'module',
key: 'module',
width: 90,
render: (module: string) => <Tag color="blue">{module}</Tag>,
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 130,
render: (priority: string, record: CollectorSettings) => (
<Select
value={priority}
style={{ width: '100%' }}
onChange={(value) => updateCollectorField(record.id, 'priority', value)}
options={[
{ value: 'P0', label: 'P0' },
{ value: 'P1', label: 'P1' },
{ value: 'P2', label: 'P2' },
]}
/>
),
},
{
title: '频率(分钟)',
dataIndex: 'frequency_minutes',
key: 'frequency_minutes',
width: 150,
render: (value: number, record: CollectorSettings) => (
<InputNumber
min={1}
max={10080}
value={value}
style={{ width: '100%' }}
onChange={(nextValue) => updateCollectorField(record.id, 'frequency_minutes', nextValue || 1)}
/>
),
},
{
title: '启用',
dataIndex: 'is_active',
key: 'is_active',
width: 90,
render: (value: boolean, record: CollectorSettings) => (
<Switch checked={value} onChange={(checked) => updateCollectorField(record.id, 'is_active', checked)} />
),
},
{
title: '上次执行',
dataIndex: 'last_run_at',
key: 'last_run_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '下次执行',
dataIndex: 'next_run_at',
key: 'next_run_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '状态',
dataIndex: 'last_status',
key: 'last_status',
width: 120,
render: (value: string | null) => {
if (!value) return <Tag></Tag>
const color = value === 'success' ? 'success' : value === 'failed' ? 'error' : 'default'
return <Tag color={color}>{value}</Tag>
},
},
{
title: '操作',
key: 'action',
width: 92,
fixed: 'right' as const,
render: (_: unknown, record: CollectorSettings) => (
<Button type="primary" loading={savingCollectorId === record.id} onClick={() => saveCollector(record)}>
</Button>
),
},
]
const tvSourceColumns = [
{
title: '频道',
key: 'name',
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: 130,
render: (_: unknown, record: TVStreamSource) => (
<Text type="secondary">{record.region} · {record.language}</Text>
),
},
{
title: '类型',
dataIndex: 'source_type',
key: 'source_type',
width: 90,
render: (value: string) => <Tag>{value}</Tag>,
},
{
title: '状态',
key: 'status',
width: 130,
render: (_: unknown, record: TVStreamSource) => (
<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>
),
},
{
title: '备注',
dataIndex: 'notes',
key: 'notes',
width: 200,
ellipsis: true,
render: (value: string) => <Text type="secondary">{value || '—'}</Text>,
},
{
title: '操作',
key: 'action',
fixed: 'right' as const,
width: tvActionsCollapsed ? 40 : 258,
onCell: () => actionCellProps,
render: (_: unknown, record: TVStreamSource) => (
<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>
),
},
]
const tabItems = [
{
key: 'system',
label: '系统显示',
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<Form form={systemForm} layout="vertical" onFinish={(values) => saveSection('system', values)}>
<Form.Item name="system_name" label="系统名称" rules={[{ required: true, message: '请输入系统名称' }]}>
<Input />
</Form.Item>
<Form.Item name="refresh_interval" label="默认刷新间隔(秒)">
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="data_retention_days" label="数据保留天数">
<InputNumber min={1} max={3650} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_concurrent_tasks" label="最大并发任务数">
<InputNumber min={1} max={50} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="auto_refresh" label="自动刷新" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</SettingsPanel>
),
},
{
key: 'notifications',
label: '通知策略',
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<Form form={notificationForm} layout="vertical" onFinish={(values) => saveSection('notifications', values)}>
<Form.Item name="email_enabled" label="启用邮件通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="email_address" label="通知邮箱">
<Input />
</Form.Item>
<Form.Item name="critical_alerts" label="严重告警通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="warning_alerts" label="警告告警通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="daily_summary" label="每日摘要" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</SettingsPanel>
),
},
{
key: 'security',
label: '安全策略',
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<Form form={securityForm} layout="vertical" onFinish={(values) => saveSection('security', values)}>
<Form.Item name="session_timeout" label="会话超时(分钟)">
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_login_attempts" label="最大登录尝试次数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="password_policy" label="密码策略">
<Select
options={[
{ value: 'low', label: '简单' },
{ value: 'medium', label: '中等' },
{ value: 'high', label: '严格' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</SettingsPanel>
),
},
{
key: 'tv',
label: '电视直播',
children: (
<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>
),
},
{
key: 'collectors',
label: '采集调度',
children: (
<div className="settings-pane">
<Card
className="settings-panel-card settings-panel-card--table"
loading={loading}
styles={{ body: { padding: 0 } }}
>
<TableScrollRegion ref={collectorTableRegionRef} className="data-source-table-region">
<Table
rowKey="id"
columns={collectorColumns}
dataSource={collectors}
pagination={false}
scroll={{ x: 1200, y: collectorTableHeight }}
tableLayout="fixed"
size="small"
/>
</TableScrollRegion>
</Card>
</div>
),
},
]
return (
<AppLayout>
<div className="page-shell settings-shell">
<div className="page-shell__header">
<div>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary"></Text>
</div>
</div>
<div className="page-shell__body settings-tabs-shell">
<Tabs className="settings-tabs" items={tabItems} />
</div>
</div>
</AppLayout>
)
}
export default Settings