Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,7 @@ public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map
.env*

# Local Netlify folder
.netlify
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"bs58": "^6.0.0",
"clsx": "^2.0.0",
"crypto-js": "^4.2.0",
"gsap": "^3.15.0",
"he": "^1.2.0",
"hls.js": "^1.6.10",
"lucide-react": "^0.438.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file modified public/icons/icon-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icons/icon-256x256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icons/icon-384x384.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icons/icon-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 105 additions & 2 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2032,6 +2032,8 @@ const VideoSourceConfig = ({
const { isLoading, withLoading } = useLoadingState();
const [sources, setSources] = useState<DataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [showBatchForm, setShowBatchForm] = useState(false);
const [batchText, setBatchText] = useState('');
const [orderChanged, setOrderChanged] = useState(false);
const [newSource, setNewSource] = useState<DataSource>({
name: '',
Expand Down Expand Up @@ -2164,6 +2166,67 @@ const VideoSourceConfig = ({
});
};

const handleBatchImport = () => {
if (!batchText.trim()) return;
withLoading('batchAddSources', async () => {
let parsed: unknown;
try {
parsed = JSON.parse(batchText);
} catch {
throw new Error('JSON 格式不正确');
}

let sources: { key: string; name: string; api: string; detail?: string }[] = [];
if (Array.isArray(parsed)) {
sources = parsed as { key: string; name: string; api: string; detail?: string }[];
} else if (parsed && typeof parsed === 'object') {
const map =
'api_site' in parsed &&
parsed.api_site &&
typeof parsed.api_site === 'object'
? (parsed as { api_site: Record<string, { name?: string; api?: string; detail?: string }> }).api_site
: (parsed as Record<string, { name?: string; api?: string; detail?: string }>);
sources = Object.entries(map).map(([key, site]) => {
const item =
site && typeof site === 'object'
? site
: { name: key, api: '' };
return {
key,
name: item.name || key,
api: item.api || '',
detail: item.detail,
};
});
}

const resp = await fetch('/api/admin/source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'batch_add', sources }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.error || `导入失败: ${resp.status}`);
}
await refreshConfig();
setBatchText('');
setShowBatchForm(false);
showAlert({
type: 'success',
title: '批量导入完成',
message: `新增 ${data.added || 0} 个,跳过 ${data.skipped || 0} 个`,
timer: 2500,
});
}).catch((err) => {
showAlert({
type: 'error',
title: '批量导入失败',
message: err instanceof Error ? err.message : '导入失败',
});
});
};

const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
Expand Down Expand Up @@ -2561,7 +2624,19 @@ const VideoSourceConfig = ({
)}
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
onClick={() => {
setShowBatchForm(!showBatchForm);
if (!showBatchForm) setShowAddForm(false);
}}
className={showBatchForm ? buttonStyles.secondary : buttonStyles.primary}
>
{showBatchForm ? '取消' : '批量导入'}
</button>
<button
onClick={() => {
setShowAddForm(!showAddForm);
if (!showAddForm) setShowBatchForm(false);
}}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
>
{showAddForm ? '取消' : '添加视频源'}
Expand Down Expand Up @@ -2622,7 +2697,35 @@ const VideoSourceConfig = ({
</div>
)}


{showBatchForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<p className='text-sm text-gray-600 dark:text-gray-400'>
粘贴你自己的源列表。支持数组、配置文件里的 api_site 对象,或 key-对象 映射。
</p>
<textarea
value={batchText}
onChange={(e) => setBatchText(e.target.value)}
rows={10}
placeholder={`{
"mysource": {
"name": "自有源",
"api": "https://your-source.example.com/api.php/provide/vod",
"detail": ""
}
}`}
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 font-mono text-sm'
/>
<div className='flex justify-end'>
<button
onClick={handleBatchImport}
disabled={!batchText.trim() || isLoading('batchAddSources')}
className={`w-full sm:w-auto px-4 py-2 ${!batchText.trim() || isLoading('batchAddSources') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('batchAddSources') ? '导入中...' : '导入'}
</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="source-list">
Expand Down
54 changes: 51 additions & 3 deletions src/app/api/admin/source/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import { NextRequest, NextResponse } from 'next/server';

import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getConfig, setCachedConfig } from '@/lib/config';
import { db } from '@/lib/db';

export const runtime = 'nodejs';

// 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_add' | 'batch_disable' | 'batch_enable' | 'batch_delete';

interface BaseBody {
action?: Action;
Expand Down Expand Up @@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
const username = authInfo.username;

// 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_add', 'batch_disable', 'batch_enable', 'batch_delete'];
if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
Expand Down Expand Up @@ -79,6 +79,53 @@ export async function POST(request: NextRequest) {
});
break;
}
case 'batch_add': {
const { sources } = body as {
sources?: {
key?: string;
name?: string;
api?: string;
detail?: string;
}[];
};
if (!Array.isArray(sources) || sources.length === 0) {
return NextResponse.json(
{ error: '缺少 sources 参数或为空' },
{ status: 400 }
);
}

const existing = new Set(adminConfig.SourceConfig.map((s) => s.key));
let added = 0;
let skipped = 0;

sources.forEach((item) => {
const key = item.key?.trim();
const name = item.name?.trim();
const api = item.api?.trim();
if (!key || !name || !api || existing.has(key)) {
skipped += 1;
return;
}
existing.add(key);
adminConfig.SourceConfig.push({
key,
name,
api,
detail: item.detail?.trim() || undefined,
from: 'custom',
disabled: false,
});
added += 1;
});

await db.saveAdminConfig(adminConfig);
await setCachedConfig(adminConfig);
return NextResponse.json(
{ ok: true, added, skipped },
{ headers: { 'Cache-Control': 'no-store' } }
);
}
case 'disable': {
const { key } = body as { key?: string };
if (!key)
Expand Down Expand Up @@ -225,6 +272,7 @@ export async function POST(request: NextRequest) {

// 持久化到存储
await db.saveAdminConfig(adminConfig);
await setCachedConfig(adminConfig);

return NextResponse.json(
{ ok: true },
Expand Down
15 changes: 5 additions & 10 deletions src/app/api/admin/source/validate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { NextRequest, NextResponse } from 'next/server';

import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { API_CONFIG } from '@/lib/config';
import { parseCmsResponse } from '@/lib/cms';
import { API_CONFIG, getConfig } from '@/lib/config';

export const runtime = 'nodejs';

Expand Down Expand Up @@ -90,18 +90,13 @@ export async function GET(request: NextRequest) {
throw new Error(`HTTP ${response.status}`);
}

const data = await response.json() as any;
const data = parseCmsResponse(await response.text());

// 检查结果是否有效
let status: 'valid' | 'no_results' | 'invalid';
if (
data &&
data.list &&
Array.isArray(data.list) &&
data.list.length > 0
) {
if (data.list.length > 0) {
// 检查是否有标题包含搜索词的结果
const validResults = data.list.filter((item: any) => {
const validResults = data.list.filter((item) => {
const title = item.vod_name || '';
return title.toLowerCase().includes(searchKeyword.toLowerCase());
});
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ export async function POST(req: NextRequest) {
// 验证成功,设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(
undefined,
process.env.USERNAME || 'local',
password,
'user',
'owner',
true
); // localstorage 模式包含 password
const expires = new Date();
Expand Down
Loading