mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-12 07:04:43 +08:00
first commit
This commit is contained in:
2110
src/app/admin/page.tsx
Normal file
2110
src/app/admin/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
209
src/app/api/admin/category/route.ts
Normal file
209
src/app/api/admin/category/route.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { IStorage } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 支持的操作类型
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
|
||||
|
||||
interface BaseBody {
|
||||
action?: Action;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (storageType === 'upstash') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Upstash 实例请通过配置文件调整',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as BaseBody & Record<string, any>;
|
||||
const { action } = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
// 基础校验
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
|
||||
if (!username || !action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取配置与存储
|
||||
const adminConfig = await getConfig();
|
||||
const storage: IStorage | null = getStorage();
|
||||
|
||||
// 权限与身份校验
|
||||
if (username !== process.env.USERNAME) {
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add': {
|
||||
const { name, type, query } = body as {
|
||||
name?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
query?: string;
|
||||
};
|
||||
if (!name || !type || !query) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
// 检查是否已存在相同的查询和类型组合
|
||||
if (
|
||||
adminConfig.CustomCategories.some(
|
||||
(c) => c.query === query && c.type === type
|
||||
)
|
||||
) {
|
||||
return NextResponse.json({ error: '该分类已存在' }, { status: 400 });
|
||||
}
|
||||
adminConfig.CustomCategories.push({
|
||||
name,
|
||||
type,
|
||||
query,
|
||||
from: 'custom',
|
||||
disabled: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'disable': {
|
||||
const { query, type } = body as {
|
||||
query?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
};
|
||||
if (!query || !type)
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 query 或 type 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
const entry = adminConfig.CustomCategories.find(
|
||||
(c) => c.query === query && c.type === type
|
||||
);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||
entry.disabled = true;
|
||||
break;
|
||||
}
|
||||
case 'enable': {
|
||||
const { query, type } = body as {
|
||||
query?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
};
|
||||
if (!query || !type)
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 query 或 type 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
const entry = adminConfig.CustomCategories.find(
|
||||
(c) => c.query === query && c.type === type
|
||||
);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||
entry.disabled = false;
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const { query, type } = body as {
|
||||
query?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
};
|
||||
if (!query || !type)
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 query 或 type 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
const idx = adminConfig.CustomCategories.findIndex(
|
||||
(c) => c.query === query && c.type === type
|
||||
);
|
||||
if (idx === -1)
|
||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||
const entry = adminConfig.CustomCategories[idx];
|
||||
if (entry.from === 'config') {
|
||||
return NextResponse.json(
|
||||
{ error: '该分类不可删除' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
adminConfig.CustomCategories.splice(idx, 1);
|
||||
break;
|
||||
}
|
||||
case 'sort': {
|
||||
const { order } = body as { order?: string[] };
|
||||
if (!Array.isArray(order)) {
|
||||
return NextResponse.json(
|
||||
{ error: '排序列表格式错误' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const map = new Map(
|
||||
adminConfig.CustomCategories.map((c) => [`${c.query}:${c.type}`, c])
|
||||
);
|
||||
const newList: typeof adminConfig.CustomCategories = [];
|
||||
order.forEach((key) => {
|
||||
const item = map.get(key);
|
||||
if (item) {
|
||||
newList.push(item);
|
||||
map.delete(key);
|
||||
}
|
||||
});
|
||||
// 未在 order 中的保持原顺序
|
||||
adminConfig.CustomCategories.forEach((item) => {
|
||||
if (map.has(`${item.query}:${item.type}`)) newList.push(item);
|
||||
});
|
||||
adminConfig.CustomCategories = newList;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 持久化到存储
|
||||
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||
await (storage as any).setAdminConfig(adminConfig);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('分类管理操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '分类管理操作失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
63
src/app/api/admin/config/route.ts
Normal file
63
src/app/api/admin/config/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { AdminConfigResult } from '@/lib/admin.types';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const result: AdminConfigResult = {
|
||||
Role: 'owner',
|
||||
Config: config,
|
||||
};
|
||||
if (username === process.env.USERNAME) {
|
||||
result.Role = 'owner';
|
||||
} else {
|
||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (user && user.role === 'admin' && !user.banned) {
|
||||
result.Role = 'admin';
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: '你是管理员吗你就访问?' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // 管理员配置不缓存
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取管理员配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取管理员配置失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
51
src/app/api/admin/reset/route.ts
Normal file
51
src/app/api/admin/reset/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { resetConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
if (username !== process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '仅支持站长重置配置' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await resetConfig();
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // 管理员配置不缓存
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '重置管理员配置失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
118
src/app/api/admin/site/route.ts
Normal file
118
src/app/api/admin/site/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getStorage } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
const {
|
||||
SiteName,
|
||||
Announcement,
|
||||
SearchDownstreamMaxPage,
|
||||
SiteInterfaceCacheTime,
|
||||
DoubanProxyType,
|
||||
DoubanProxy,
|
||||
DoubanImageProxyType,
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
SearchDownstreamMaxPage: number;
|
||||
SiteInterfaceCacheTime: number;
|
||||
DoubanProxyType: string;
|
||||
DoubanProxy: string;
|
||||
DoubanImageProxyType: string;
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
if (
|
||||
typeof SiteName !== 'string' ||
|
||||
typeof Announcement !== 'string' ||
|
||||
typeof SearchDownstreamMaxPage !== 'number' ||
|
||||
typeof SiteInterfaceCacheTime !== 'number' ||
|
||||
typeof DoubanProxyType !== 'string' ||
|
||||
typeof DoubanProxy !== 'string' ||
|
||||
typeof DoubanImageProxyType !== 'string' ||
|
||||
typeof DoubanImageProxy !== 'string' ||
|
||||
typeof DisableYellowFilter !== 'boolean'
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
const storage = getStorage();
|
||||
|
||||
// 权限校验
|
||||
if (username !== process.env.USERNAME) {
|
||||
// 管理员
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!user || user.role !== 'admin' || user.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存中的站点设置
|
||||
adminConfig.SiteConfig = {
|
||||
SiteName,
|
||||
Announcement,
|
||||
SearchDownstreamMaxPage,
|
||||
SiteInterfaceCacheTime,
|
||||
DoubanProxyType,
|
||||
DoubanProxy,
|
||||
DoubanImageProxyType,
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||
await (storage as any).setAdminConfig(adminConfig);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // 不缓存结果
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('更新站点配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '更新站点配置失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
169
src/app/api/admin/source/route.ts
Normal file
169
src/app/api/admin/source/route.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { IStorage } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 支持的操作类型
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
|
||||
|
||||
interface BaseBody {
|
||||
action?: Action;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as BaseBody & Record<string, any>;
|
||||
const { action } = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
// 基础校验
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
|
||||
if (!username || !action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取配置与存储
|
||||
const adminConfig = await getConfig();
|
||||
const storage: IStorage | null = getStorage();
|
||||
|
||||
// 权限与身份校验
|
||||
if (username !== process.env.USERNAME) {
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add': {
|
||||
const { key, name, api, detail } = body as {
|
||||
key?: string;
|
||||
name?: string;
|
||||
api?: string;
|
||||
detail?: string;
|
||||
};
|
||||
if (!key || !name || !api) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
if (adminConfig.SourceConfig.some((s) => s.key === key)) {
|
||||
return NextResponse.json({ error: '该源已存在' }, { status: 400 });
|
||||
}
|
||||
adminConfig.SourceConfig.push({
|
||||
key,
|
||||
name,
|
||||
api,
|
||||
detail,
|
||||
from: 'custom',
|
||||
disabled: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'disable': {
|
||||
const { key } = body as { key?: string };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
entry.disabled = true;
|
||||
break;
|
||||
}
|
||||
case 'enable': {
|
||||
const { key } = body as { key?: string };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
entry.disabled = false;
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const { key } = body as { key?: string };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
|
||||
if (idx === -1)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
const entry = adminConfig.SourceConfig[idx];
|
||||
if (entry.from === 'config') {
|
||||
return NextResponse.json({ error: '该源不可删除' }, { status: 400 });
|
||||
}
|
||||
adminConfig.SourceConfig.splice(idx, 1);
|
||||
break;
|
||||
}
|
||||
case 'sort': {
|
||||
const { order } = body as { order?: string[] };
|
||||
if (!Array.isArray(order)) {
|
||||
return NextResponse.json(
|
||||
{ error: '排序列表格式错误' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const map = new Map(adminConfig.SourceConfig.map((s) => [s.key, s]));
|
||||
const newList: typeof adminConfig.SourceConfig = [];
|
||||
order.forEach((k) => {
|
||||
const item = map.get(k);
|
||||
if (item) {
|
||||
newList.push(item);
|
||||
map.delete(k);
|
||||
}
|
||||
});
|
||||
// 未在 order 中的保持原顺序
|
||||
adminConfig.SourceConfig.forEach((item) => {
|
||||
if (map.has(item.key)) newList.push(item);
|
||||
});
|
||||
adminConfig.SourceConfig = newList;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 持久化到存储
|
||||
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||
await (storage as any).setAdminConfig(adminConfig);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('视频源管理操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '视频源管理操作失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
337
src/app/api/admin/user/route.ts
Normal file
337
src/app/api/admin/user/route.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { IStorage } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 支持的操作类型
|
||||
const ACTIONS = [
|
||||
'add',
|
||||
'ban',
|
||||
'unban',
|
||||
'setAdmin',
|
||||
'cancelAdmin',
|
||||
'setAllowRegister',
|
||||
'changePassword',
|
||||
'deleteUser',
|
||||
] as const;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
const {
|
||||
targetUsername, // 目标用户名
|
||||
targetPassword, // 目标用户密码(仅在添加用户时需要)
|
||||
allowRegister,
|
||||
action,
|
||||
} = body as {
|
||||
targetUsername?: string;
|
||||
targetPassword?: string;
|
||||
allowRegister?: boolean;
|
||||
action?: (typeof ACTIONS)[number];
|
||||
};
|
||||
|
||||
if (!action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action !== 'setAllowRegister' && !targetUsername) {
|
||||
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
action !== 'setAllowRegister' &&
|
||||
action !== 'changePassword' &&
|
||||
action !== 'deleteUser' &&
|
||||
username === targetUsername
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '无法对自己进行此操作' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取配置与存储
|
||||
const adminConfig = await getConfig();
|
||||
const storage: IStorage | null = getStorage();
|
||||
|
||||
// 判定操作者角色
|
||||
let operatorRole: 'owner' | 'admin';
|
||||
if (username === process.env.USERNAME) {
|
||||
operatorRole = 'owner';
|
||||
} else {
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
operatorRole = 'admin';
|
||||
}
|
||||
|
||||
// 查找目标用户条目
|
||||
let targetEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === targetUsername
|
||||
);
|
||||
|
||||
if (
|
||||
targetEntry &&
|
||||
targetEntry.role === 'owner' &&
|
||||
action !== 'changePassword'
|
||||
) {
|
||||
return NextResponse.json({ error: '无法操作站长' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 权限校验逻辑
|
||||
const isTargetAdmin = targetEntry?.role === 'admin';
|
||||
|
||||
if (action === 'setAllowRegister') {
|
||||
if (typeof allowRegister !== 'boolean') {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
adminConfig.UserConfig.AllowRegister = allowRegister;
|
||||
// 保存后直接返回成功(走后面的统一保存逻辑)
|
||||
} else {
|
||||
switch (action) {
|
||||
case 'add': {
|
||||
if (targetEntry) {
|
||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||
}
|
||||
if (!targetPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少目标用户密码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!storage || typeof storage.registerUser !== 'function') {
|
||||
return NextResponse.json(
|
||||
{ error: '存储未配置用户注册' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
await storage.registerUser(targetUsername!, targetPassword);
|
||||
// 更新配置
|
||||
adminConfig.UserConfig.Users.push({
|
||||
username: targetUsername!,
|
||||
role: 'user',
|
||||
});
|
||||
targetEntry =
|
||||
adminConfig.UserConfig.Users[
|
||||
adminConfig.UserConfig.Users.length - 1
|
||||
];
|
||||
break;
|
||||
}
|
||||
case 'ban': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (isTargetAdmin) {
|
||||
// 目标是管理员
|
||||
if (operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可封禁管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
targetEntry.banned = true;
|
||||
break;
|
||||
}
|
||||
case 'unban': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (isTargetAdmin) {
|
||||
if (operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可操作管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
targetEntry.banned = false;
|
||||
break;
|
||||
}
|
||||
case 'setAdmin': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (targetEntry.role === 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: '该用户已是管理员' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可设置管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
targetEntry.role = 'admin';
|
||||
break;
|
||||
}
|
||||
case 'cancelAdmin': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (targetEntry.role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不是管理员' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可取消管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
targetEntry.role = 'user';
|
||||
break;
|
||||
}
|
||||
case 'changePassword': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!targetPassword) {
|
||||
return NextResponse.json({ error: '缺少新密码' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 权限检查:不允许修改站长密码
|
||||
if (targetEntry.role === 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '无法修改站长密码' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isTargetAdmin &&
|
||||
operatorRole !== 'owner' &&
|
||||
username !== targetUsername
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可修改其他管理员密码' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!storage || typeof storage.changePassword !== 'function') {
|
||||
return NextResponse.json(
|
||||
{ error: '存储未配置密码修改功能' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
await storage.changePassword(targetUsername!, targetPassword);
|
||||
break;
|
||||
}
|
||||
case 'deleteUser': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
|
||||
if (username === targetUsername) {
|
||||
return NextResponse.json(
|
||||
{ error: '不能删除自己' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (isTargetAdmin && operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可删除管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!storage || typeof storage.deleteUser !== 'function') {
|
||||
return NextResponse.json(
|
||||
{ error: '存储未配置用户删除功能' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
await storage.deleteUser(targetUsername!);
|
||||
|
||||
// 从配置中移除用户
|
||||
const userIndex = adminConfig.UserConfig.Users.findIndex(
|
||||
(u) => u.username === targetUsername
|
||||
);
|
||||
if (userIndex > -1) {
|
||||
adminConfig.UserConfig.Users.splice(userIndex, 1);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 将更新后的配置写入数据库
|
||||
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||
await (storage as any).setAdminConfig(adminConfig);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // 管理员配置不缓存
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('用户管理操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '用户管理操作失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
72
src/app/api/change-password/route.ts
Normal file
72
src/app/api/change-password/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable no-console*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { IStorage } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
// 不支持 localstorage 模式
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储模式修改密码',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { newPassword } = body;
|
||||
|
||||
// 获取认证信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 验证新密码
|
||||
if (!newPassword || typeof newPassword !== 'string') {
|
||||
return NextResponse.json({ error: '新密码不得为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const username = authInfo.username;
|
||||
|
||||
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME)
|
||||
if (username === process.env.USERNAME) {
|
||||
return NextResponse.json(
|
||||
{ error: '站长不能通过此接口修改密码' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取存储实例
|
||||
const storage: IStorage | null = getStorage();
|
||||
if (!storage || typeof storage.changePassword !== 'function') {
|
||||
return NextResponse.json(
|
||||
{ error: '存储服务不支持修改密码' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
await storage.changePassword(username, newPassword);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '修改密码失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
189
src/app/api/cron/route.ts
Normal file
189
src/app/api/cron/route.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log(request.url);
|
||||
try {
|
||||
console.log('Cron job triggered:', new Date().toISOString());
|
||||
|
||||
refreshRecordAndFavorites();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Cron job executed successfully',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cron job failed:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Cron job failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecordAndFavorites() {
|
||||
if (
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage') === 'localstorage'
|
||||
) {
|
||||
console.log('跳过刷新:当前使用 localstorage 存储模式');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await db.getAllUsers();
|
||||
if (process.env.USERNAME && !users.includes(process.env.USERNAME)) {
|
||||
users.push(process.env.USERNAME);
|
||||
}
|
||||
// 函数级缓存:key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
|
||||
const detailCache = new Map<string, Promise<SearchResult | null>>();
|
||||
|
||||
// 获取详情 Promise(带缓存和错误处理)
|
||||
const getDetail = async (
|
||||
source: string,
|
||||
id: string,
|
||||
fallbackTitle: string
|
||||
): Promise<SearchResult | null> => {
|
||||
const key = `${source}+${id}`;
|
||||
let promise = detailCache.get(key);
|
||||
if (!promise) {
|
||||
promise = fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle: fallbackTitle.trim(),
|
||||
})
|
||||
.then((detail) => {
|
||||
// 成功时才缓存结果
|
||||
const successPromise = Promise.resolve(detail);
|
||||
detailCache.set(key, successPromise);
|
||||
return detail;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`获取视频详情失败 (${source}+${id}):`, err);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
for (const user of users) {
|
||||
console.log(`开始处理用户: ${user}`);
|
||||
|
||||
// 播放记录
|
||||
try {
|
||||
const playRecords = await db.getAllPlayRecords(user);
|
||||
const totalRecords = Object.keys(playRecords).length;
|
||||
let processedRecords = 0;
|
||||
|
||||
for (const [key, record] of Object.entries(playRecords)) {
|
||||
try {
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
console.warn(`跳过无效的播放记录键: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const detail = await getDetail(source, id, record.title);
|
||||
if (!detail) {
|
||||
console.warn(`跳过无法获取详情的播放记录: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const episodeCount = detail.episodes?.length || 0;
|
||||
if (episodeCount > 0 && episodeCount !== record.total_episodes) {
|
||||
await db.savePlayRecord(user, source, id, {
|
||||
title: detail.title || record.title,
|
||||
source_name: record.source_name,
|
||||
cover: detail.poster || record.cover,
|
||||
index: record.index,
|
||||
total_episodes: episodeCount,
|
||||
play_time: record.play_time,
|
||||
year: detail.year || record.year,
|
||||
total_time: record.total_time,
|
||||
save_time: record.save_time,
|
||||
search_title: record.search_title,
|
||||
});
|
||||
console.log(
|
||||
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount})`
|
||||
);
|
||||
}
|
||||
|
||||
processedRecords++;
|
||||
} catch (err) {
|
||||
console.error(`处理播放记录失败 (${key}):`, err);
|
||||
// 继续处理下一个记录
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`播放记录处理完成: ${processedRecords}/${totalRecords}`);
|
||||
} catch (err) {
|
||||
console.error(`获取用户播放记录失败 (${user}):`, err);
|
||||
}
|
||||
|
||||
// 收藏
|
||||
try {
|
||||
const favorites = await db.getAllFavorites(user);
|
||||
const totalFavorites = Object.keys(favorites).length;
|
||||
let processedFavorites = 0;
|
||||
|
||||
for (const [key, fav] of Object.entries(favorites)) {
|
||||
try {
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
console.warn(`跳过无效的收藏键: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const favDetail = await getDetail(source, id, fav.title);
|
||||
if (!favDetail) {
|
||||
console.warn(`跳过无法获取详情的收藏: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const favEpisodeCount = favDetail.episodes?.length || 0;
|
||||
if (favEpisodeCount > 0 && favEpisodeCount !== fav.total_episodes) {
|
||||
await db.saveFavorite(user, source, id, {
|
||||
title: favDetail.title || fav.title,
|
||||
source_name: fav.source_name,
|
||||
cover: favDetail.poster || fav.cover,
|
||||
year: favDetail.year || fav.year,
|
||||
total_episodes: favEpisodeCount,
|
||||
save_time: fav.save_time,
|
||||
search_title: fav.search_title,
|
||||
});
|
||||
console.log(
|
||||
`更新收藏: ${fav.title} (${fav.total_episodes} -> ${favEpisodeCount})`
|
||||
);
|
||||
}
|
||||
|
||||
processedFavorites++;
|
||||
} catch (err) {
|
||||
console.error(`处理收藏失败 (${key}):`, err);
|
||||
// 继续处理下一个收藏
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
|
||||
} catch (err) {
|
||||
console.error(`获取用户收藏失败 (${user}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('刷新播放记录/收藏任务完成');
|
||||
} catch (err) {
|
||||
console.error('刷新播放记录/收藏任务启动失败', err);
|
||||
}
|
||||
}
|
||||
46
src/app/api/detail/route.ts
Normal file
46
src/app/api/detail/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||
import { getDetailFromApi } from '@/lib/downstream';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const sourceCode = searchParams.get('source');
|
||||
|
||||
if (!id || !sourceCode) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites();
|
||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||
|
||||
if (!apiSite) {
|
||||
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await getDetailFromApi(apiSite, id);
|
||||
const cacheTime = await getCacheTime();
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
100
src/app/api/douban/categories/route.ts
Normal file
100
src/app/api/douban/categories/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime } from '@/lib/config';
|
||||
import { fetchDoubanData } from '@/lib/douban';
|
||||
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||
|
||||
interface DoubanCategoryApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
card_subtitle: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 获取参数
|
||||
const kind = searchParams.get('kind') || 'movie';
|
||||
const category = searchParams.get('category');
|
||||
const type = searchParams.get('type');
|
||||
const pageLimit = parseInt(searchParams.get('limit') || '20');
|
||||
const pageStart = parseInt(searchParams.get('start') || '0');
|
||||
|
||||
// 验证参数
|
||||
if (!kind || !category || !type) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必要参数: kind 或 category 或 type' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['tv', 'movie'].includes(kind)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'kind 参数必须是 tv 或 movie' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageLimit < 1 || pageLimit > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pageSize 必须在 1-100 之间' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pageStart 不能小于 0' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
|
||||
|
||||
try {
|
||||
// 调用豆瓣 API
|
||||
const doubanData = await fetchDoubanData<DoubanCategoryApiResponse>(target);
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.pic?.normal || item.pic?.large || '',
|
||||
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
|
||||
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
|
||||
}));
|
||||
|
||||
const response: DoubanResult = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
130
src/app/api/douban/recommends/route.ts
Normal file
130
src/app/api/douban/recommends/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime } from '@/lib/config';
|
||||
import { fetchDoubanData } from '@/lib/douban';
|
||||
import { DoubanResult } from '@/lib/types';
|
||||
|
||||
interface DoubanRecommendApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
year: string;
|
||||
type: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 获取参数
|
||||
const kind = searchParams.get('kind');
|
||||
const pageLimit = parseInt(searchParams.get('limit') || '20');
|
||||
const pageStart = parseInt(searchParams.get('start') || '0');
|
||||
const category =
|
||||
searchParams.get('category') === 'all' ? '' : searchParams.get('category');
|
||||
const format =
|
||||
searchParams.get('format') === 'all' ? '' : searchParams.get('format');
|
||||
const region =
|
||||
searchParams.get('region') === 'all' ? '' : searchParams.get('region');
|
||||
const year =
|
||||
searchParams.get('year') === 'all' ? '' : searchParams.get('year');
|
||||
const platform =
|
||||
searchParams.get('platform') === 'all' ? '' : searchParams.get('platform');
|
||||
const sort = searchParams.get('sort') === 'T' ? '' : searchParams.get('sort');
|
||||
const label =
|
||||
searchParams.get('label') === 'all' ? '' : searchParams.get('label');
|
||||
|
||||
if (!kind) {
|
||||
return NextResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
|
||||
}
|
||||
|
||||
const selectedCategories = { 类型: category } as any;
|
||||
if (format) {
|
||||
selectedCategories['形式'] = format;
|
||||
}
|
||||
if (region) {
|
||||
selectedCategories['地区'] = region;
|
||||
}
|
||||
|
||||
const tags = [] as Array<string>;
|
||||
if (category) {
|
||||
tags.push(category);
|
||||
}
|
||||
if (!category && format) {
|
||||
tags.push(format);
|
||||
}
|
||||
if (label) {
|
||||
tags.push(label);
|
||||
}
|
||||
if (region) {
|
||||
tags.push(region);
|
||||
}
|
||||
if (year) {
|
||||
tags.push(year);
|
||||
}
|
||||
if (platform) {
|
||||
tags.push(platform);
|
||||
}
|
||||
|
||||
const baseUrl = `https://m.douban.com/rexxar/api/v2/${kind}/recommend`;
|
||||
const params = new URLSearchParams();
|
||||
params.append('refresh', '0');
|
||||
params.append('start', pageStart.toString());
|
||||
params.append('count', pageLimit.toString());
|
||||
params.append('selected_categories', JSON.stringify(selectedCategories));
|
||||
params.append('uncollect', 'false');
|
||||
params.append('score_range', '0,10');
|
||||
params.append('tags', tags.join(','));
|
||||
if (sort) {
|
||||
params.append('sort', sort);
|
||||
}
|
||||
|
||||
const target = `${baseUrl}?${params.toString()}`;
|
||||
console.log(target);
|
||||
try {
|
||||
const doubanData = await fetchDoubanData<DoubanRecommendApiResponse>(
|
||||
target
|
||||
);
|
||||
const list = doubanData.items
|
||||
.filter((item) => item.type == 'movie' || item.type == 'tv')
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.pic?.normal || item.pic?.large || '',
|
||||
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
|
||||
year: item.year,
|
||||
}));
|
||||
const response: DoubanResult = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
177
src/app/api/douban/route.ts
Normal file
177
src/app/api/douban/route.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime } from '@/lib/config';
|
||||
import { fetchDoubanData } from '@/lib/douban';
|
||||
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||
|
||||
interface DoubanApiResponse {
|
||||
subjects: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 获取参数
|
||||
const type = searchParams.get('type');
|
||||
const tag = searchParams.get('tag');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '16');
|
||||
const pageStart = parseInt(searchParams.get('pageStart') || '0');
|
||||
|
||||
// 验证参数
|
||||
if (!type || !tag) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必要参数: type 或 tag' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['tv', 'movie'].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'type 参数必须是 tv 或 movie' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageSize < 1 || pageSize > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pageSize 必须在 1-100 之间' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pageStart 不能小于 0' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === 'top250') {
|
||||
return handleTop250(pageStart);
|
||||
}
|
||||
|
||||
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
|
||||
|
||||
try {
|
||||
// 调用豆瓣 API
|
||||
const doubanData = await fetchDoubanData<DoubanApiResponse>(target);
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.cover,
|
||||
rate: item.rate,
|
||||
year: '',
|
||||
}));
|
||||
|
||||
const response: DoubanResult = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTop250(pageStart: number) {
|
||||
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
|
||||
|
||||
// 直接使用 fetch 获取 HTML 页面
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const fetchOptions = {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
},
|
||||
};
|
||||
|
||||
return fetch(target, fetchOptions)
|
||||
.then(async (fetchResponse) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!fetchResponse.ok) {
|
||||
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
|
||||
}
|
||||
|
||||
// 获取 HTML 内容
|
||||
const html = await fetchResponse.text();
|
||||
|
||||
// 通过正则同时捕获影片 id、标题、封面以及评分
|
||||
const moviePattern =
|
||||
/<div class="item">[\s\S]*?<a[^>]+href="https?:\/\/movie\.douban\.com\/subject\/(\d+)\/"[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]*)<\/span>[\s\S]*?<\/div>/g;
|
||||
const movies: DoubanItem[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = moviePattern.exec(html)) !== null) {
|
||||
const id = match[1];
|
||||
const title = match[2];
|
||||
const cover = match[3];
|
||||
const rate = match[4] || '';
|
||||
|
||||
// 处理图片 URL,确保使用 HTTPS
|
||||
const processedCover = cover.replace(/^http:/, 'https:');
|
||||
|
||||
movies.push({
|
||||
id: id,
|
||||
title: title,
|
||||
poster: processedCover,
|
||||
rate: rate,
|
||||
year: '',
|
||||
});
|
||||
}
|
||||
|
||||
const apiResponse: DoubanResult = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: movies,
|
||||
};
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(apiResponse, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取豆瓣 Top250 数据失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
}
|
||||
190
src/app/api/favorites/route.ts
Normal file
190
src/app/api/favorites/route.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { Favorite } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* GET /api/favorites
|
||||
*
|
||||
* 支持两种调用方式:
|
||||
* 1. 不带 query,返回全部收藏列表(Record<string, Favorite>)。
|
||||
* 2. 带 key=source+id,返回单条收藏(Favorite | null)。
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
// 查询单条收藏
|
||||
if (key) {
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const fav = await db.getFavorite(authInfo.username, source, id);
|
||||
return NextResponse.json(fav, { status: 200 });
|
||||
}
|
||||
|
||||
// 查询全部收藏
|
||||
const favorites = await db.getAllFavorites(authInfo.username);
|
||||
return NextResponse.json(favorites, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/favorites
|
||||
* body: { key: string; favorite: Favorite }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, favorite }: { key: string; favorite: Favorite } = body;
|
||||
|
||||
if (!key || !favorite) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing key or favorite' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证必要字段
|
||||
if (!favorite.title || !favorite.source_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid favorite data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const finalFavorite = {
|
||||
...favorite,
|
||||
save_time: favorite.save_time ?? Date.now(),
|
||||
} as Favorite;
|
||||
|
||||
await db.saveFavorite(authInfo.username, source, id, finalFavorite);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('保存收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/favorites
|
||||
*
|
||||
* 1. 不带 query -> 清空全部收藏
|
||||
* 2. 带 key=source+id -> 删除单条收藏
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const username = authInfo.username;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (key) {
|
||||
// 删除单条
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await db.deleteFavorite(username, source, id);
|
||||
} else {
|
||||
// 清空全部
|
||||
const all = await db.getAllFavorites(username);
|
||||
await Promise.all(
|
||||
Object.keys(all).map(async (k) => {
|
||||
const [s, i] = k.split('+');
|
||||
if (s && i) await db.deleteFavorite(username, s, i);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
62
src/app/api/image-proxy/route.ts
Normal file
62
src/app/api/image-proxy/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// OrionTV 兼容接口
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const imageUrl = searchParams.get('url');
|
||||
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
headers: {
|
||||
Referer: 'https://movie.douban.com/',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: imageResponse.statusText },
|
||||
{ status: imageResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = imageResponse.headers.get('content-type');
|
||||
|
||||
if (!imageResponse.body) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image response has no body' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建响应头
|
||||
const headers = new Headers();
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
}
|
||||
|
||||
// 设置缓存头(可选)
|
||||
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000'); // 缓存半年
|
||||
headers.set('CDN-Cache-Control', 'public, s-maxage=15720000');
|
||||
headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=15720000');
|
||||
headers.set('Netlify-Vary', 'query');
|
||||
|
||||
// 直接返回图片流
|
||||
return new Response(imageResponse.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching image' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
208
src/app/api/login/route.ts
Normal file
208
src/app/api/login/route.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 读取存储类型环境变量,默认 localstorage
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 生成签名
|
||||
async function generateSignature(
|
||||
data: string,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const messageData = encoder.encode(data);
|
||||
|
||||
// 导入密钥
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
// 生成签名
|
||||
const signature = await crypto.subtle.sign('HMAC', key, messageData);
|
||||
|
||||
// 转换为十六进制字符串
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 生成认证Cookie(带签名)
|
||||
async function generateAuthCookie(
|
||||
username?: string,
|
||||
password?: string,
|
||||
role?: 'owner' | 'admin' | 'user',
|
||||
includePassword = false
|
||||
): Promise<string> {
|
||||
const authData: any = { role: role || 'user' };
|
||||
|
||||
// 只在需要时包含 password
|
||||
if (includePassword && password) {
|
||||
authData.password = password;
|
||||
}
|
||||
|
||||
if (username && process.env.PASSWORD) {
|
||||
authData.username = username;
|
||||
// 使用密码作为密钥对用户名进行签名
|
||||
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 本地 / localStorage 模式——仅校验固定密码
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
const envPassword = process.env.PASSWORD;
|
||||
|
||||
// 未配置 PASSWORD 时直接放行
|
||||
if (!envPassword) {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
// 清除可能存在的认证cookie
|
||||
response.cookies.set('auth', '', {
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const { password } = await req.json();
|
||||
if (typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (password !== envPassword) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: '密码错误' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const cookieValue = await generateAuthCookie(
|
||||
undefined,
|
||||
password,
|
||||
'user',
|
||||
true
|
||||
); // localstorage 模式包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 数据库 / redis 模式——校验用户名并尝试连接数据库
|
||||
const { username, password } = await req.json();
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 可能是站长,直接读环境变量
|
||||
if (
|
||||
username === process.env.USERNAME &&
|
||||
password === process.env.PASSWORD
|
||||
) {
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const cookieValue = await generateAuthCookie(
|
||||
username,
|
||||
password,
|
||||
'owner',
|
||||
false
|
||||
); // 数据库模式不包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
} else if (username === process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户被封禁' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 校验用户密码
|
||||
try {
|
||||
const pass = await db.verifyUser(username, password);
|
||||
if (!pass) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名或密码错误' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const cookieValue = await generateAuthCookie(
|
||||
username,
|
||||
password,
|
||||
user?.role || 'user',
|
||||
false
|
||||
); // 数据库模式不包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error('数据库验证失败', err);
|
||||
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录接口异常', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
18
src/app/api/logout/route.ts
Normal file
18
src/app/api/logout/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
// 清除认证cookie
|
||||
response.cookies.set('auth', '', {
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
159
src/app/api/playrecords/route.ts
Normal file
159
src/app/api/playrecords/route.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { PlayRecord } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const records = await db.getAllPlayRecords(authInfo.username);
|
||||
return NextResponse.json(records, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, record }: { key: string; record: PlayRecord } = body;
|
||||
|
||||
if (!key || !record) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing key or record' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证播放记录数据
|
||||
if (!record.title || !record.source_name || record.index < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid record data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从key中解析source和id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const finalRecord = {
|
||||
...record,
|
||||
save_time: record.save_time ?? Date.now(),
|
||||
} as PlayRecord;
|
||||
|
||||
await db.savePlayRecord(authInfo.username, source, id, finalRecord);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('保存播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const username = authInfo.username;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (key) {
|
||||
// 如果提供了 key,删除单条播放记录
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.deletePlayRecord(username, source, id);
|
||||
} else {
|
||||
// 未提供 key,则清空全部播放记录
|
||||
// 目前 DbManager 没有对应方法,这里直接遍历删除
|
||||
const all = await db.getAllPlayRecords(username);
|
||||
await Promise.all(
|
||||
Object.keys(all).map(async (k) => {
|
||||
const [s, i] = k.split('+');
|
||||
if (s && i) await db.deletePlayRecord(username, s, i);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
129
src/app/api/register/route.ts
Normal file
129
src/app/api/register/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 读取存储类型环境变量,默认 localstorage
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 生成签名
|
||||
async function generateSignature(
|
||||
data: string,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const messageData = encoder.encode(data);
|
||||
|
||||
// 导入密钥
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
// 生成签名
|
||||
const signature = await crypto.subtle.sign('HMAC', key, messageData);
|
||||
|
||||
// 转换为十六进制字符串
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 生成认证Cookie(带签名)
|
||||
async function generateAuthCookie(username: string): Promise<string> {
|
||||
const authData: any = {
|
||||
role: 'user',
|
||||
username,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 使用process.env.PASSWORD作为签名密钥,而不是用户密码
|
||||
const signingKey = process.env.PASSWORD || '';
|
||||
const signature = await generateSignature(username, signingKey);
|
||||
authData.signature = signature;
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// localstorage 模式下不支持注册
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '当前模式不支持注册' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
// 校验是否开放注册
|
||||
if (!config.UserConfig.AllowRegister) {
|
||||
return NextResponse.json({ error: '当前未开放注册' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { username, password } = await req.json();
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查是否和管理员重复
|
||||
if (username === process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查用户是否已存在
|
||||
const exist = await db.checkUserExist(username);
|
||||
if (exist) {
|
||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.registerUser(username, password);
|
||||
|
||||
// 添加到配置中并保存
|
||||
config.UserConfig.Users.push({
|
||||
username,
|
||||
role: 'user',
|
||||
});
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
// 注册成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const cookieValue = await generateAuthCookie(username);
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error('数据库注册失败', err);
|
||||
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册接口异常', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
86
src/app/api/search/one/route.ts
Normal file
86
src/app/api/search/one/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// OrionTV 兼容接口
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
const resourceId = searchParams.get('resourceId');
|
||||
|
||||
if (!query || !resourceId) {
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(
|
||||
{ result: null, error: '缺少必要参数: q 或 resourceId' },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = config.SourceConfig.filter((site) => !site.disabled);
|
||||
|
||||
try {
|
||||
// 根据 resourceId 查找对应的 API 站点
|
||||
const targetSite = apiSites.find((site) => site.key === resourceId);
|
||||
if (!targetSite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `未找到指定的视频源: ${resourceId}`,
|
||||
result: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = await searchFromApi(targetSite, query);
|
||||
let result = results.filter((r) => r.title === query);
|
||||
if (!config.SiteConfig.DisableYellowFilter) {
|
||||
result = result.filter((result) => {
|
||||
const typeName = result.type_name || '';
|
||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||
});
|
||||
}
|
||||
const cacheTime = await getCacheTime();
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '未找到结果',
|
||||
result: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ results: result },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '搜索失败',
|
||||
result: null,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/app/api/search/resources/route.ts
Normal file
24
src/app/api/search/resources/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// OrionTV 兼容接口
|
||||
export async function GET() {
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites();
|
||||
const cacheTime = await getCacheTime();
|
||||
|
||||
return NextResponse.json(apiSites, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
74
src/app/api/search/route.ts
Normal file
74
src/app/api/search/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
if (!query) {
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(
|
||||
{ results: [] },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = config.SourceConfig.filter((site) => !site.disabled);
|
||||
|
||||
// 添加超时控制和错误处理,避免慢接口拖累整体响应
|
||||
const searchPromises = apiSites.map((site) =>
|
||||
Promise.race([
|
||||
searchFromApi(site, query),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
|
||||
),
|
||||
]).catch((err) => {
|
||||
console.warn(`搜索失败 ${site.name}:`, err.message);
|
||||
return []; // 返回空数组而不是抛出错误
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(searchPromises);
|
||||
const successResults = results
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => (result as PromiseFulfilledResult<any>).value);
|
||||
let flattenedResults = successResults.flat();
|
||||
if (!config.SiteConfig.DisableYellowFilter) {
|
||||
flattenedResults = flattenedResults.filter((result) => {
|
||||
const typeName = result.type_name || '';
|
||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||
});
|
||||
}
|
||||
const cacheTime = await getCacheTime();
|
||||
|
||||
return NextResponse.json(
|
||||
{ results: flattenedResults },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
137
src/app/api/search/suggestions/route.ts
Normal file
137
src/app/api/search/suggestions/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q')?.trim();
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
// 生成建议
|
||||
const suggestions = await generateSuggestions(query);
|
||||
|
||||
// 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟)
|
||||
const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300;
|
||||
|
||||
return NextResponse.json(
|
||||
{ suggestions },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('获取搜索建议失败', error);
|
||||
return NextResponse.json({ error: '获取搜索建议失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSuggestions(query: string): Promise<
|
||||
Array<{
|
||||
text: string;
|
||||
type: 'exact' | 'related' | 'suggestion';
|
||||
score: number;
|
||||
}>
|
||||
> {
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = config.SourceConfig.filter((site: any) => !site.disabled);
|
||||
let realKeywords: string[] = [];
|
||||
|
||||
if (apiSites.length > 0) {
|
||||
// 取第一个可用的数据源进行搜索
|
||||
const firstSite = apiSites[0];
|
||||
const results = await searchFromApi(firstSite, query);
|
||||
|
||||
realKeywords = Array.from(
|
||||
new Set(
|
||||
results
|
||||
.map((r: any) => r.title)
|
||||
.filter(Boolean)
|
||||
.flatMap((title: string) => title.split(/[ -::·、-]/))
|
||||
.filter(
|
||||
(w: string) => w.length > 1 && w.toLowerCase().includes(queryLower)
|
||||
)
|
||||
)
|
||||
).slice(0, 8);
|
||||
}
|
||||
|
||||
// 根据关键词与查询的匹配程度计算分数,并动态确定类型
|
||||
const realSuggestions = realKeywords.map((word) => {
|
||||
const wordLower = word.toLowerCase();
|
||||
const queryWords = queryLower.split(/[ -::·、-]/);
|
||||
|
||||
// 计算匹配分数:完全匹配得分更高
|
||||
let score = 1.0;
|
||||
if (wordLower === queryLower) {
|
||||
score = 2.0; // 完全匹配
|
||||
} else if (
|
||||
wordLower.startsWith(queryLower) ||
|
||||
wordLower.endsWith(queryLower)
|
||||
) {
|
||||
score = 1.8; // 前缀或后缀匹配
|
||||
} else if (queryWords.some((qw) => wordLower.includes(qw))) {
|
||||
score = 1.5; // 包含查询词
|
||||
}
|
||||
|
||||
// 根据匹配程度确定类型
|
||||
let type: 'exact' | 'related' | 'suggestion' = 'related';
|
||||
if (score >= 2.0) {
|
||||
type = 'exact';
|
||||
} else if (score >= 1.5) {
|
||||
type = 'related';
|
||||
} else {
|
||||
type = 'suggestion';
|
||||
}
|
||||
|
||||
return {
|
||||
text: word,
|
||||
type,
|
||||
score,
|
||||
};
|
||||
});
|
||||
|
||||
// 按分数降序排列,相同分数按类型优先级排列
|
||||
const sortedSuggestions = realSuggestions.sort((a, b) => {
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score; // 分数高的在前
|
||||
}
|
||||
// 分数相同时,按类型优先级:exact > related > suggestion
|
||||
const typePriority = { exact: 3, related: 2, suggestion: 1 };
|
||||
return typePriority[b.type] - typePriority[a.type];
|
||||
});
|
||||
|
||||
return sortedSuggestions;
|
||||
}
|
||||
133
src/app/api/searchhistory/route.ts
Normal file
133
src/app/api/searchhistory/route.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 最大保存条数(与客户端保持一致)
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* GET /api/searchhistory
|
||||
* 返回 string[]
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const history = await db.getSearchHistory(authInfo.username);
|
||||
return NextResponse.json(history, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/searchhistory
|
||||
* body: { keyword: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const keyword: string = body.keyword?.trim();
|
||||
|
||||
if (!keyword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Keyword is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.addSearchHistory(authInfo.username, keyword);
|
||||
|
||||
// 再次获取最新列表,确保客户端与服务端同步
|
||||
const history = await db.getSearchHistory(authInfo.username);
|
||||
return NextResponse.json(history.slice(0, HISTORY_LIMIT), { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('添加搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/searchhistory?keyword=<kw>
|
||||
*
|
||||
* 1. 不带 keyword -> 清空全部搜索历史
|
||||
* 2. 带 keyword=<kw> -> 删除单条关键字
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const kw = searchParams.get('keyword')?.trim();
|
||||
|
||||
await db.deleteSearchHistory(authInfo.username, kw || undefined);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/app/api/server-config/route.ts
Normal file
18
src/app/api/server-config/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log('server-config called: ', request.url);
|
||||
|
||||
const config = await getConfig();
|
||||
const result = {
|
||||
SiteName: config.SiteConfig.SiteName,
|
||||
StorageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
|
||||
};
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
143
src/app/api/skipconfigs/route.ts
Normal file
143
src/app/api/skipconfigs/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { SkipConfig } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (source && id) {
|
||||
// 获取单个配置
|
||||
const config = await db.getSkipConfig(authInfo.username, source, id);
|
||||
return NextResponse.json(config);
|
||||
} else {
|
||||
// 获取所有配置
|
||||
const configs = await db.getAllSkipConfigs(authInfo.username);
|
||||
return NextResponse.json(configs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取跳过片头片尾配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取跳过片头片尾配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (adminConfig.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, config } = body;
|
||||
|
||||
if (!key || !config) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 解析key为source和id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证配置格式
|
||||
const skipConfig: SkipConfig = {
|
||||
enable: Boolean(config.enable),
|
||||
intro_time: Number(config.intro_time) || 0,
|
||||
outro_time: Number(config.outro_time) || 0,
|
||||
};
|
||||
|
||||
await db.setSkipConfig(authInfo.username, source, id, skipConfig);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('保存跳过片头片尾配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '保存跳过片头片尾配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (adminConfig.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 解析key为source和id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.deleteSkipConfig(authInfo.username, source, id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('删除跳过片头片尾配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '删除跳过片头片尾配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
822
src/app/douban/page.tsx
Normal file
822
src/app/douban/page.tsx
Normal file
@@ -0,0 +1,822 @@
|
||||
/* eslint-disable no-console,react-hooks/exhaustive-deps,@typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { GetBangumiCalendarData } from '@/lib/bangumi.client';
|
||||
import {
|
||||
getDoubanCategories,
|
||||
getDoubanList,
|
||||
getDoubanRecommends,
|
||||
} from '@/lib/douban.client';
|
||||
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||
|
||||
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
|
||||
import DoubanCustomSelector from '@/components/DoubanCustomSelector';
|
||||
import DoubanSelector from '@/components/DoubanSelector';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
function DoubanPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [selectorsReady, setSelectorsReady] = useState(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadingRef = useRef<HTMLDivElement>(null);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 用于存储最新参数值的 refs
|
||||
const currentParamsRef = useRef({
|
||||
type: '',
|
||||
primarySelection: '',
|
||||
secondarySelection: '',
|
||||
multiLevelSelection: {} as Record<string, string>,
|
||||
selectedWeekday: '',
|
||||
currentPage: 0,
|
||||
});
|
||||
|
||||
const type = searchParams.get('type') || 'movie';
|
||||
|
||||
// 获取 runtimeConfig 中的自定义分类数据
|
||||
const [customCategories, setCustomCategories] = useState<
|
||||
Array<{ name: string; type: 'movie' | 'tv'; query: string }>
|
||||
>([]);
|
||||
|
||||
// 选择器状态 - 完全独立,不依赖URL参数
|
||||
const [primarySelection, setPrimarySelection] = useState<string>(() => {
|
||||
if (type === 'movie') return '热门';
|
||||
if (type === 'tv' || type === 'show') return '最近热门';
|
||||
if (type === 'anime') return '每日放送';
|
||||
return '';
|
||||
});
|
||||
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
|
||||
if (type === 'movie') return '全部';
|
||||
if (type === 'tv') return 'tv';
|
||||
if (type === 'show') return 'show';
|
||||
return '全部';
|
||||
});
|
||||
|
||||
// MultiLevelSelector 状态
|
||||
const [multiLevelValues, setMultiLevelValues] = useState<
|
||||
Record<string, string>
|
||||
>({
|
||||
type: 'all',
|
||||
region: 'all',
|
||||
year: 'all',
|
||||
platform: 'all',
|
||||
label: 'all',
|
||||
sort: 'T',
|
||||
});
|
||||
|
||||
// 星期选择器状态
|
||||
const [selectedWeekday, setSelectedWeekday] = useState<string>('');
|
||||
|
||||
// 获取自定义分类数据
|
||||
useEffect(() => {
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
|
||||
setCustomCategories(runtimeConfig.CUSTOM_CATEGORIES);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 同步最新参数值到 ref
|
||||
useEffect(() => {
|
||||
currentParamsRef.current = {
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelSelection: multiLevelValues,
|
||||
selectedWeekday,
|
||||
currentPage,
|
||||
};
|
||||
}, [
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelValues,
|
||||
selectedWeekday,
|
||||
currentPage,
|
||||
]);
|
||||
|
||||
// 初始化时标记选择器为准备好状态
|
||||
useEffect(() => {
|
||||
// 短暂延迟确保初始状态设置完成
|
||||
const timer = setTimeout(() => {
|
||||
setSelectorsReady(true);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 只在组件挂载时执行一次
|
||||
|
||||
// type变化时立即重置selectorsReady(最高优先级)
|
||||
useEffect(() => {
|
||||
setSelectorsReady(false);
|
||||
setLoading(true); // 立即显示loading状态
|
||||
}, [type]);
|
||||
|
||||
// 当type变化时重置选择器状态
|
||||
useEffect(() => {
|
||||
if (type === 'custom' && customCategories.length > 0) {
|
||||
// 自定义分类模式:优先选择 movie,如果没有 movie 则选择 tv
|
||||
const types = Array.from(
|
||||
new Set(customCategories.map((cat) => cat.type))
|
||||
);
|
||||
if (types.length > 0) {
|
||||
// 优先选择 movie,如果没有 movie 则选择 tv
|
||||
let selectedType = types[0]; // 默认选择第一个
|
||||
if (types.includes('movie')) {
|
||||
selectedType = 'movie';
|
||||
} else {
|
||||
selectedType = 'tv';
|
||||
}
|
||||
setPrimarySelection(selectedType);
|
||||
|
||||
// 设置选中类型的第一个分类的 query 作为二级选择
|
||||
const firstCategory = customCategories.find(
|
||||
(cat) => cat.type === selectedType
|
||||
);
|
||||
if (firstCategory) {
|
||||
setSecondarySelection(firstCategory.query);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 原有逻辑
|
||||
if (type === 'movie') {
|
||||
setPrimarySelection('热门');
|
||||
setSecondarySelection('全部');
|
||||
} else if (type === 'tv') {
|
||||
setPrimarySelection('最近热门');
|
||||
setSecondarySelection('tv');
|
||||
} else if (type === 'show') {
|
||||
setPrimarySelection('最近热门');
|
||||
setSecondarySelection('show');
|
||||
} else if (type === 'anime') {
|
||||
setPrimarySelection('每日放送');
|
||||
setSecondarySelection('全部');
|
||||
} else {
|
||||
setPrimarySelection('');
|
||||
setSecondarySelection('全部');
|
||||
}
|
||||
}
|
||||
|
||||
// 清空 MultiLevelSelector 状态
|
||||
setMultiLevelValues({
|
||||
type: 'all',
|
||||
region: 'all',
|
||||
year: 'all',
|
||||
platform: 'all',
|
||||
label: 'all',
|
||||
sort: 'T',
|
||||
});
|
||||
|
||||
// 使用短暂延迟确保状态更新完成后标记选择器准备好
|
||||
const timer = setTimeout(() => {
|
||||
setSelectorsReady(true);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [type, customCategories]);
|
||||
|
||||
// 生成骨架屏数据
|
||||
const skeletonData = Array.from({ length: 25 }, (_, index) => index);
|
||||
|
||||
// 参数快照比较函数
|
||||
const isSnapshotEqual = useCallback(
|
||||
(
|
||||
snapshot1: {
|
||||
type: string;
|
||||
primarySelection: string;
|
||||
secondarySelection: string;
|
||||
multiLevelSelection: Record<string, string>;
|
||||
selectedWeekday: string;
|
||||
currentPage: number;
|
||||
},
|
||||
snapshot2: {
|
||||
type: string;
|
||||
primarySelection: string;
|
||||
secondarySelection: string;
|
||||
multiLevelSelection: Record<string, string>;
|
||||
selectedWeekday: string;
|
||||
currentPage: number;
|
||||
}
|
||||
) => {
|
||||
return (
|
||||
snapshot1.type === snapshot2.type &&
|
||||
snapshot1.primarySelection === snapshot2.primarySelection &&
|
||||
snapshot1.secondarySelection === snapshot2.secondarySelection &&
|
||||
snapshot1.selectedWeekday === snapshot2.selectedWeekday &&
|
||||
snapshot1.currentPage === snapshot2.currentPage &&
|
||||
JSON.stringify(snapshot1.multiLevelSelection) ===
|
||||
JSON.stringify(snapshot2.multiLevelSelection)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 生成API请求参数的辅助函数
|
||||
const getRequestParams = useCallback(
|
||||
(pageStart: number) => {
|
||||
// 当type为tv或show时,kind统一为'tv',category使用type本身
|
||||
if (type === 'tv' || type === 'show') {
|
||||
return {
|
||||
kind: 'tv' as const,
|
||||
category: type,
|
||||
type: secondarySelection,
|
||||
pageLimit: 25,
|
||||
pageStart,
|
||||
};
|
||||
}
|
||||
|
||||
// 电影类型保持原逻辑
|
||||
return {
|
||||
kind: type as 'tv' | 'movie',
|
||||
category: primarySelection,
|
||||
type: secondarySelection,
|
||||
pageLimit: 25,
|
||||
pageStart,
|
||||
};
|
||||
},
|
||||
[type, primarySelection, secondarySelection]
|
||||
);
|
||||
|
||||
// 防抖的数据加载函数
|
||||
const loadInitialData = useCallback(async () => {
|
||||
// 创建当前参数的快照
|
||||
const requestSnapshot = {
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelSelection: multiLevelValues,
|
||||
selectedWeekday,
|
||||
currentPage: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// 确保在加载初始数据时重置页面状态
|
||||
setDoubanData([]);
|
||||
setCurrentPage(0);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
|
||||
let data: DoubanResult;
|
||||
|
||||
if (type === 'custom') {
|
||||
// 自定义分类模式:根据选中的一级和二级选项获取对应的分类
|
||||
const selectedCategory = customCategories.find(
|
||||
(cat) =>
|
||||
cat.type === primarySelection && cat.query === secondarySelection
|
||||
);
|
||||
|
||||
if (selectedCategory) {
|
||||
data = await getDoubanList({
|
||||
tag: selectedCategory.query,
|
||||
type: selectedCategory.type,
|
||||
pageLimit: 25,
|
||||
pageStart: 0,
|
||||
});
|
||||
} else {
|
||||
throw new Error('没有找到对应的分类');
|
||||
}
|
||||
} else if (type === 'anime' && primarySelection === '每日放送') {
|
||||
const calendarData = await GetBangumiCalendarData();
|
||||
const weekdayData = calendarData.find(
|
||||
(item) => item.weekday.en === selectedWeekday
|
||||
);
|
||||
if (weekdayData) {
|
||||
data = {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
list: weekdayData.items.map((item) => ({
|
||||
id: item.id?.toString() || '',
|
||||
title: item.name_cn || item.name,
|
||||
poster:
|
||||
item.images.large ||
|
||||
item.images.common ||
|
||||
item.images.medium ||
|
||||
item.images.small ||
|
||||
item.images.grid,
|
||||
rate: item.rating?.score?.toString() || '',
|
||||
year: item.air_date?.split('-')?.[0] || '',
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
throw new Error('没有找到对应的日期');
|
||||
}
|
||||
} else if (type === 'anime') {
|
||||
data = await getDoubanRecommends({
|
||||
kind: primarySelection === '番剧' ? 'tv' : 'movie',
|
||||
pageLimit: 25,
|
||||
pageStart: 0,
|
||||
category: '动画',
|
||||
format: primarySelection === '番剧' ? '电视剧' : '',
|
||||
region: multiLevelValues.region
|
||||
? (multiLevelValues.region as string)
|
||||
: '',
|
||||
year: multiLevelValues.year ? (multiLevelValues.year as string) : '',
|
||||
platform: multiLevelValues.platform
|
||||
? (multiLevelValues.platform as string)
|
||||
: '',
|
||||
sort: multiLevelValues.sort ? (multiLevelValues.sort as string) : '',
|
||||
label: multiLevelValues.label
|
||||
? (multiLevelValues.label as string)
|
||||
: '',
|
||||
});
|
||||
} else if (primarySelection === '全部') {
|
||||
data = await getDoubanRecommends({
|
||||
kind: type === 'show' ? 'tv' : (type as 'tv' | 'movie'),
|
||||
pageLimit: 25,
|
||||
pageStart: 0, // 初始数据加载始终从第一页开始
|
||||
category: multiLevelValues.type
|
||||
? (multiLevelValues.type as string)
|
||||
: '',
|
||||
format: type === 'show' ? '综艺' : type === 'tv' ? '电视剧' : '',
|
||||
region: multiLevelValues.region
|
||||
? (multiLevelValues.region as string)
|
||||
: '',
|
||||
year: multiLevelValues.year ? (multiLevelValues.year as string) : '',
|
||||
platform: multiLevelValues.platform
|
||||
? (multiLevelValues.platform as string)
|
||||
: '',
|
||||
sort: multiLevelValues.sort ? (multiLevelValues.sort as string) : '',
|
||||
label: multiLevelValues.label
|
||||
? (multiLevelValues.label as string)
|
||||
: '',
|
||||
});
|
||||
} else {
|
||||
data = await getDoubanCategories(getRequestParams(0));
|
||||
}
|
||||
|
||||
if (data.code === 200) {
|
||||
// 检查参数是否仍然一致,如果一致才设置数据
|
||||
// 使用 ref 获取最新的当前值
|
||||
const currentSnapshot = { ...currentParamsRef.current };
|
||||
|
||||
if (isSnapshotEqual(requestSnapshot, currentSnapshot)) {
|
||||
setDoubanData(data.list);
|
||||
setHasMore(data.list.length !== 0);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log('参数不一致,不执行任何操作,避免设置过期数据');
|
||||
}
|
||||
// 如果参数不一致,不执行任何操作,避免设置过期数据
|
||||
} else {
|
||||
throw new Error(data.message || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLoading(false); // 发生错误时总是停止loading状态
|
||||
}
|
||||
}, [
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelValues,
|
||||
selectedWeekday,
|
||||
getRequestParams,
|
||||
customCategories,
|
||||
]);
|
||||
|
||||
// 只在选择器准备好后才加载数据
|
||||
useEffect(() => {
|
||||
// 只有在选择器准备好时才开始加载
|
||||
if (!selectorsReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的防抖定时器
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 使用防抖机制加载数据,避免连续状态更新触发多次请求
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
loadInitialData();
|
||||
}, 100); // 100ms 防抖延迟
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
selectorsReady,
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelValues,
|
||||
selectedWeekday,
|
||||
loadInitialData,
|
||||
]);
|
||||
|
||||
// 单独处理 currentPage 变化(加载更多)
|
||||
useEffect(() => {
|
||||
if (currentPage > 0) {
|
||||
const fetchMoreData = async () => {
|
||||
// 创建当前参数的快照
|
||||
const requestSnapshot = {
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelSelection: multiLevelValues,
|
||||
selectedWeekday,
|
||||
currentPage,
|
||||
};
|
||||
|
||||
try {
|
||||
setIsLoadingMore(true);
|
||||
|
||||
let data: DoubanResult;
|
||||
if (type === 'custom') {
|
||||
// 自定义分类模式:根据选中的一级和二级选项获取对应的分类
|
||||
const selectedCategory = customCategories.find(
|
||||
(cat) =>
|
||||
cat.type === primarySelection &&
|
||||
cat.query === secondarySelection
|
||||
);
|
||||
|
||||
if (selectedCategory) {
|
||||
data = await getDoubanList({
|
||||
tag: selectedCategory.query,
|
||||
type: selectedCategory.type,
|
||||
pageLimit: 25,
|
||||
pageStart: currentPage * 25,
|
||||
});
|
||||
} else {
|
||||
throw new Error('没有找到对应的分类');
|
||||
}
|
||||
} else if (type === 'anime' && primarySelection === '每日放送') {
|
||||
// 每日放送模式下,不进行数据请求,返回空数据
|
||||
data = {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
list: [],
|
||||
};
|
||||
} else if (type === 'anime') {
|
||||
data = await getDoubanRecommends({
|
||||
kind: primarySelection === '番剧' ? 'tv' : 'movie',
|
||||
pageLimit: 25,
|
||||
pageStart: currentPage * 25,
|
||||
category: '动画',
|
||||
format: primarySelection === '番剧' ? '电视剧' : '',
|
||||
region: multiLevelValues.region
|
||||
? (multiLevelValues.region as string)
|
||||
: '',
|
||||
year: multiLevelValues.year
|
||||
? (multiLevelValues.year as string)
|
||||
: '',
|
||||
platform: multiLevelValues.platform
|
||||
? (multiLevelValues.platform as string)
|
||||
: '',
|
||||
sort: multiLevelValues.sort
|
||||
? (multiLevelValues.sort as string)
|
||||
: '',
|
||||
label: multiLevelValues.label
|
||||
? (multiLevelValues.label as string)
|
||||
: '',
|
||||
});
|
||||
} else if (primarySelection === '全部') {
|
||||
data = await getDoubanRecommends({
|
||||
kind: type === 'show' ? 'tv' : (type as 'tv' | 'movie'),
|
||||
pageLimit: 25,
|
||||
pageStart: currentPage * 25,
|
||||
category: multiLevelValues.type
|
||||
? (multiLevelValues.type as string)
|
||||
: '',
|
||||
format: type === 'show' ? '综艺' : type === 'tv' ? '电视剧' : '',
|
||||
region: multiLevelValues.region
|
||||
? (multiLevelValues.region as string)
|
||||
: '',
|
||||
year: multiLevelValues.year
|
||||
? (multiLevelValues.year as string)
|
||||
: '',
|
||||
platform: multiLevelValues.platform
|
||||
? (multiLevelValues.platform as string)
|
||||
: '',
|
||||
sort: multiLevelValues.sort
|
||||
? (multiLevelValues.sort as string)
|
||||
: '',
|
||||
label: multiLevelValues.label
|
||||
? (multiLevelValues.label as string)
|
||||
: '',
|
||||
});
|
||||
} else {
|
||||
data = await getDoubanCategories(
|
||||
getRequestParams(currentPage * 25)
|
||||
);
|
||||
}
|
||||
|
||||
if (data.code === 200) {
|
||||
// 检查参数是否仍然一致,如果一致才设置数据
|
||||
// 使用 ref 获取最新的当前值
|
||||
const currentSnapshot = { ...currentParamsRef.current };
|
||||
|
||||
if (isSnapshotEqual(requestSnapshot, currentSnapshot)) {
|
||||
setDoubanData((prev) => [...prev, ...data.list]);
|
||||
setHasMore(data.list.length !== 0);
|
||||
} else {
|
||||
console.log('参数不一致,不执行任何操作,避免设置过期数据');
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.message || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMoreData();
|
||||
}
|
||||
}, [
|
||||
currentPage,
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
customCategories,
|
||||
multiLevelValues,
|
||||
selectedWeekday,
|
||||
]);
|
||||
|
||||
// 设置滚动监听
|
||||
useEffect(() => {
|
||||
// 如果没有更多数据或正在加载,则不设置监听
|
||||
if (!hasMore || isLoadingMore || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保 loadingRef 存在
|
||||
if (!loadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(loadingRef.current);
|
||||
observerRef.current = observer;
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasMore, isLoadingMore, loading]);
|
||||
|
||||
// 处理选择器变化
|
||||
const handlePrimaryChange = useCallback(
|
||||
(value: string) => {
|
||||
// 只有当值真正改变时才设置loading状态
|
||||
if (value !== primarySelection) {
|
||||
setLoading(true);
|
||||
// 立即重置页面状态,防止基于旧状态的请求
|
||||
setCurrentPage(0);
|
||||
setDoubanData([]);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
|
||||
// 清空 MultiLevelSelector 状态
|
||||
setMultiLevelValues({
|
||||
type: 'all',
|
||||
region: 'all',
|
||||
year: 'all',
|
||||
platform: 'all',
|
||||
label: 'all',
|
||||
sort: 'T',
|
||||
});
|
||||
|
||||
// 如果是自定义分类模式,同时更新一级和二级选择器
|
||||
if (type === 'custom' && customCategories.length > 0) {
|
||||
const firstCategory = customCategories.find(
|
||||
(cat) => cat.type === value
|
||||
);
|
||||
if (firstCategory) {
|
||||
// 批量更新状态,避免多次触发数据加载
|
||||
setPrimarySelection(value);
|
||||
setSecondarySelection(firstCategory.query);
|
||||
} else {
|
||||
setPrimarySelection(value);
|
||||
}
|
||||
} else {
|
||||
// 电视剧和综艺切换到"最近热门"时,重置二级分类为第一个选项
|
||||
if ((type === 'tv' || type === 'show') && value === '最近热门') {
|
||||
setPrimarySelection(value);
|
||||
if (type === 'tv') {
|
||||
setSecondarySelection('tv');
|
||||
} else if (type === 'show') {
|
||||
setSecondarySelection('show');
|
||||
}
|
||||
} else {
|
||||
setPrimarySelection(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[primarySelection, type, customCategories]
|
||||
);
|
||||
|
||||
const handleSecondaryChange = useCallback(
|
||||
(value: string) => {
|
||||
// 只有当值真正改变时才设置loading状态
|
||||
if (value !== secondarySelection) {
|
||||
setLoading(true);
|
||||
// 立即重置页面状态,防止基于旧状态的请求
|
||||
setCurrentPage(0);
|
||||
setDoubanData([]);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
setSecondarySelection(value);
|
||||
}
|
||||
},
|
||||
[secondarySelection]
|
||||
);
|
||||
|
||||
const handleMultiLevelChange = useCallback(
|
||||
(values: Record<string, string>) => {
|
||||
// 比较两个对象是否相同,忽略顺序
|
||||
const isEqual = (
|
||||
obj1: Record<string, string>,
|
||||
obj2: Record<string, string>
|
||||
) => {
|
||||
const keys1 = Object.keys(obj1).sort();
|
||||
const keys2 = Object.keys(obj2).sort();
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
return keys1.every((key) => obj1[key] === obj2[key]);
|
||||
};
|
||||
|
||||
// 如果相同,则不设置loading状态
|
||||
if (isEqual(values, multiLevelValues)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// 立即重置页面状态,防止基于旧状态的请求
|
||||
setCurrentPage(0);
|
||||
setDoubanData([]);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
setMultiLevelValues(values);
|
||||
},
|
||||
[multiLevelValues]
|
||||
);
|
||||
|
||||
const handleWeekdayChange = useCallback((weekday: string) => {
|
||||
setSelectedWeekday(weekday);
|
||||
}, []);
|
||||
|
||||
const getPageTitle = () => {
|
||||
// 根据 type 生成标题
|
||||
return type === 'movie'
|
||||
? '电影'
|
||||
: type === 'tv'
|
||||
? '电视剧'
|
||||
: type === 'anime'
|
||||
? '动漫'
|
||||
: type === 'show'
|
||||
? '综艺'
|
||||
: '自定义';
|
||||
};
|
||||
|
||||
const getPageDescription = () => {
|
||||
if (type === 'anime' && primarySelection === '每日放送') {
|
||||
return '来自 Bangumi 番组计划的精选内容';
|
||||
}
|
||||
return '来自豆瓣的精选内容';
|
||||
};
|
||||
|
||||
const getActivePath = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.set('type', type);
|
||||
|
||||
const queryString = params.toString();
|
||||
const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
|
||||
return activePath;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath={getActivePath()}>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 页面标题和选择器 */}
|
||||
<div className='mb-6 sm:mb-8 space-y-4 sm:space-y-6'>
|
||||
{/* 页面标题 */}
|
||||
<div>
|
||||
<h1 className='text-2xl sm:text-3xl font-bold text-gray-800 mb-1 sm:mb-2 dark:text-gray-200'>
|
||||
{getPageTitle()}
|
||||
</h1>
|
||||
<p className='text-sm sm:text-base text-gray-600 dark:text-gray-400'>
|
||||
{getPageDescription()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 选择器组件 */}
|
||||
{type !== 'custom' ? (
|
||||
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
|
||||
<DoubanSelector
|
||||
type={type as 'movie' | 'tv' | 'show' | 'anime'}
|
||||
primarySelection={primarySelection}
|
||||
secondarySelection={secondarySelection}
|
||||
onPrimaryChange={handlePrimaryChange}
|
||||
onSecondaryChange={handleSecondaryChange}
|
||||
onMultiLevelChange={handleMultiLevelChange}
|
||||
onWeekdayChange={handleWeekdayChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
|
||||
<DoubanCustomSelector
|
||||
customCategories={customCategories}
|
||||
primarySelection={primarySelection}
|
||||
secondarySelection={secondarySelection}
|
||||
onPrimaryChange={handlePrimaryChange}
|
||||
onSecondaryChange={handleSecondaryChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容展示区域 */}
|
||||
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
|
||||
{/* 内容网格 */}
|
||||
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
|
||||
{loading || !selectorsReady
|
||||
? // 显示骨架屏
|
||||
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
|
||||
: // 显示实际数据
|
||||
doubanData.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className='w-full'>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
douban_id={Number(item.id)}
|
||||
rate={item.rate}
|
||||
year={item.year}
|
||||
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制,tv 不控
|
||||
isBangumi={
|
||||
type === 'anime' && primarySelection === '每日放送'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
{hasMore && !loading && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el && el.offsetParent !== null) {
|
||||
(
|
||||
loadingRef as React.MutableRefObject<HTMLDivElement | null>
|
||||
).current = el;
|
||||
}
|
||||
}}
|
||||
className='flex justify-center mt-12 py-8'
|
||||
>
|
||||
{isLoadingMore && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
|
||||
<span className='text-gray-600'>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && doubanData.length > 0 && (
|
||||
<div className='text-center text-gray-500 py-8'>已加载全部内容</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && doubanData.length === 0 && (
|
||||
<div className='text-center text-gray-500 py-8'>暂无相关内容</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DoubanPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DoubanPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
176
src/app/globals.css
Normal file
176
src/app/globals.css
Normal file
@@ -0,0 +1,176 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
/* 阻止 iOS Safari 拉动回弹 */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
}
|
||||
|
||||
html:not(.dark) body {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#e6f3fb 0%,
|
||||
#eaf3f7 18%,
|
||||
#f7f7f3 38%,
|
||||
#e9ecef 60%,
|
||||
#dbe3ea 80%,
|
||||
#d3dde6 100%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(31, 41, 55, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(107, 114, 128, 0.5);
|
||||
}
|
||||
|
||||
/* 视频卡片悬停效果 */
|
||||
.video-card-hover {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.video-card-hover:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 渐变遮罩 */
|
||||
.gradient-overlay {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0.8) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 隐藏移动端(<768px)垂直滚动条 */
|
||||
@media (max-width: 767px) {
|
||||
html,
|
||||
body {
|
||||
-ms-overflow-style: none; /* IE & Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none; /* Chrome Safari */
|
||||
}
|
||||
}
|
||||
|
||||
/* 隐藏所有滚动条(兼容 WebKit、Firefox、IE/Edge) */
|
||||
* {
|
||||
-ms-overflow-style: none; /* IE & Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* View Transitions API 动画 */
|
||||
@keyframes slide-from-top {
|
||||
from {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-from-bottom {
|
||||
from {
|
||||
clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.8s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/*
|
||||
切换时,旧的视图不应该有动画,它应该在下面,等待被新的视图覆盖。
|
||||
这可以防止在动画完成前,页面底部提前变色。
|
||||
*/
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 从浅色到深色:新内容(深色)从顶部滑入 */
|
||||
html.dark::view-transition-new(root) {
|
||||
animation-name: slide-from-top;
|
||||
}
|
||||
|
||||
/* 从深色到浅色:新内容(浅色)从底部滑入 */
|
||||
html:not(.dark)::view-transition-new(root) {
|
||||
animation-name: slide-from-bottom;
|
||||
}
|
||||
|
||||
/* 强制播放器内部的 video 元素高度为 100%,并保持内容完整显示 */
|
||||
div[data-media-provider] video {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.art-poster {
|
||||
background-size: contain !important; /* 使图片完整展示 */
|
||||
background-position: center center !important; /* 居中显示 */
|
||||
background-repeat: no-repeat !important; /* 防止重复 */
|
||||
background-color: #000 !important; /* 其余区域填充为黑色 */
|
||||
}
|
||||
|
||||
/* 隐藏移动端竖屏时的 pip 按钮 */
|
||||
@media (max-width: 768px) {
|
||||
.art-control-pip {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.art-control-fullscreenWeb {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.art-control-volume {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
124
src/app/layout.tsx
Normal file
124
src/app/layout.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
import './globals.css';
|
||||
import 'sweetalert2/dist/sweetalert2.min.css';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import RuntimeConfig from '@/lib/runtime';
|
||||
|
||||
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
||||
import { SiteProvider } from '../components/SiteProvider';
|
||||
import { ThemeProvider } from '../components/ThemeProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
// 动态生成 metadata,支持配置更新后的标题变化
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash') {
|
||||
const config = await getConfig();
|
||||
siteName = config.SiteConfig.SiteName;
|
||||
}
|
||||
|
||||
return {
|
||||
title: siteName,
|
||||
description: '影视聚合',
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
let announcement =
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
||||
let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct';
|
||||
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||
let doubanImageProxyType =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct';
|
||||
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
|
||||
let disableYellowFilter =
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||
let customCategories =
|
||||
(RuntimeConfig as any).custom_category?.map((category: any) => ({
|
||||
name: 'name' in category ? category.name : '',
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
})) || ([] as Array<{ name: string; type: 'movie' | 'tv'; query: string }>);
|
||||
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash') {
|
||||
const config = await getConfig();
|
||||
siteName = config.SiteConfig.SiteName;
|
||||
announcement = config.SiteConfig.Announcement;
|
||||
enableRegister = config.UserConfig.AllowRegister;
|
||||
doubanProxyType = config.SiteConfig.DoubanProxyType;
|
||||
doubanProxy = config.SiteConfig.DoubanProxy;
|
||||
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
|
||||
doubanImageProxy = config.SiteConfig.DoubanImageProxy;
|
||||
disableYellowFilter = config.SiteConfig.DisableYellowFilter;
|
||||
customCategories = config.CustomCategories.filter(
|
||||
(category) => !category.disabled
|
||||
).map((category) => ({
|
||||
name: category.name || '',
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
}));
|
||||
}
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
const runtimeConfig = {
|
||||
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
|
||||
ENABLE_REGISTER: enableRegister,
|
||||
DOUBAN_PROXY_TYPE: doubanProxyType,
|
||||
DOUBAN_PROXY: doubanProxy,
|
||||
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
|
||||
DOUBAN_IMAGE_PROXY: doubanImageProxy,
|
||||
DISABLE_YELLOW_FILTER: disableYellowFilter,
|
||||
CUSTOM_CATEGORIES: customCategories,
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang='zh-CN' suppressHydrationWarning>
|
||||
<head>
|
||||
<meta
|
||||
name='viewport'
|
||||
content='width=device-width, initial-scale=1.0, viewport-fit=cover'
|
||||
/>
|
||||
<link rel='apple-touch-icon' href='/icons/icon-192x192.png' />
|
||||
{/* 将配置序列化后直接写入脚本,浏览器端可通过 window.RUNTIME_CONFIG 获取 */}
|
||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SiteProvider siteName={siteName} announcement={announcement}>
|
||||
{children}
|
||||
<GlobalErrorIndicator />
|
||||
</SiteProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
245
src/app/login/page.tsx
Normal file
245
src/app/login/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
|
||||
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
// 版本显示组件
|
||||
function VersionDisplay() {
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
const status = await checkForUpdates();
|
||||
setUpdateStatus(status);
|
||||
} catch (_) {
|
||||
// do nothing
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkUpdate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
window.open('https://github.com/LunaTechLab/MoonTV', '_blank')
|
||||
}
|
||||
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
|
||||
>
|
||||
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
||||
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 ${
|
||||
updateStatus === UpdateStatus.HAS_UPDATE
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: updateStatus === UpdateStatus.NO_UPDATE
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{updateStatus === UpdateStatus.HAS_UPDATE && (
|
||||
<>
|
||||
<AlertCircle className='w-3.5 h-3.5' />
|
||||
<span className='font-semibold text-xs'>有新版本</span>
|
||||
</>
|
||||
)}
|
||||
{updateStatus === UpdateStatus.NO_UPDATE && (
|
||||
<>
|
||||
<CheckCircle className='w-3.5 h-3.5' />
|
||||
<span className='font-semibold text-xs'>已是最新</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shouldAskUsername, setShouldAskUsername] = useState(false);
|
||||
const [enableRegister, setEnableRegister] = useState(false);
|
||||
const { siteName } = useSite();
|
||||
|
||||
// 在客户端挂载后设置配置
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
|
||||
setShouldAskUsername(storageType && storageType !== 'localstorage');
|
||||
setEnableRegister(
|
||||
Boolean((window as any).RUNTIME_CONFIG?.ENABLE_REGISTER)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!password || (shouldAskUsername && !username)) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
...(shouldAskUsername ? { username } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
router.replace(redirect);
|
||||
} else if (res.status === 401) {
|
||||
setError('密码错误');
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error ?? '服务器错误');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('网络错误,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理注册逻辑
|
||||
const handleRegister = async () => {
|
||||
setError(null);
|
||||
if (!password || !username) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
router.replace(redirect);
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error ?? '服务器错误');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('网络错误,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
|
||||
<div className='absolute top-4 right-4'>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
|
||||
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
|
||||
{siteName}
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className='space-y-8'>
|
||||
{shouldAskUsername && (
|
||||
<div>
|
||||
<label htmlFor='username' className='sr-only'>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id='username'
|
||||
type='text'
|
||||
autoComplete='username'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
placeholder='输入用户名'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor='password' className='sr-only'>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id='password'
|
||||
type='password'
|
||||
autoComplete='current-password'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
placeholder='输入访问密码'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||
)}
|
||||
|
||||
{/* 登录 / 注册按钮 */}
|
||||
{shouldAskUsername && enableRegister ? (
|
||||
<div className='flex gap-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRegister}
|
||||
disabled={!password || !username || loading}
|
||||
className='flex-1 inline-flex justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={
|
||||
!password || loading || (shouldAskUsername && !username)
|
||||
}
|
||||
className='flex-1 inline-flex justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type='submit'
|
||||
disabled={
|
||||
!password || loading || (shouldAskUsername && !username)
|
||||
}
|
||||
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 版本信息显示 */}
|
||||
<VersionDisplay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LoginPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
491
src/app/page.tsx
Normal file
491
src/app/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
BangumiCalendarData,
|
||||
GetBangumiCalendarData,
|
||||
} from '@/lib/bangumi.client';
|
||||
// 客户端收藏 API
|
||||
import {
|
||||
clearAllFavorites,
|
||||
getAllFavorites,
|
||||
getAllPlayRecords,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { getDoubanCategories } from '@/lib/douban.client';
|
||||
import { DoubanItem } from '@/lib/types';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import ContinueWatching from '@/components/ContinueWatching';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
function HomeClient() {
|
||||
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
||||
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
||||
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
||||
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
||||
const [bangumiCalendarData, setBangumiCalendarData] = useState<
|
||||
BangumiCalendarData[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { announcement } = useSite();
|
||||
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
|
||||
// 检查公告弹窗状态
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && announcement) {
|
||||
const hasSeenAnnouncement = localStorage.getItem('hasSeenAnnouncement');
|
||||
if (hasSeenAnnouncement !== announcement) {
|
||||
setShowAnnouncement(true);
|
||||
} else {
|
||||
setShowAnnouncement(Boolean(!hasSeenAnnouncement && announcement));
|
||||
}
|
||||
}
|
||||
}, [announcement]);
|
||||
|
||||
// 收藏夹数据
|
||||
type FavoriteItem = {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes: number;
|
||||
source_name: string;
|
||||
currentEpisode?: number;
|
||||
search_title?: string;
|
||||
};
|
||||
|
||||
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecommendData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 并行获取热门电影、热门剧集和热门综艺
|
||||
const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] =
|
||||
await Promise.all([
|
||||
getDoubanCategories({
|
||||
kind: 'movie',
|
||||
category: '热门',
|
||||
type: '全部',
|
||||
}),
|
||||
getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }),
|
||||
getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }),
|
||||
GetBangumiCalendarData(),
|
||||
]);
|
||||
|
||||
if (moviesData.code === 200) {
|
||||
setHotMovies(moviesData.list);
|
||||
}
|
||||
|
||||
if (tvShowsData.code === 200) {
|
||||
setHotTvShows(tvShowsData.list);
|
||||
}
|
||||
|
||||
if (varietyShowsData.code === 200) {
|
||||
setHotVarietyShows(varietyShowsData.list);
|
||||
}
|
||||
|
||||
setBangumiCalendarData(bangumiCalendarData);
|
||||
} catch (error) {
|
||||
console.error('获取推荐数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecommendData();
|
||||
}, []);
|
||||
|
||||
// 处理收藏数据更新的函数
|
||||
const updateFavoriteItems = async (allFavorites: Record<string, any>) => {
|
||||
const allPlayRecords = await getAllPlayRecords();
|
||||
|
||||
// 根据保存时间排序(从近到远)
|
||||
const sorted = Object.entries(allFavorites)
|
||||
.sort(([, a], [, b]) => b.save_time - a.save_time)
|
||||
.map(([key, fav]) => {
|
||||
const plusIndex = key.indexOf('+');
|
||||
const source = key.slice(0, plusIndex);
|
||||
const id = key.slice(plusIndex + 1);
|
||||
|
||||
// 查找对应的播放记录,获取当前集数
|
||||
const playRecord = allPlayRecords[key];
|
||||
const currentEpisode = playRecord?.index;
|
||||
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
title: fav.title,
|
||||
year: fav.year,
|
||||
poster: fav.cover,
|
||||
episodes: fav.total_episodes,
|
||||
source_name: fav.source_name,
|
||||
currentEpisode,
|
||||
search_title: fav?.search_title,
|
||||
} as FavoriteItem;
|
||||
});
|
||||
setFavoriteItems(sorted);
|
||||
};
|
||||
|
||||
// 当切换到收藏夹时加载收藏数据
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'favorites') return;
|
||||
|
||||
const loadFavorites = async () => {
|
||||
const allFavorites = await getAllFavorites();
|
||||
await updateFavoriteItems(allFavorites);
|
||||
};
|
||||
|
||||
loadFavorites();
|
||||
|
||||
// 监听收藏更新事件
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'favoritesUpdated',
|
||||
(newFavorites: Record<string, any>) => {
|
||||
updateFavoriteItems(newFavorites);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [activeTab]);
|
||||
|
||||
const handleCloseAnnouncement = (announcement: string) => {
|
||||
setShowAnnouncement(false);
|
||||
localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 顶部 Tab 切换 */}
|
||||
<div className='mb-8 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
options={[
|
||||
{ label: '首页', value: 'home' },
|
||||
{ label: '收藏夹', value: 'favorites' },
|
||||
]}
|
||||
active={activeTab}
|
||||
onChange={(value) => setActiveTab(value as 'home' | 'favorites')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{activeTab === 'favorites' ? (
|
||||
// 收藏夹视图
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
我的收藏
|
||||
</h2>
|
||||
{favoriteItems.length > 0 && (
|
||||
<button
|
||||
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
onClick={async () => {
|
||||
await clearAllFavorites();
|
||||
setFavoriteItems([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
|
||||
{favoriteItems.map((item) => (
|
||||
<div key={item.id + item.source} className='w-full'>
|
||||
<VideoCard
|
||||
query={item.search_title}
|
||||
{...item}
|
||||
from='favorite'
|
||||
type={item.episodes > 1 ? 'tv' : ''}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{favoriteItems.length === 0 && (
|
||||
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||
暂无收藏内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
// 首页视图
|
||||
<>
|
||||
{/* 继续观看 */}
|
||||
<ContinueWatching />
|
||||
|
||||
{/* 热门电影 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门电影
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=movie'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotMovies.map((movie, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={movie.title}
|
||||
poster={movie.poster}
|
||||
douban_id={Number(movie.id)}
|
||||
rate={movie.rate}
|
||||
year={movie.year}
|
||||
type='movie'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热门剧集 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门剧集
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=tv'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotTvShows.map((show, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
douban_id={Number(show.id)}
|
||||
rate={show.rate}
|
||||
year={show.year}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 每日新番放送 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
新番放送
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=anime'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 展示当前日期的番剧
|
||||
(() => {
|
||||
// 获取当前日期对应的星期
|
||||
const today = new Date();
|
||||
const weekdays = [
|
||||
'Sun',
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
];
|
||||
const currentWeekday = weekdays[today.getDay()];
|
||||
|
||||
// 找到当前星期对应的番剧数据
|
||||
const todayAnimes =
|
||||
bangumiCalendarData.find(
|
||||
(item) => item.weekday.en === currentWeekday
|
||||
)?.items || [];
|
||||
|
||||
return todayAnimes.map((anime, index) => (
|
||||
<div
|
||||
key={`${anime.id}-${index}`}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={anime.name_cn || anime.name}
|
||||
poster={
|
||||
anime.images.large ||
|
||||
anime.images.common ||
|
||||
anime.images.medium ||
|
||||
anime.images.small ||
|
||||
anime.images.grid
|
||||
}
|
||||
douban_id={anime.id}
|
||||
rate={anime.rating?.score?.toString() || ''}
|
||||
year={anime.air_date?.split('-')?.[0] || ''}
|
||||
isBangumi={true}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热门综艺 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门综艺
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=show'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotVarietyShows.map((show, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
douban_id={Number(show.id)}
|
||||
rate={show.rate}
|
||||
year={show.year}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{announcement && showAnnouncement && (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${
|
||||
showAnnouncement ? '' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl'>
|
||||
<div className='flex justify-between items-start mb-4'>
|
||||
<h3 className='text-2xl font-bold tracking-tight text-gray-800 dark:text-white border-b border-green-500 pb-1'>
|
||||
提示
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleCloseAnnouncement(announcement)}
|
||||
className='text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white transition-colors'
|
||||
aria-label='关闭'
|
||||
></button>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<div className='relative overflow-hidden rounded-lg mb-4 bg-green-50 dark:bg-green-900/20'>
|
||||
<div className='absolute inset-y-0 left-0 w-1.5 bg-green-500 dark:bg-green-400'></div>
|
||||
<p className='ml-4 text-gray-600 dark:text-gray-300 leading-relaxed'>
|
||||
{announcement}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCloseAnnouncement(announcement)}
|
||||
className='w-full rounded-lg bg-gradient-to-r from-green-600 to-green-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-green-700 hover:to-green-800 dark:from-green-600 dark:to-green-700 dark:hover:from-green-700 dark:hover:to-green-800 transition-all duration-300 transform hover:-translate-y-0.5'
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense>
|
||||
<HomeClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
2108
src/app/play/page.tsx
Normal file
2108
src/app/play/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
468
src/app/search/page.tsx
Normal file
468
src/app/search/page.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any */
|
||||
'use client';
|
||||
|
||||
import { ChevronUp, Search, X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
addSearchHistory,
|
||||
clearSearchHistory,
|
||||
deleteSearchHistory,
|
||||
getSearchHistory,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
function SearchPageClient() {
|
||||
// 搜索历史
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
// 返回顶部按钮显示状态
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
// 获取默认聚合设置:只读取用户本地设置,默认为 true
|
||||
const getDefaultAggregate = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const userSetting = localStorage.getItem('defaultAggregateSearch');
|
||||
if (userSetting !== null) {
|
||||
return JSON.parse(userSetting);
|
||||
}
|
||||
}
|
||||
return true; // 默认启用聚合
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => {
|
||||
return getDefaultAggregate() ? 'agg' : 'all';
|
||||
});
|
||||
|
||||
// 聚合后的结果(按标题和年份分组)
|
||||
const aggregatedResults = useMemo(() => {
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
searchResults.forEach((item) => {
|
||||
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
|
||||
const key = `${item.title.replaceAll(' ', '')}-${
|
||||
item.year || 'unknown'
|
||||
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(item);
|
||||
map.set(key, arr);
|
||||
});
|
||||
return Array.from(map.entries()).sort((a, b) => {
|
||||
// 优先排序:标题与搜索词完全一致的排在前面
|
||||
const aExactMatch = a[1][0].title
|
||||
.replaceAll(' ', '')
|
||||
.includes(searchQuery.trim().replaceAll(' ', ''));
|
||||
const bExactMatch = b[1][0].title
|
||||
.replaceAll(' ', '')
|
||||
.includes(searchQuery.trim().replaceAll(' ', ''));
|
||||
|
||||
if (aExactMatch && !bExactMatch) return -1;
|
||||
if (!aExactMatch && bExactMatch) return 1;
|
||||
|
||||
// 年份排序
|
||||
if (a[1][0].year === b[1][0].year) {
|
||||
return a[0].localeCompare(b[0]);
|
||||
} else {
|
||||
// 处理 unknown 的情况
|
||||
const aYear = a[1][0].year;
|
||||
const bYear = b[1][0].year;
|
||||
|
||||
if (aYear === 'unknown' && bYear === 'unknown') {
|
||||
return 0;
|
||||
} else if (aYear === 'unknown') {
|
||||
return 1; // a 排在后面
|
||||
} else if (bYear === 'unknown') {
|
||||
return -1; // b 排在后面
|
||||
} else {
|
||||
// 都是数字年份,按数字大小排序(大的在前面)
|
||||
return aYear > bYear ? -1 : 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [searchResults]);
|
||||
|
||||
useEffect(() => {
|
||||
// 无搜索参数时聚焦搜索框
|
||||
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
||||
|
||||
// 初始加载搜索历史
|
||||
getSearchHistory().then(setSearchHistory);
|
||||
|
||||
// 监听搜索历史更新事件
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'searchHistoryUpdated',
|
||||
(newHistory: string[]) => {
|
||||
setSearchHistory(newHistory);
|
||||
}
|
||||
);
|
||||
|
||||
// 获取滚动位置的函数 - 专门针对 body 滚动
|
||||
const getScrollTop = () => {
|
||||
return document.body.scrollTop || 0;
|
||||
};
|
||||
|
||||
// 使用 requestAnimationFrame 持续检测滚动位置
|
||||
let isRunning = false;
|
||||
const checkScrollPosition = () => {
|
||||
if (!isRunning) return;
|
||||
|
||||
const scrollTop = getScrollTop();
|
||||
const shouldShow = scrollTop > 300;
|
||||
setShowBackToTop(shouldShow);
|
||||
|
||||
requestAnimationFrame(checkScrollPosition);
|
||||
};
|
||||
|
||||
// 启动持续检测
|
||||
isRunning = true;
|
||||
checkScrollPosition();
|
||||
|
||||
// 监听 body 元素的滚动事件
|
||||
const handleScroll = () => {
|
||||
const scrollTop = getScrollTop();
|
||||
setShowBackToTop(scrollTop > 300);
|
||||
};
|
||||
|
||||
document.body.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
isRunning = false; // 停止 requestAnimationFrame 循环
|
||||
|
||||
// 移除 body 滚动事件监听器
|
||||
document.body.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 当搜索参数变化时更新搜索状态
|
||||
const query = searchParams.get('q');
|
||||
if (query) {
|
||||
setSearchQuery(query);
|
||||
fetchSearchResults(query);
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||
addSearchHistory(query);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const fetchSearchResults = async (query: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||
);
|
||||
const data = await response.json();
|
||||
let results = data.results;
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
!(window as any).RUNTIME_CONFIG?.DISABLE_YELLOW_FILTER
|
||||
) {
|
||||
results = results.filter((result: SearchResult) => {
|
||||
const typeName = result.type_name || '';
|
||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||
});
|
||||
}
|
||||
setSearchResults(
|
||||
results.sort((a: SearchResult, b: SearchResult) => {
|
||||
// 优先排序:标题与搜索词完全一致的排在前面
|
||||
const aExactMatch = a.title === query.trim();
|
||||
const bExactMatch = b.title === query.trim();
|
||||
|
||||
if (aExactMatch && !bExactMatch) return -1;
|
||||
if (!aExactMatch && bExactMatch) return 1;
|
||||
|
||||
// 如果都匹配或都不匹配,则按原来的逻辑排序
|
||||
if (a.year === b.year) {
|
||||
return a.title.localeCompare(b.title);
|
||||
} else {
|
||||
// 处理 unknown 的情况
|
||||
if (a.year === 'unknown' && b.year === 'unknown') {
|
||||
return 0;
|
||||
} else if (a.year === 'unknown') {
|
||||
return 1; // a 排在后面
|
||||
} else if (b.year === 'unknown') {
|
||||
return -1; // b 排在后面
|
||||
} else {
|
||||
// 都是数字年份,按数字大小排序(大的在前面)
|
||||
return parseInt(a.year) > parseInt(b.year) ? -1 : 1;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
setShowResults(true);
|
||||
} catch (error) {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 输入框内容变化时触发,显示搜索建议
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
|
||||
if (value.trim()) {
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索框聚焦时触发,显示搜索建议
|
||||
const handleInputFocus = () => {
|
||||
if (searchQuery.trim()) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索表单提交时触发,处理搜索逻辑
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) return;
|
||||
|
||||
// 回显搜索框
|
||||
setSearchQuery(trimmed);
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
setShowSuggestions(false);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
// 直接发请求
|
||||
fetchSearchResults(trimmed);
|
||||
|
||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||
addSearchHistory(trimmed);
|
||||
};
|
||||
|
||||
const handleSuggestionSelect = (suggestion: string) => {
|
||||
setSearchQuery(suggestion);
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 自动执行搜索
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
|
||||
fetchSearchResults(suggestion);
|
||||
addSearchHistory(suggestion);
|
||||
};
|
||||
|
||||
// 返回顶部功能
|
||||
const scrollToTop = () => {
|
||||
try {
|
||||
// 根据调试结果,真正的滚动容器是 document.body
|
||||
document.body.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} catch (error) {
|
||||
// 如果平滑滚动完全失败,使用立即滚动
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/search'>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
|
||||
{/* 搜索框 */}
|
||||
<div className='mb-8'>
|
||||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' />
|
||||
<input
|
||||
id='searchInput'
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder='搜索电影、电视剧...'
|
||||
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-4 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
|
||||
/>
|
||||
|
||||
{/* 搜索建议 */}
|
||||
<SearchSuggestions
|
||||
query={searchQuery}
|
||||
isVisible={showSuggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
onClose={() => setShowSuggestions(false)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果或搜索历史 */}
|
||||
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||
{isLoading ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
) : showResults ? (
|
||||
<section className='mb-12'>
|
||||
{/* 标题 + 聚合开关 */}
|
||||
<div className='mb-8 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
搜索结果
|
||||
</h2>
|
||||
{/* 聚合开关 */}
|
||||
<label className='flex items-center gap-2 cursor-pointer select-none'>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
||||
聚合
|
||||
</span>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={viewMode === 'agg'}
|
||||
onChange={() =>
|
||||
setViewMode(viewMode === 'agg' ? 'all' : 'agg')
|
||||
}
|
||||
/>
|
||||
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
key={`search-results-${viewMode}`}
|
||||
className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
|
||||
>
|
||||
{viewMode === 'agg'
|
||||
? aggregatedResults.map(([mapKey, group]) => {
|
||||
return (
|
||||
<div key={`agg-${mapKey}`} className='w-full'>
|
||||
<VideoCard
|
||||
from='search'
|
||||
items={group}
|
||||
query={
|
||||
searchQuery.trim() !== group[0].title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: searchResults.map((item) => (
|
||||
<div
|
||||
key={`all-${item.source}-${item.id}`}
|
||||
className='w-full'
|
||||
>
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
episodes={item.episodes.length}
|
||||
source={item.source}
|
||||
source_name={item.source_name}
|
||||
douban_id={item.douban_id}
|
||||
query={
|
||||
searchQuery.trim() !== item.title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
year={item.year}
|
||||
from='search'
|
||||
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : searchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left dark:text-gray-200'>
|
||||
搜索历史
|
||||
{searchHistory.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearSearchHistory(); // 事件监听会自动更新界面
|
||||
}}
|
||||
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</h2>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{searchHistory.map((item) => (
|
||||
<div key={item} className='relative group'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery(item);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}`
|
||||
);
|
||||
}}
|
||||
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
aria-label='删除搜索历史'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
deleteSearchHistory(item); // 事件监听会自动更新界面
|
||||
}}
|
||||
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
|
||||
>
|
||||
<X className='w-3 h-3' />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 返回顶部悬浮按钮 */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-20 md:bottom-6 right-6 z-[500] w-12 h-12 bg-green-500/90 hover:bg-green-500 text-white rounded-full shadow-lg backdrop-blur-sm transition-all duration-300 ease-in-out flex items-center justify-center group ${
|
||||
showBackToTop
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||
}`}
|
||||
aria-label='返回顶部'
|
||||
>
|
||||
<ChevronUp className='w-6 h-6 transition-transform group-hover:scale-110' />
|
||||
</button>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
97
src/app/warning/page.tsx
Normal file
97
src/app/warning/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '安全警告 - MoonTV',
|
||||
description: '站点安全配置警告',
|
||||
};
|
||||
|
||||
export default function WarningPage() {
|
||||
return (
|
||||
<div className='min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4'>
|
||||
<div className='max-w-2xl w-full bg-white rounded-2xl shadow-2xl p-4 sm:p-8 border border-red-200'>
|
||||
{/* 警告图标 */}
|
||||
<div className='flex justify-center mb-4 sm:mb-6'>
|
||||
<div className='w-16 h-16 sm:w-20 sm:h-20 bg-red-100 rounded-full flex items-center justify-center'>
|
||||
<svg
|
||||
className='w-10 h-10 sm:w-12 sm:h-12 text-red-600'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className='text-center mb-6 sm:mb-8'>
|
||||
<h1 className='text-2xl sm:text-3xl font-bold text-gray-900 mb-2'>
|
||||
安全合规配置警告
|
||||
</h1>
|
||||
<div className='w-12 sm:w-16 h-1 bg-red-500 mx-auto rounded-full'></div>
|
||||
</div>
|
||||
|
||||
{/* 警告内容 */}
|
||||
<div className='space-y-4 sm:space-y-6 text-gray-700'>
|
||||
<div className='bg-red-50 border-l-4 border-red-500 p-3 sm:p-4 rounded-r-lg'>
|
||||
<p className='text-base sm:text-lg font-semibold text-red-800 mb-2'>
|
||||
⚠️ 安全风险提示
|
||||
</p>
|
||||
<p className='text-sm sm:text-base text-red-700'>
|
||||
检测到您的站点未配置访问控制,存在潜在的安全风险和法律合规问题。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<h2 className='text-lg sm:text-xl font-semibold text-gray-900'>
|
||||
主要风险
|
||||
</h2>
|
||||
<ul className='space-y-2 sm:space-y-3 text-sm sm:text-base text-gray-600'>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>未经授权的访问可能导致内容被恶意传播</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>服务器资源可能被滥用,影响正常服务</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>可能收到相关权利方的法律通知</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>服务提供商可能因合规问题终止服务</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3 sm:p-4'>
|
||||
<h3 className='text-base sm:text-lg font-semibold text-yellow-800 mb-2'>
|
||||
🔒 安全配置建议
|
||||
</h3>
|
||||
<p className='text-sm sm:text-base text-yellow-700'>
|
||||
请立即配置{' '}
|
||||
<code className='bg-yellow-100 px-1.5 py-0.5 rounded text-xs sm:text-sm font-mono'>
|
||||
PASSWORD
|
||||
</code>{' '}
|
||||
环境变量以启用访问控制。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部装饰 */}
|
||||
<div className='mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-gray-200'>
|
||||
<div className='text-center text-xs sm:text-sm text-gray-500'>
|
||||
<p>为确保系统安全性和合规性,请及时完成安全配置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user