mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 09:14:42 +08:00
feat: implement iptv
This commit is contained in:
@@ -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='分类配置'
|
||||
|
||||
Reference in New Issue
Block a user