feat: implement iptv

This commit is contained in:
shinya
2025-08-24 00:26:48 +08:00
parent 179e74bf45
commit b4e81d94eb
22 changed files with 2399 additions and 12 deletions

View File

@@ -33,6 +33,7 @@ import {
FileText,
FolderOpen,
Settings,
Tv,
Users,
Video,
} from 'lucide-react';
@@ -251,6 +252,18 @@ interface DataSource {
from: 'config' | 'custom';
}
// 直播源数据类型
interface LiveDataSource {
name: string;
key: string;
url: string;
ua?: string;
epg?: string;
channelNumber?: number;
disabled?: boolean;
from: 'config' | 'custom';
}
// 自定义分类数据类型
interface CustomCategory {
name?: string;
@@ -3858,6 +3871,425 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
);
};
// 直播源配置组件
const LiveSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const [liveSources, setLiveSources] = useState<LiveDataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [newLiveSource, setNewLiveSource] = useState<LiveDataSource>({
name: '',
key: '',
url: '',
ua: '',
epg: '',
disabled: false,
from: 'custom',
});
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.LiveConfig) {
setLiveSources(config.LiveConfig);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callLiveSourceApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/live', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (key: string) => {
const target = liveSources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
callLiveSourceApi({ action, key }).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
callLiveSourceApi({ action: 'delete', key }).catch(() => {
console.error('操作失败', 'delete', key);
});
};
// 刷新直播源
const handleRefreshLiveSources = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
const response = await fetch('/api/admin/live/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `刷新失败: ${response.status}`);
}
// 刷新成功后重新获取配置
await refreshConfig();
showAlert({ type: 'success', title: '刷新成功', message: '直播源已刷新', timer: 2000 });
} catch (err) {
showError(err instanceof Error ? err.message : '刷新失败', showAlert);
} finally {
setIsRefreshing(false);
}
};
const handleAddLiveSource = () => {
if (!newLiveSource.name || !newLiveSource.key || !newLiveSource.url) return;
callLiveSourceApi({
action: 'add',
key: newLiveSource.key,
name: newLiveSource.name,
url: newLiveSource.url,
ua: newLiveSource.ua,
epg: newLiveSource.epg,
})
.then(() => {
setNewLiveSource({
name: '',
key: '',
url: '',
epg: '',
ua: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newLiveSource);
});
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = liveSources.findIndex((s) => s.key === active.id);
const newIndex = liveSources.findIndex((s) => s.key === over.id);
setLiveSources((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = liveSources.map((s) => s.key);
callLiveSourceApi({ action: 'sort', order })
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ liveSource }: { liveSource: LiveDataSource }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: liveSource.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className='px-2 py-4 cursor-grab text-gray-400'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{liveSource.name}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{liveSource.key}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={liveSource.url}
>
{liveSource.url}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={liveSource.epg || '-'}
>
{liveSource.epg || '-'}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={liveSource.ua || '-'}
>
{liveSource.ua || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-center'>
{liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${!liveSource.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!liveSource.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => handleToggleEnable(liveSource.key)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!liveSource.disabled
? buttonStyles.roundedDanger
: buttonStyles.roundedSuccess
} transition-colors`}
>
{!liveSource.disabled ? '禁用' : '启用'}
</button>
{liveSource.from !== 'config' && (
<button
onClick={() => handleDelete(liveSource.key)}
className={buttonStyles.roundedSecondary}
>
</button>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加直播源表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<div className='flex items-center space-x-2'>
<button
onClick={handleRefreshLiveSources}
disabled={isRefreshing}
className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${isRefreshing
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg'
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors'
}`}
>
<span>{isRefreshing ? '刷新中...' : '刷新直播源'}</span>
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
>
{showAddForm ? '取消' : '添加直播源'}
</button>
</div>
</div>
{showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='名称'
value={newLiveSource.name}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, name: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='Key'
value={newLiveSource.key}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, key: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='M3U 地址'
value={newLiveSource.url}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, url: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='节目单地址(选填)'
value={newLiveSource.epg}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, epg: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='自定义 UA选填'
value={newLiveSource.ua}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, ua: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddLiveSource}
disabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url}
className={`w-full sm:w-auto px-4 py-2 ${!newLiveSource.name || !newLiveSource.key || !newLiveSource.url ? buttonStyles.disabled : buttonStyles.success}`}
>
</button>
</div>
</div>
)}
{/* 直播源表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="live-source-list">
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Key
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
M3U
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
UA
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={liveSources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{liveSources.map((liveSource) => (
<DraggableRow key={liveSource.key} liveSource={liveSource} />
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
className={`px-3 py-1.5 text-sm ${buttonStyles.primary}`}
>
</button>
</div>
)}
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
function AdminPageClient() {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const [config, setConfig] = useState<AdminConfig | null>(null);
@@ -3868,6 +4300,7 @@ function AdminPageClient() {
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
userConfig: false,
videoSource: false,
liveSource: false,
siteConfig: false,
categoryConfig: false,
configFile: false,
@@ -4042,6 +4475,18 @@ function AdminPageClient() {
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 直播源配置标签 */}
<CollapsibleTab
title='直播源配置'
icon={
<Tv size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.liveSource}
onToggle={() => toggleTab('liveSource')}
>
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 分类配置标签 */}
<CollapsibleTab
title='分类配置'