增加网络直播
This commit is contained in:
@@ -10715,6 +10715,262 @@ const LiveSourceConfig = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 网络直播配置组件
|
||||||
|
const WebLiveConfig = ({
|
||||||
|
config,
|
||||||
|
refreshConfig,
|
||||||
|
}: {
|
||||||
|
config: AdminConfig | null;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
|
const [webLiveSources, setWebLiveSources] = useState<any[]>([]);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [editingSource, setEditingSource] = useState<any | null>(null);
|
||||||
|
const [newSource, setNewSource] = useState({
|
||||||
|
name: '',
|
||||||
|
platform: 'huya',
|
||||||
|
roomId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.WebLiveConfig) {
|
||||||
|
setWebLiveSources(config.WebLiveConfig);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const callApi = async (body: Record<string, any>) => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/admin/web-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 handleAdd = () => {
|
||||||
|
if (!newSource.name || !newSource.platform || !newSource.roomId) return;
|
||||||
|
withLoading('addWebLive', async () => {
|
||||||
|
await callApi({
|
||||||
|
action: 'add',
|
||||||
|
name: newSource.name,
|
||||||
|
platform: newSource.platform,
|
||||||
|
roomId: newSource.roomId,
|
||||||
|
});
|
||||||
|
setNewSource({ name: '', platform: 'huya', roomId: '' });
|
||||||
|
setShowAddForm(false);
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (!editingSource || !editingSource.name || !editingSource.roomId) return;
|
||||||
|
withLoading('editWebLive', async () => {
|
||||||
|
await callApi({
|
||||||
|
action: 'edit',
|
||||||
|
key: editingSource.key,
|
||||||
|
name: editingSource.name,
|
||||||
|
platform: editingSource.platform,
|
||||||
|
roomId: editingSource.roomId,
|
||||||
|
});
|
||||||
|
setEditingSource(null);
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (key: string) => {
|
||||||
|
const target = webLiveSources.find((s) => s.key === key);
|
||||||
|
if (!target) return;
|
||||||
|
const action = target.disabled ? 'enable' : 'disable';
|
||||||
|
withLoading(`toggleWebLive_${key}`, () => callApi({ action, key })).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (key: string) => {
|
||||||
|
withLoading(`deleteWebLive_${key}`, () => callApi({ action: 'delete', key })).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
|
||||||
|
>
|
||||||
|
{showAddForm ? '取消' : '添加网络直播'}
|
||||||
|
</button>
|
||||||
|
</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={newSource.name}
|
||||||
|
onChange={(e) => setNewSource((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'
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newSource.platform}
|
||||||
|
onChange={(e) => setNewSource((prev) => ({ ...prev, platform: 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'
|
||||||
|
>
|
||||||
|
<option value='huya'>虎牙</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='房间ID'
|
||||||
|
value={newSource.roomId}
|
||||||
|
onChange={(e) => setNewSource((prev) => ({ ...prev, roomId: 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={handleAdd}
|
||||||
|
disabled={!newSource.name || !newSource.platform || !newSource.roomId || isLoading('addWebLive')}
|
||||||
|
className={`w-full sm:w-auto px-4 py-2 ${
|
||||||
|
!newSource.name || !newSource.platform || !newSource.roomId || isLoading('addWebLive')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading('addWebLive') ? '添加中...' : '添加'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSource && (
|
||||||
|
<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='flex items-center justify-between'>
|
||||||
|
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>编辑: {editingSource.name}</h5>
|
||||||
|
<button onClick={() => setEditingSource(null)} className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>名称</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={editingSource.name}
|
||||||
|
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, name: e.target.value } : null)}
|
||||||
|
className='w-full 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>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>直播类型</label>
|
||||||
|
<select
|
||||||
|
value={editingSource.platform}
|
||||||
|
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, platform: e.target.value } : null)}
|
||||||
|
className='w-full 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'
|
||||||
|
>
|
||||||
|
<option value='huya'>虎牙</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>房间ID</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={editingSource.roomId}
|
||||||
|
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, roomId: e.target.value } : null)}
|
||||||
|
className='w-full 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>
|
||||||
|
<div className='flex justify-end space-x-2'>
|
||||||
|
<button onClick={() => setEditingSource(null)} className={buttonStyles.secondary}>取消</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
disabled={!editingSource.name || !editingSource.roomId || isLoading('editWebLive')}
|
||||||
|
className={`${!editingSource.name || !editingSource.roomId || isLoading('editWebLive') ? buttonStyles.disabled : buttonStyles.success}`}
|
||||||
|
>
|
||||||
|
{isLoading('editWebLive') ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-auto'>
|
||||||
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
|
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||||
|
<tr>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>名称</th>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>直播类型</th>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>房间ID</th>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>状态</th>
|
||||||
|
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
|
{webLiveSources.map((source) => (
|
||||||
|
<tr key={source.key} className='hover:bg-gray-50 dark:hover:bg-gray-800'>
|
||||||
|
<td className='px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.name}</td>
|
||||||
|
<td className='px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.platform === 'huya' ? '虎牙' : source.platform}</td>
|
||||||
|
<td className='px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.roomId}</td>
|
||||||
|
<td className='px-6 py-4'>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${!source.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'}`}>
|
||||||
|
{!source.disabled ? '启用中' : '已禁用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='px-6 py-4 text-right text-sm space-x-2'>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(source.key)}
|
||||||
|
disabled={isLoading(`toggleWebLive_${source.key}`)}
|
||||||
|
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled ? buttonStyles.roundedDanger : buttonStyles.roundedSuccess} ${isLoading(`toggleWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{!source.disabled ? '禁用' : '启用'}
|
||||||
|
</button>
|
||||||
|
{source.from !== 'config' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingSource(source)}
|
||||||
|
disabled={isLoading(`editWebLive_${source.key}`)}
|
||||||
|
className={`${buttonStyles.roundedPrimary} ${isLoading(`editWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(source.key)}
|
||||||
|
disabled={isLoading(`deleteWebLive_${source.key}`)}
|
||||||
|
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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() {
|
function AdminPageClient() {
|
||||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
const { isLoading, withLoading } = useLoadingState();
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
@@ -10732,6 +10988,7 @@ function AdminPageClient() {
|
|||||||
xiaoyaConfig: false,
|
xiaoyaConfig: false,
|
||||||
aiConfig: false,
|
aiConfig: false,
|
||||||
liveSource: false,
|
liveSource: false,
|
||||||
|
webLive: false,
|
||||||
siteConfig: false,
|
siteConfig: false,
|
||||||
registrationConfig: false,
|
registrationConfig: false,
|
||||||
categoryConfig: false,
|
categoryConfig: false,
|
||||||
@@ -11057,6 +11314,18 @@ function AdminPageClient() {
|
|||||||
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
|
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
{/* 网络直播配置标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='网络直播配置'
|
||||||
|
icon={
|
||||||
|
<Tv size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.webLive}
|
||||||
|
onToggle={() => toggleTab('webLive')}
|
||||||
|
>
|
||||||
|
<WebLiveConfig config={config} refreshConfig={fetchConfig} />
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
{/* 私人影库大类 */}
|
{/* 私人影库大类 */}
|
||||||
<CollapsibleTab
|
<CollapsibleTab
|
||||||
title='私人影库'
|
title='私人影库'
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
const username = authInfo?.username;
|
||||||
|
const config = await getConfig();
|
||||||
|
if (username !== process.env.USERNAME) {
|
||||||
|
const userInfo = await db.getUserInfoV2(username || '');
|
||||||
|
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { action } = body;
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.WebLiveConfig) {
|
||||||
|
config.WebLiveConfig = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'add': {
|
||||||
|
const { name, platform, roomId } = body;
|
||||||
|
if (!name || !platform || !roomId) {
|
||||||
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `web_${Date.now()}`;
|
||||||
|
if (config.WebLiveConfig.some((s) => s.key === key)) {
|
||||||
|
return NextResponse.json({ error: 'Key已存在' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
config.WebLiveConfig.push({
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
platform,
|
||||||
|
roomId,
|
||||||
|
from: 'custom',
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
const { key } = body;
|
||||||
|
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (source.from === 'config') {
|
||||||
|
return NextResponse.json({ error: '无法删除配置文件中的源' }, { status: 400 });
|
||||||
|
}
|
||||||
|
config.WebLiveConfig = config.WebLiveConfig.filter((s) => s.key !== key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'enable': {
|
||||||
|
const { key } = body;
|
||||||
|
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
source.disabled = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'disable': {
|
||||||
|
const { key } = body;
|
||||||
|
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
source.disabled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'edit': {
|
||||||
|
const { key, name, platform, roomId } = body;
|
||||||
|
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (source.from === 'config') {
|
||||||
|
return NextResponse.json({ error: '无法编辑配置文件中的源' }, { status: 400 });
|
||||||
|
}
|
||||||
|
source.name = name;
|
||||||
|
source.platform = platform;
|
||||||
|
source.roomId = roomId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sort': {
|
||||||
|
const { keys } = body;
|
||||||
|
if (!Array.isArray(keys)) {
|
||||||
|
return NextResponse.json({ error: '无效的排序数据' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const sortedSources = keys
|
||||||
|
.map((key) => config.WebLiveConfig!.find((s) => s.key === key))
|
||||||
|
.filter((s) => s !== undefined);
|
||||||
|
config.WebLiveConfig = sortedSources;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : '操作失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
if (!config?.WebLiveConfig) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = config.WebLiveConfig.filter(s => !s.disabled);
|
||||||
|
return NextResponse.json(sources);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : '获取失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const platform = searchParams.get('platform');
|
||||||
|
const roomId = searchParams.get('roomId');
|
||||||
|
|
||||||
|
if (!platform || !roomId) {
|
||||||
|
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'huya') {
|
||||||
|
const res = await fetch(`https://mp.huya.com/cache.php?m=Live&do=profileRoom&roomid=${roomId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.status === 200 && data.data?.liveStatus === 'ON') {
|
||||||
|
const stream = data.data.stream;
|
||||||
|
const url = `${stream.flv.multiLine[0].url}/${stream.flv.streamName}.${stream.flv.sFlvUrlSuffix}?${stream.flv.sFlvAntiCode}`;
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: '直播未开启' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: '不支持的平台' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : '获取失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
|
||||||
|
let Artplayer: any = null;
|
||||||
|
let Hls: any = null;
|
||||||
|
|
||||||
|
export default function WebLivePage() {
|
||||||
|
const artRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const artPlayerRef = useRef<any>(null);
|
||||||
|
const [sources, setSources] = useState<any[]>([]);
|
||||||
|
const [currentSource, setCurrentSource] = useState<any | null>(null);
|
||||||
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
import('artplayer').then(mod => { Artplayer = mod.default; });
|
||||||
|
import('hls.js').then(mod => { Hls = mod.default; });
|
||||||
|
}
|
||||||
|
fetchSources();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSources = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/web-live/sources');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSources(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取直播源失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function m3u8Loader(video: HTMLVideoElement, url: string) {
|
||||||
|
if (!Hls) return;
|
||||||
|
const hls = new Hls({ debug: false, enableWorker: true, lowLatencyMode: true });
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
(video as any).hls = hls;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Artplayer || !Hls || !videoUrl || !artRef.current) return;
|
||||||
|
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
artPlayerRef.current = new Artplayer({
|
||||||
|
container: artRef.current,
|
||||||
|
url: videoUrl,
|
||||||
|
isLive: true,
|
||||||
|
autoplay: true,
|
||||||
|
customType: { m3u8: m3u8Loader },
|
||||||
|
icons: { loading: '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 100 100"><circle cx="50" cy="50" fill="none" stroke="currentColor" stroke-width="4" r="35" stroke-dasharray="164.93361431346415 56.97787143782138"><animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"/></circle></svg>' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.destroy();
|
||||||
|
artPlayerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoUrl]);
|
||||||
|
|
||||||
|
const handleSourceClick = async (source: any) => {
|
||||||
|
setCurrentSource(source);
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/web-live/stream?platform=${source.platform}&roomId=${source.roomId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setVideoUrl(data.url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取直播流失败:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/web-live'>
|
||||||
|
<div className='flex flex-col gap-4 p-5'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>网络直播</h1>
|
||||||
|
<div className='grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-4'>
|
||||||
|
<div ref={artRef} className='w-full aspect-video bg-black rounded-lg' />
|
||||||
|
<div className='flex flex-col gap-2 max-h-[600px] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4'>
|
||||||
|
<h2 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>直播列表</h2>
|
||||||
|
{sources.map((source) => (
|
||||||
|
<button
|
||||||
|
key={source.key}
|
||||||
|
onClick={() => handleSourceClick(source)}
|
||||||
|
className={`p-3 rounded-lg text-left transition-colors ${
|
||||||
|
currentSource?.key === source.key
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='font-medium'>{source.name}</div>
|
||||||
|
<div className='text-sm opacity-75'>{source.platform === 'huya' ? '虎牙' : source.platform}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,6 +55,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||||||
label: '电视直播',
|
label: '电视直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Radio,
|
||||||
|
label: '网络直播',
|
||||||
|
href: '/web-live',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,6 +93,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||||||
label: '电视直播',
|
label: '电视直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Radio,
|
||||||
|
label: '网络直播',
|
||||||
|
href: '/web-live',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||||||
label: '电视直播',
|
label: '电视直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Radio,
|
||||||
|
label: '网络直播',
|
||||||
|
href: '/web-live',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,6 +184,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||||||
label: '电视直播',
|
label: '电视直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Radio,
|
||||||
|
label: '网络直播',
|
||||||
|
href: '/web-live',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ export interface AdminConfig {
|
|||||||
channelNumber?: number;
|
channelNumber?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}[];
|
}[];
|
||||||
|
WebLiveConfig?: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
platform: string; // 直播平台类型,如 'huya'
|
||||||
|
roomId: string; // 房间ID
|
||||||
|
from: 'config' | 'custom';
|
||||||
|
disabled?: boolean;
|
||||||
|
}[];
|
||||||
ThemeConfig?: {
|
ThemeConfig?: {
|
||||||
enableBuiltInTheme: boolean; // 是否启用内置主题
|
enableBuiltInTheme: boolean; // 是否启用内置主题
|
||||||
builtInTheme: string; // 内置主题名称
|
builtInTheme: string; // 内置主题名称
|
||||||
|
|||||||
Reference in New Issue
Block a user