release: bump version to 0.26.0

This commit is contained in:
rayd1o
2026-04-12 04:36:38 +08:00
parent a359d94127
commit 812c825dc6
22 changed files with 2153 additions and 7 deletions

View File

@@ -2315,6 +2315,32 @@ body {
max-height: none !important;
}
.settings-tv-toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.settings-tv-toolbar__controls {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
}
.settings-tv-toolbar__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.settings-tv-field {
display: grid;
gap: 8px;
}

View File

@@ -55,6 +55,32 @@ interface CollectorSettings {
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,
@@ -78,6 +104,8 @@ function Settings() {
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 collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
const [systemForm] = Form.useForm<SystemSettings>()
@@ -91,6 +119,7 @@ function 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('获取系统配置失败')
@@ -175,6 +204,100 @@ 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 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 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 saveTvSettings = async () => {
if (!tvSettings) return
try {
setSavingTvSettings(true)
await axios.put('/api/v1/settings/tv', tvSettings)
message.success('电视直播配置已保存')
await fetchSettings()
} catch (error) {
message.error('电视直播配置保存失败')
console.error(error)
} finally {
setSavingTvSettings(false)
}
}
const collectorColumns = [
{
title: '数据源',
@@ -274,6 +397,133 @@ 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)}
/>
</div>
),
},
{
title: '区域 / 语言',
key: 'locale',
width: 160,
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>
),
},
{
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)} />
),
},
{
title: '状态',
key: 'status',
width: 110,
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>
),
},
{
title: '备注',
dataIndex: 'notes',
key: 'notes',
width: 220,
render: (value: string, record: TVStreamSource) => (
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} />
),
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right' as const,
render: (_: unknown, record: TVStreamSource) => (
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}>
</Button>
),
},
]
const tabItems = [
{
key: 'system',
@@ -356,6 +606,58 @@ function Settings() {
</SettingsPanel>
),
},
{
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>
<div className="table-scroll-region data-source-table-region">
<Table
rowKey="id"
columns={tvSourceColumns}
dataSource={tvSettings?.sources || []}
pagination={false}
scroll={{ x: 1500, y: 420 }}
tableLayout="fixed"
size="small"
/>
</div>
</div>
</Card>
</div>
),
},
{
key: 'collectors',
label: '采集调度',