mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-14 20: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>
|
||||
);
|
||||
}
|
||||
13
src/components/BackButton.tsx
Normal file
13
src/components/BackButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export function BackButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||
aria-label='Back'
|
||||
>
|
||||
<ArrowLeft className='w-full h-full' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
103
src/components/CapsuleSwitch.tsx
Normal file
103
src/components/CapsuleSwitch.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface CapsuleSwitchProps {
|
||||
options: { label: string; value: string }[];
|
||||
active: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
||||
options,
|
||||
active,
|
||||
onChange,
|
||||
className,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [indicatorStyle, setIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
const activeIndex = options.findIndex((opt) => opt.value === active);
|
||||
|
||||
// 更新指示器位置
|
||||
const updateIndicatorPosition = () => {
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
buttonRefs.current[activeIndex] &&
|
||||
containerRef.current
|
||||
) {
|
||||
const button = buttonRefs.current[activeIndex];
|
||||
const container = containerRef.current;
|
||||
if (button && container) {
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (buttonRect.width > 0) {
|
||||
setIndicatorStyle({
|
||||
left: buttonRect.left - containerRect.left,
|
||||
width: buttonRect.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时立即计算初始位置
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(updateIndicatorPosition, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// 监听选中项变化
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(updateIndicatorPosition, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
|
||||
className || ''
|
||||
}`}
|
||||
>
|
||||
{/* 滑动的白色背景指示器 */}
|
||||
{indicatorStyle.width > 0 && (
|
||||
<div
|
||||
className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
|
||||
style={{
|
||||
left: `${indicatorStyle.left}px`,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.map((opt, index) => {
|
||||
const isActive = active === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`relative z-10 w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer ${
|
||||
isActive
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapsuleSwitch;
|
||||
154
src/components/ContinueWatching.tsx
Normal file
154
src/components/ContinueWatching.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/* eslint-disable no-console */
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlayRecord } from '@/lib/db.client';
|
||||
import {
|
||||
clearAllPlayRecords,
|
||||
getAllPlayRecords,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
interface ContinueWatchingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
const [playRecords, setPlayRecords] = useState<
|
||||
(PlayRecord & { key: string })[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 处理播放记录数据更新的函数
|
||||
const updatePlayRecords = (allRecords: Record<string, PlayRecord>) => {
|
||||
// 将记录转换为数组并根据 save_time 由近到远排序
|
||||
const recordsArray = Object.entries(allRecords).map(([key, record]) => ({
|
||||
...record,
|
||||
key,
|
||||
}));
|
||||
|
||||
// 按 save_time 降序排序(最新的在前面)
|
||||
const sortedRecords = recordsArray.sort(
|
||||
(a, b) => b.save_time - a.save_time
|
||||
);
|
||||
|
||||
setPlayRecords(sortedRecords);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlayRecords = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 从缓存或API获取所有播放记录
|
||||
const allRecords = await getAllPlayRecords();
|
||||
updatePlayRecords(allRecords);
|
||||
} catch (error) {
|
||||
console.error('获取播放记录失败:', error);
|
||||
setPlayRecords([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlayRecords();
|
||||
|
||||
// 监听播放记录更新事件
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'playRecordsUpdated',
|
||||
(newRecords: Record<string, PlayRecord>) => {
|
||||
updatePlayRecords(newRecords);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// 如果没有播放记录,则不渲染组件
|
||||
if (!loading && playRecords.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算播放进度百分比
|
||||
const getProgress = (record: PlayRecord) => {
|
||||
if (record.total_time === 0) return 0;
|
||||
return (record.play_time / record.total_time) * 100;
|
||||
};
|
||||
|
||||
// 从 key 中解析 source 和 id
|
||||
const parseKey = (key: string) => {
|
||||
const [source, id] = key.split('+');
|
||||
return { source, id };
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`mb-8 ${className || ''}`}>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
继续观看
|
||||
</h2>
|
||||
{!loading && playRecords.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 clearAllPlayRecords();
|
||||
setPlayRecords([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 6 }).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 className='mt-1 h-3 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
return (
|
||||
<div
|
||||
key={record.key}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
poster={record.cover}
|
||||
year={record.year}
|
||||
source={source}
|
||||
source_name={record.source_name}
|
||||
progress={getProgress(record)}
|
||||
episodes={record.total_episodes}
|
||||
currentEpisode={record.index}
|
||||
query={record.search_title}
|
||||
from='playrecord'
|
||||
onDelete={() =>
|
||||
setPlayRecords((prev) =>
|
||||
prev.filter((r) => r.key !== record.key)
|
||||
)
|
||||
}
|
||||
type={record.total_episodes > 1 ? 'tv' : ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
21
src/components/DoubanCardSkeleton.tsx
Normal file
21
src/components/DoubanCardSkeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
|
||||
const DoubanCardSkeleton = () => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
|
||||
{/* 信息层骨架 */}
|
||||
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='h-4 w-24 sm:w-32 bg-gray-200 rounded animate-pulse mb-2'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanCardSkeleton;
|
||||
318
src/components/DoubanCustomSelector.tsx
Normal file
318
src/components/DoubanCustomSelector.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface CustomCategory {
|
||||
name: string;
|
||||
type: 'movie' | 'tv';
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface DoubanCustomSelectorProps {
|
||||
customCategories: CustomCategory[];
|
||||
primarySelection?: string;
|
||||
secondarySelection?: string;
|
||||
onPrimaryChange: (value: string) => void;
|
||||
onSecondaryChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
|
||||
customCategories,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
onPrimaryChange,
|
||||
onSecondaryChange,
|
||||
}) => {
|
||||
// 为不同的选择器创建独立的refs和状态
|
||||
const primaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
const secondaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
// 二级选择器滚动容器的ref
|
||||
const secondaryScrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 根据 customCategories 生成一级选择器选项(按 type 分组,电影优先)
|
||||
const primaryOptions = React.useMemo(() => {
|
||||
const types = Array.from(new Set(customCategories.map((cat) => cat.type)));
|
||||
// 确保电影类型排在前面
|
||||
const sortedTypes = types.sort((a, b) => {
|
||||
if (a === 'movie' && b !== 'movie') return -1;
|
||||
if (a !== 'movie' && b === 'movie') return 1;
|
||||
return 0;
|
||||
});
|
||||
return sortedTypes.map((type) => ({
|
||||
label: type === 'movie' ? '电影' : '剧集',
|
||||
value: type,
|
||||
}));
|
||||
}, [customCategories]);
|
||||
|
||||
// 根据选中的一级选项生成二级选择器选项
|
||||
const secondaryOptions = React.useMemo(() => {
|
||||
if (!primarySelection) return [];
|
||||
return customCategories
|
||||
.filter((cat) => cat.type === primarySelection)
|
||||
.map((cat) => ({
|
||||
label: cat.name || cat.query,
|
||||
value: cat.query,
|
||||
}));
|
||||
}, [customCategories, primarySelection]);
|
||||
|
||||
// 处理二级选择器的鼠标滚轮事件(原生 DOM 事件)
|
||||
const handleSecondaryWheel = React.useCallback((e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const container = secondaryScrollContainerRef.current;
|
||||
if (container) {
|
||||
const scrollAmount = e.deltaY * 2;
|
||||
container.scrollLeft += scrollAmount;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 添加二级选择器的鼠标滚轮事件监听器
|
||||
useEffect(() => {
|
||||
const scrollContainer = secondaryScrollContainerRef.current;
|
||||
const capsuleContainer = secondaryContainerRef.current;
|
||||
|
||||
if (scrollContainer && capsuleContainer) {
|
||||
// 同时监听滚动容器和胶囊容器的滚轮事件
|
||||
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
|
||||
passive: false,
|
||||
});
|
||||
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
|
||||
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
|
||||
};
|
||||
}
|
||||
}, [handleSecondaryWheel]);
|
||||
|
||||
// 当二级选项变化时重新添加事件监听器
|
||||
useEffect(() => {
|
||||
const scrollContainer = secondaryScrollContainerRef.current;
|
||||
const capsuleContainer = secondaryContainerRef.current;
|
||||
|
||||
if (scrollContainer && capsuleContainer && secondaryOptions.length > 0) {
|
||||
// 重新添加事件监听器
|
||||
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
|
||||
passive: false,
|
||||
});
|
||||
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
|
||||
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
|
||||
};
|
||||
}
|
||||
}, [handleSecondaryWheel, secondaryOptions]);
|
||||
|
||||
// 更新指示器位置的通用函数
|
||||
const updateIndicatorPosition = (
|
||||
activeIndex: number,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
|
||||
setIndicatorStyle: React.Dispatch<
|
||||
React.SetStateAction<{ left: number; width: number }>
|
||||
>
|
||||
) => {
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
buttonRefs.current[activeIndex] &&
|
||||
containerRef.current
|
||||
) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const button = buttonRefs.current[activeIndex];
|
||||
const container = containerRef.current;
|
||||
if (button && container) {
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (buttonRect.width > 0) {
|
||||
setIndicatorStyle({
|
||||
left: buttonRect.left - containerRect.left,
|
||||
width: buttonRect.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时立即计算初始位置
|
||||
useEffect(() => {
|
||||
// 主选择器初始位置
|
||||
if (primaryOptions.length > 0) {
|
||||
const activeIndex = primaryOptions.findIndex(
|
||||
(opt) => opt.value === (primarySelection || primaryOptions[0].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
}
|
||||
|
||||
// 副选择器初始位置
|
||||
if (secondaryOptions.length > 0) {
|
||||
const activeIndex = secondaryOptions.findIndex(
|
||||
(opt) => opt.value === (secondarySelection || secondaryOptions[0].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
secondaryContainerRef,
|
||||
secondaryButtonRefs,
|
||||
setSecondaryIndicatorStyle
|
||||
);
|
||||
}
|
||||
}, [primaryOptions, secondaryOptions]); // 当选项变化时重新计算
|
||||
|
||||
// 监听主选择器变化
|
||||
useEffect(() => {
|
||||
if (primaryOptions.length > 0) {
|
||||
const activeIndex = primaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
}
|
||||
}, [primarySelection, primaryOptions]);
|
||||
|
||||
// 监听副选择器变化
|
||||
useEffect(() => {
|
||||
if (secondaryOptions.length > 0) {
|
||||
const activeIndex = secondaryOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
secondaryContainerRef,
|
||||
secondaryButtonRefs,
|
||||
setSecondaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
}
|
||||
}, [secondarySelection, secondaryOptions]);
|
||||
|
||||
// 渲染胶囊式选择器
|
||||
const renderCapsuleSelector = (
|
||||
options: { label: string; value: string }[],
|
||||
activeValue: string | undefined,
|
||||
onChange: (value: string) => void,
|
||||
isPrimary = false
|
||||
) => {
|
||||
const containerRef = isPrimary
|
||||
? primaryContainerRef
|
||||
: secondaryContainerRef;
|
||||
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
|
||||
const indicatorStyle = isPrimary
|
||||
? primaryIndicatorStyle
|
||||
: secondaryIndicatorStyle;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
|
||||
>
|
||||
{/* 滑动的白色背景指示器 */}
|
||||
{indicatorStyle.width > 0 && (
|
||||
<div
|
||||
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
|
||||
style={{
|
||||
left: `${indicatorStyle.left}px`,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.map((option, index) => {
|
||||
const isActive = activeValue === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
|
||||
isActive
|
||||
? 'text-gray-900 dark:text-gray-100 cursor-default'
|
||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果没有自定义分类,则不渲染任何内容
|
||||
if (!customCategories || customCategories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* 两级选择器包装 */}
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
类型
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
primaryOptions,
|
||||
primarySelection || primaryOptions[0]?.value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 */}
|
||||
{secondaryOptions.length > 0 && (
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
片单
|
||||
</span>
|
||||
<div ref={secondaryScrollContainerRef} className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
secondaryOptions,
|
||||
secondarySelection || secondaryOptions[0]?.value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanCustomSelector;
|
||||
567
src/components/DoubanSelector.tsx
Normal file
567
src/components/DoubanSelector.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import MultiLevelSelector from './MultiLevelSelector';
|
||||
import WeekdaySelector from './WeekdaySelector';
|
||||
|
||||
interface SelectorOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DoubanSelectorProps {
|
||||
type: 'movie' | 'tv' | 'show' | 'anime';
|
||||
primarySelection?: string;
|
||||
secondarySelection?: string;
|
||||
onPrimaryChange: (value: string) => void;
|
||||
onSecondaryChange: (value: string) => void;
|
||||
onMultiLevelChange?: (values: Record<string, string>) => void;
|
||||
onWeekdayChange: (weekday: string) => void;
|
||||
}
|
||||
|
||||
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
onPrimaryChange,
|
||||
onSecondaryChange,
|
||||
onMultiLevelChange,
|
||||
onWeekdayChange,
|
||||
}) => {
|
||||
// 为不同的选择器创建独立的refs和状态
|
||||
const primaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
const secondaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
// 电影的一级选择器选项
|
||||
const moviePrimaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '热门电影', value: '热门' },
|
||||
{ label: '最新电影', value: '最新' },
|
||||
{ label: '豆瓣高分', value: '豆瓣高分' },
|
||||
{ label: '冷门佳片', value: '冷门佳片' },
|
||||
];
|
||||
|
||||
// 电影的二级选择器选项
|
||||
const movieSecondaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '华语', value: '华语' },
|
||||
{ label: '欧美', value: '欧美' },
|
||||
{ label: '韩国', value: '韩国' },
|
||||
{ label: '日本', value: '日本' },
|
||||
];
|
||||
|
||||
// 电视剧一级选择器选项
|
||||
const tvPrimaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '最近热门', value: '最近热门' },
|
||||
];
|
||||
|
||||
// 电视剧二级选择器选项
|
||||
const tvSecondaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: 'tv' },
|
||||
{ label: '国产', value: 'tv_domestic' },
|
||||
{ label: '欧美', value: 'tv_american' },
|
||||
{ label: '日本', value: 'tv_japanese' },
|
||||
{ label: '韩国', value: 'tv_korean' },
|
||||
{ label: '动漫', value: 'tv_animation' },
|
||||
{ label: '纪录片', value: 'tv_documentary' },
|
||||
];
|
||||
|
||||
// 综艺一级选择器选项
|
||||
const showPrimaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '最近热门', value: '最近热门' },
|
||||
];
|
||||
|
||||
// 综艺二级选择器选项
|
||||
const showSecondaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: 'show' },
|
||||
{ label: '国内', value: 'show_domestic' },
|
||||
{ label: '国外', value: 'show_foreign' },
|
||||
];
|
||||
|
||||
// 动漫一级选择器选项
|
||||
const animePrimaryOptions: SelectorOption[] = [
|
||||
{ label: '每日放送', value: '每日放送' },
|
||||
{ label: '番剧', value: '番剧' },
|
||||
{ label: '剧场版', value: '剧场版' },
|
||||
];
|
||||
|
||||
// 处理多级选择器变化
|
||||
const handleMultiLevelChange = (values: Record<string, string>) => {
|
||||
onMultiLevelChange?.(values);
|
||||
};
|
||||
|
||||
// 更新指示器位置的通用函数
|
||||
const updateIndicatorPosition = (
|
||||
activeIndex: number,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
|
||||
setIndicatorStyle: React.Dispatch<
|
||||
React.SetStateAction<{ left: number; width: number }>
|
||||
>
|
||||
) => {
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
buttonRefs.current[activeIndex] &&
|
||||
containerRef.current
|
||||
) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const button = buttonRefs.current[activeIndex];
|
||||
const container = containerRef.current;
|
||||
if (button && container) {
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (buttonRect.width > 0) {
|
||||
setIndicatorStyle({
|
||||
left: buttonRect.left - containerRect.left,
|
||||
width: buttonRect.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时立即计算初始位置
|
||||
useEffect(() => {
|
||||
// 主选择器初始位置
|
||||
if (type === 'movie') {
|
||||
const activeIndex = moviePrimaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (primarySelection || moviePrimaryOptions[0].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
} else if (type === 'tv') {
|
||||
const activeIndex = tvPrimaryOptions.findIndex(
|
||||
(opt) => opt.value === (primarySelection || tvPrimaryOptions[1].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
} else if (type === 'anime') {
|
||||
const activeIndex = animePrimaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (primarySelection || animePrimaryOptions[0].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
} else if (type === 'show') {
|
||||
const activeIndex = showPrimaryOptions.findIndex(
|
||||
(opt) => opt.value === (primarySelection || showPrimaryOptions[1].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
}
|
||||
|
||||
// 副选择器初始位置
|
||||
let secondaryActiveIndex = -1;
|
||||
if (type === 'movie') {
|
||||
secondaryActiveIndex = movieSecondaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
|
||||
);
|
||||
} else if (type === 'tv') {
|
||||
secondaryActiveIndex = tvSecondaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (secondarySelection || tvSecondaryOptions[0].value)
|
||||
);
|
||||
} else if (type === 'show') {
|
||||
secondaryActiveIndex = showSecondaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (secondarySelection || showSecondaryOptions[0].value)
|
||||
);
|
||||
}
|
||||
|
||||
if (secondaryActiveIndex >= 0) {
|
||||
updateIndicatorPosition(
|
||||
secondaryActiveIndex,
|
||||
secondaryContainerRef,
|
||||
secondaryButtonRefs,
|
||||
setSecondaryIndicatorStyle
|
||||
);
|
||||
}
|
||||
}, [type]); // 只在type变化时重新计算
|
||||
|
||||
// 监听主选择器变化
|
||||
useEffect(() => {
|
||||
if (type === 'movie') {
|
||||
const activeIndex = moviePrimaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
} else if (type === 'tv') {
|
||||
const activeIndex = tvPrimaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
} else if (type === 'anime') {
|
||||
const activeIndex = animePrimaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
} else if (type === 'show') {
|
||||
const activeIndex = showPrimaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
}
|
||||
}, [primarySelection]);
|
||||
|
||||
// 监听副选择器变化
|
||||
useEffect(() => {
|
||||
let activeIndex = -1;
|
||||
let options: SelectorOption[] = [];
|
||||
|
||||
if (type === 'movie') {
|
||||
activeIndex = movieSecondaryOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
options = movieSecondaryOptions;
|
||||
} else if (type === 'tv') {
|
||||
activeIndex = tvSecondaryOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
options = tvSecondaryOptions;
|
||||
} else if (type === 'show') {
|
||||
activeIndex = showSecondaryOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
options = showSecondaryOptions;
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
secondaryContainerRef,
|
||||
secondaryButtonRefs,
|
||||
setSecondaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
}
|
||||
}, [secondarySelection]);
|
||||
|
||||
// 渲染胶囊式选择器
|
||||
const renderCapsuleSelector = (
|
||||
options: SelectorOption[],
|
||||
activeValue: string | undefined,
|
||||
onChange: (value: string) => void,
|
||||
isPrimary = false
|
||||
) => {
|
||||
const containerRef = isPrimary
|
||||
? primaryContainerRef
|
||||
: secondaryContainerRef;
|
||||
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
|
||||
const indicatorStyle = isPrimary
|
||||
? primaryIndicatorStyle
|
||||
: secondaryIndicatorStyle;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
|
||||
>
|
||||
{/* 滑动的白色背景指示器 */}
|
||||
{indicatorStyle.width > 0 && (
|
||||
<div
|
||||
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
|
||||
style={{
|
||||
left: `${indicatorStyle.left}px`,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.map((option, index) => {
|
||||
const isActive = activeValue === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
|
||||
isActive
|
||||
? 'text-gray-900 dark:text-gray-100 cursor-default'
|
||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* 电影类型 - 显示两级选择器 */}
|
||||
{type === 'movie' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
moviePrimaryOptions,
|
||||
primarySelection || moviePrimaryOptions[0].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 - 只在非"全部"时显示 */}
|
||||
{primarySelection !== '全部' ? (
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
地区
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
movieSecondaryOptions,
|
||||
secondarySelection || movieSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
筛选
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 电视剧类型 - 显示两级选择器 */}
|
||||
{type === 'tv' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
tvPrimaryOptions,
|
||||
primarySelection || tvPrimaryOptions[1].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
|
||||
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
类型
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
tvSecondaryOptions,
|
||||
secondarySelection || tvSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
筛选
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 动漫类型 - 显示一级选择器和多级选择器 */}
|
||||
{type === 'anime' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
animePrimaryOptions,
|
||||
primarySelection || animePrimaryOptions[0].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选部分 - 根据一级选择器显示不同内容 */}
|
||||
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
|
||||
// 每日放送分类下显示星期选择器
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
星期
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 其他分类下显示原有的筛选功能
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
筛选
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{(primarySelection || animePrimaryOptions[0].value) ===
|
||||
'番剧' ? (
|
||||
<MultiLevelSelector
|
||||
key={`anime-tv-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType='anime-tv'
|
||||
/>
|
||||
) : (
|
||||
<MultiLevelSelector
|
||||
key={`anime-movie-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType='anime-movie'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 综艺类型 - 显示两级选择器 */}
|
||||
{type === 'show' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
showPrimaryOptions,
|
||||
primarySelection || showPrimaryOptions[1].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
|
||||
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
类型
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
showSecondaryOptions,
|
||||
secondarySelection || showSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
筛选
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanSelector;
|
||||
640
src/components/EpisodeSelector.tsx
Normal file
640
src/components/EpisodeSelector.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
|
||||
// 定义视频信息类型
|
||||
interface VideoInfo {
|
||||
quality: string;
|
||||
loadSpeed: string;
|
||||
pingTime: number;
|
||||
hasError?: boolean; // 添加错误状态标识
|
||||
}
|
||||
|
||||
interface EpisodeSelectorProps {
|
||||
/** 总集数 */
|
||||
totalEpisodes: number;
|
||||
/** 剧集标题 */
|
||||
episodes_titles: string[];
|
||||
/** 每页显示多少集,默认 50 */
|
||||
episodesPerPage?: number;
|
||||
/** 当前选中的集数(1 开始) */
|
||||
value?: number;
|
||||
/** 用户点击选集后的回调 */
|
||||
onChange?: (episodeNumber: number) => void;
|
||||
/** 换源相关 */
|
||||
onSourceChange?: (source: string, id: string, title: string) => void;
|
||||
currentSource?: string;
|
||||
currentId?: string;
|
||||
videoTitle?: string;
|
||||
videoYear?: string;
|
||||
availableSources?: SearchResult[];
|
||||
sourceSearchLoading?: boolean;
|
||||
sourceSearchError?: string | null;
|
||||
/** 预计算的测速结果,避免重复测速 */
|
||||
precomputedVideoInfo?: Map<string, VideoInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。
|
||||
*/
|
||||
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
totalEpisodes,
|
||||
episodes_titles,
|
||||
episodesPerPage = 50,
|
||||
value = 1,
|
||||
onChange,
|
||||
onSourceChange,
|
||||
currentSource,
|
||||
currentId,
|
||||
videoTitle,
|
||||
availableSources = [],
|
||||
sourceSearchLoading = false,
|
||||
sourceSearchError = null,
|
||||
precomputedVideoInfo,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||
|
||||
// 存储每个源的视频信息
|
||||
const [videoInfoMap, setVideoInfoMap] = useState<Map<string, VideoInfo>>(
|
||||
new Map()
|
||||
);
|
||||
const [attemptedSources, setAttemptedSources] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
// 使用 ref 来避免闭包问题
|
||||
const attemptedSourcesRef = useRef<Set<string>>(new Set());
|
||||
const videoInfoMapRef = useRef<Map<string, VideoInfo>>(new Map());
|
||||
|
||||
// 同步状态到 ref
|
||||
useEffect(() => {
|
||||
attemptedSourcesRef.current = attemptedSources;
|
||||
}, [attemptedSources]);
|
||||
|
||||
useEffect(() => {
|
||||
videoInfoMapRef.current = videoInfoMap;
|
||||
}, [videoInfoMap]);
|
||||
|
||||
// 主要的 tab 状态:'episodes' 或 'sources'
|
||||
// 当只有一集时默认展示 "换源",并隐藏 "选集" 标签
|
||||
const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>(
|
||||
totalEpisodes > 1 ? 'episodes' : 'sources'
|
||||
);
|
||||
|
||||
// 当前分页索引(0 开始)
|
||||
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
||||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||||
|
||||
// 是否倒序显示
|
||||
const [descending, setDescending] = useState<boolean>(false);
|
||||
|
||||
// 根据 descending 状态计算实际显示的分页索引
|
||||
const displayPage = useMemo(() => {
|
||||
if (descending) {
|
||||
return pageCount - 1 - currentPage;
|
||||
}
|
||||
return currentPage;
|
||||
}, [currentPage, descending, pageCount]);
|
||||
|
||||
// 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建
|
||||
const getVideoInfo = useCallback(async (source: SearchResult) => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
|
||||
// 使用 ref 获取最新的状态,避免闭包问题
|
||||
if (attemptedSourcesRef.current.has(sourceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取第一集的URL
|
||||
if (!source.episodes || source.episodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const episodeUrl =
|
||||
source.episodes.length > 1 ? source.episodes[1] : source.episodes[0];
|
||||
|
||||
// 标记为已尝试
|
||||
setAttemptedSources((prev) => new Set(prev).add(sourceKey));
|
||||
|
||||
try {
|
||||
const info = await getVideoResolutionFromM3u8(episodeUrl);
|
||||
setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info));
|
||||
} catch (error) {
|
||||
// 失败时保存错误状态
|
||||
setVideoInfoMap((prev) =>
|
||||
new Map(prev).set(sourceKey, {
|
||||
quality: '错误',
|
||||
loadSpeed: '未知',
|
||||
pingTime: 0,
|
||||
hasError: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 当有预计算结果时,先合并到videoInfoMap中
|
||||
useEffect(() => {
|
||||
if (precomputedVideoInfo && precomputedVideoInfo.size > 0) {
|
||||
// 原子性地更新两个状态,避免时序问题
|
||||
setVideoInfoMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
precomputedVideoInfo.forEach((value, key) => {
|
||||
newMap.set(key, value);
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
|
||||
setAttemptedSources((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
precomputedVideoInfo.forEach((info, key) => {
|
||||
if (!info.hasError) {
|
||||
newSet.add(key);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// 同步更新 ref,确保 getVideoInfo 能立即看到更新
|
||||
precomputedVideoInfo.forEach((info, key) => {
|
||||
if (!info.hasError) {
|
||||
attemptedSourcesRef.current.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [precomputedVideoInfo]);
|
||||
|
||||
// 读取本地“优选和测速”开关,默认开启
|
||||
const [optimizationEnabled] = useState<boolean>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('enableOptimization');
|
||||
if (saved !== null) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发
|
||||
useEffect(() => {
|
||||
const fetchVideoInfosInBatches = async () => {
|
||||
if (
|
||||
!optimizationEnabled || // 若关闭测速则直接退出
|
||||
activeTab !== 'sources' ||
|
||||
availableSources.length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
// 筛选出尚未测速的播放源
|
||||
const pendingSources = availableSources.filter((source) => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
return !attemptedSourcesRef.current.has(sourceKey);
|
||||
});
|
||||
|
||||
if (pendingSources.length === 0) return;
|
||||
|
||||
const batchSize = Math.ceil(pendingSources.length / 2);
|
||||
|
||||
for (let start = 0; start < pendingSources.length; start += batchSize) {
|
||||
const batch = pendingSources.slice(start, start + batchSize);
|
||||
await Promise.all(batch.map(getVideoInfo));
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideoInfosInBatches();
|
||||
// 依赖项保持与之前一致
|
||||
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled]);
|
||||
|
||||
// 升序分页标签
|
||||
const categoriesAsc = useMemo(() => {
|
||||
return Array.from({ length: pageCount }, (_, i) => {
|
||||
const start = i * episodesPerPage + 1;
|
||||
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
|
||||
return { start, end };
|
||||
});
|
||||
}, [pageCount, episodesPerPage, totalEpisodes]);
|
||||
|
||||
// 根据 descending 状态决定分页标签的排序和内容
|
||||
const categories = useMemo(() => {
|
||||
if (descending) {
|
||||
// 倒序时,label 也倒序显示
|
||||
return [...categoriesAsc]
|
||||
.reverse()
|
||||
.map(({ start, end }) => `${end}-${start}`);
|
||||
}
|
||||
return categoriesAsc.map(({ start, end }) => `${start}-${end}`);
|
||||
}, [categoriesAsc, descending]);
|
||||
|
||||
const categoryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||||
useEffect(() => {
|
||||
const btn = buttonRefs.current[displayPage];
|
||||
const container = categoryContainerRef.current;
|
||||
if (btn && container) {
|
||||
// 手动计算滚动位置,只滚动分页标签容器
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
const scrollLeft = container.scrollLeft;
|
||||
|
||||
// 计算按钮相对于容器的位置
|
||||
const btnLeft = btnRect.left - containerRect.left + scrollLeft;
|
||||
const btnWidth = btnRect.width;
|
||||
const containerWidth = containerRect.width;
|
||||
|
||||
// 计算目标滚动位置,使按钮居中
|
||||
const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2;
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
container.scrollTo({
|
||||
left: targetScrollLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [displayPage, pageCount]);
|
||||
|
||||
// 处理换源tab点击,只在点击时才搜索
|
||||
const handleSourceTabClick = () => {
|
||||
setActiveTab('sources');
|
||||
};
|
||||
|
||||
const handleCategoryClick = useCallback(
|
||||
(index: number) => {
|
||||
if (descending) {
|
||||
// 在倒序时,需要将显示索引转换为实际索引
|
||||
setCurrentPage(pageCount - 1 - index);
|
||||
} else {
|
||||
setCurrentPage(index);
|
||||
}
|
||||
},
|
||||
[descending, pageCount]
|
||||
);
|
||||
|
||||
const handleEpisodeClick = useCallback(
|
||||
(episodeNumber: number) => {
|
||||
onChange?.(episodeNumber);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleSourceClick = useCallback(
|
||||
(source: SearchResult) => {
|
||||
onSourceChange?.(source.source, source.id, source.title);
|
||||
},
|
||||
[onSourceChange]
|
||||
);
|
||||
|
||||
const currentStart = currentPage * episodesPerPage + 1;
|
||||
const currentEnd = Math.min(
|
||||
currentStart + episodesPerPage - 1,
|
||||
totalEpisodes
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
|
||||
{/* 主要的 Tab 切换 - 无缝融入设计 */}
|
||||
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
||||
{totalEpisodes > 1 && (
|
||||
<div
|
||||
onClick={() => setActiveTab('episodes')}
|
||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||
${
|
||||
activeTab === 'episodes'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
选集
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
onClick={handleSourceTabClick}
|
||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||
${
|
||||
activeTab === 'sources'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
换源
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选集 Tab 内容 */}
|
||||
{activeTab === 'episodes' && (
|
||||
<>
|
||||
{/* 分类标签 */}
|
||||
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
|
||||
<div className='flex-1 overflow-x-auto' ref={categoryContainerRef}>
|
||||
<div className='flex gap-2 min-w-max'>
|
||||
{categories.map((label, idx) => {
|
||||
const isActive = idx === displayPage;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[idx] = el;
|
||||
}}
|
||||
onClick={() => handleCategoryClick(idx)}
|
||||
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
|
||||
${
|
||||
isActive
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
{label}
|
||||
{isActive && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 向上/向下按钮 */}
|
||||
<button
|
||||
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-green-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-green-400 dark:hover:bg-white/20 transition-colors transform translate-y-[-4px]'
|
||||
onClick={() => {
|
||||
// 切换集数排序(正序/倒序)
|
||||
setDescending((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 集数网格 */}
|
||||
<div className='flex flex-wrap gap-3 overflow-y-auto flex-1 content-start pb-4'>
|
||||
{(() => {
|
||||
const len = currentEnd - currentStart + 1;
|
||||
const episodes = Array.from({ length: len }, (_, i) =>
|
||||
descending ? currentEnd - i : currentStart + i
|
||||
);
|
||||
return episodes;
|
||||
})().map((episodeNumber) => {
|
||||
const isActive = episodeNumber === value;
|
||||
return (
|
||||
<button
|
||||
key={episodeNumber}
|
||||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
||||
className={`h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono
|
||||
${
|
||||
isActive
|
||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-white/10 dark:text-gray-300 dark:hover:bg-white/20'
|
||||
}`.trim()}
|
||||
>
|
||||
{(() => {
|
||||
const title = episodes_titles?.[episodeNumber - 1];
|
||||
if (!title) {
|
||||
return episodeNumber;
|
||||
}
|
||||
// 如果匹配"第X集"格式,提取中间的数字
|
||||
const match = title.match(/第(\d+)集/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return title;
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 换源 Tab 内容 */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className='flex flex-col h-full mt-4'>
|
||||
{sourceSearchLoading && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
<span className='ml-2 text-sm text-gray-600 dark:text-gray-300'>
|
||||
搜索中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceSearchError && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='text-center'>
|
||||
<div className='text-red-500 text-2xl mb-2'>⚠️</div>
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{sourceSearchError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sourceSearchLoading &&
|
||||
!sourceSearchError &&
|
||||
availableSources.length === 0 && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='text-center'>
|
||||
<div className='text-gray-400 text-2xl mb-2'>📺</div>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-300'>
|
||||
暂无可用的换源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sourceSearchLoading &&
|
||||
!sourceSearchError &&
|
||||
availableSources.length > 0 && (
|
||||
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
|
||||
{availableSources
|
||||
.sort((a, b) => {
|
||||
const aIsCurrent =
|
||||
a.source?.toString() === currentSource?.toString() &&
|
||||
a.id?.toString() === currentId?.toString();
|
||||
const bIsCurrent =
|
||||
b.source?.toString() === currentSource?.toString() &&
|
||||
b.id?.toString() === currentId?.toString();
|
||||
if (aIsCurrent && !bIsCurrent) return -1;
|
||||
if (!aIsCurrent && bIsCurrent) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((source, index) => {
|
||||
const isCurrentSource =
|
||||
source.source?.toString() === currentSource?.toString() &&
|
||||
source.id?.toString() === currentId?.toString();
|
||||
return (
|
||||
<div
|
||||
key={`${source.source}-${source.id}`}
|
||||
onClick={() =>
|
||||
!isCurrentSource && handleSourceClick(source)
|
||||
}
|
||||
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
|
||||
${
|
||||
isCurrentSource
|
||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
|
||||
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
|
||||
}`.trim()}
|
||||
>
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
|
||||
{source.episodes && source.episodes.length > 0 && (
|
||||
<img
|
||||
src={processImageUrl(source.poster)}
|
||||
alt={source.title}
|
||||
className='w-full h-full object-cover'
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
<div className='flex-1 min-w-0 flex flex-col justify-between h-20'>
|
||||
{/* 标题和分辨率 - 顶部 */}
|
||||
<div className='flex items-start justify-between gap-3 h-6'>
|
||||
<div className='flex-1 min-w-0 relative group/title'>
|
||||
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'>
|
||||
{source.title}
|
||||
</h3>
|
||||
{/* 标题级别的 tooltip - 第一个元素不显示 */}
|
||||
{index !== 0 && (
|
||||
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[500] pointer-events-none'>
|
||||
{source.title}
|
||||
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
const videoInfo = videoInfoMap.get(sourceKey);
|
||||
|
||||
if (videoInfo && videoInfo.quality !== '未知') {
|
||||
if (videoInfo.hasError) {
|
||||
return (
|
||||
<div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center'>
|
||||
检测失败
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 根据分辨率设置不同颜色:2K、4K为紫色,1080p、720p为绿色,其他为黄色
|
||||
const isUltraHigh = ['4K', '2K'].includes(
|
||||
videoInfo.quality
|
||||
);
|
||||
const isHigh = ['1080p', '720p'].includes(
|
||||
videoInfo.quality
|
||||
);
|
||||
const textColorClasses = isUltraHigh
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: isHigh
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center`}
|
||||
>
|
||||
{videoInfo.quality}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 源名称和集数信息 - 垂直居中 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
|
||||
{source.source_name}
|
||||
</span>
|
||||
{source.episodes.length > 1 && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
||||
{source.episodes.length} 集
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 网络信息 - 底部 */}
|
||||
<div className='flex items-end h-6'>
|
||||
{(() => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
const videoInfo = videoInfoMap.get(sourceKey);
|
||||
if (videoInfo) {
|
||||
if (!videoInfo.hasError) {
|
||||
return (
|
||||
<div className='flex items-end gap-3 text-xs'>
|
||||
<div className='text-green-600 dark:text-green-400 font-medium text-xs'>
|
||||
{videoInfo.loadSpeed}
|
||||
</div>
|
||||
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'>
|
||||
{videoInfo.pingTime}ms
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
|
||||
无测速数据
|
||||
</div>
|
||||
); // 占位div
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (videoTitle) {
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(videoTitle)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className='w-full text-center text-xs text-gray-500 dark:text-gray-400 hover:text-green-500 dark:hover:text-green-400 transition-colors py-2'
|
||||
>
|
||||
影片匹配有误?点击去搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodeSelector;
|
||||
105
src/components/GlobalErrorIndicator.tsx
Normal file
105
src/components/GlobalErrorIndicator.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ErrorInfo {
|
||||
id: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function GlobalErrorIndicator() {
|
||||
const [currentError, setCurrentError] = useState<ErrorInfo | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isReplacing, setIsReplacing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听自定义错误事件
|
||||
const handleError = (event: CustomEvent) => {
|
||||
const { message } = event.detail;
|
||||
const newError: ErrorInfo = {
|
||||
id: Date.now().toString(),
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 如果已有错误,开始替换动画
|
||||
if (currentError) {
|
||||
setCurrentError(newError);
|
||||
setIsReplacing(true);
|
||||
|
||||
// 动画完成后恢复正常
|
||||
setTimeout(() => {
|
||||
setIsReplacing(false);
|
||||
}, 200);
|
||||
} else {
|
||||
// 第一次显示错误
|
||||
setCurrentError(newError);
|
||||
}
|
||||
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
// 监听错误事件
|
||||
window.addEventListener('globalError', handleError as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('globalError', handleError as EventListener);
|
||||
};
|
||||
}, [currentError]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setCurrentError(null);
|
||||
setIsReplacing(false);
|
||||
};
|
||||
|
||||
if (!isVisible || !currentError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='fixed top-4 right-4 z-[2000]'>
|
||||
{/* 错误卡片 */}
|
||||
<div
|
||||
className={`bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between min-w-[300px] max-w-[400px] transition-all duration-300 ${
|
||||
isReplacing ? 'scale-105 bg-red-400' : 'scale-100 bg-red-500'
|
||||
} animate-fade-in`}
|
||||
>
|
||||
<span className='text-sm font-medium flex-1 mr-3'>
|
||||
{currentError.message}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className='text-white hover:text-red-100 transition-colors flex-shrink-0'
|
||||
aria-label='关闭错误提示'
|
||||
>
|
||||
<svg
|
||||
className='w-5 h-5'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 全局错误触发函数
|
||||
export function triggerGlobalError(message: string) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/components/ImagePlaceholder.tsx
Normal file
40
src/components/ImagePlaceholder.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// 图片占位符组件 - 实现骨架屏效果(支持暗色模式)
|
||||
const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
|
||||
<div
|
||||
className={`w-full ${aspectRatio} rounded-lg`}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shine 1.5s infinite',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes shine {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
/* 亮色模式变量 */
|
||||
:root {
|
||||
--skeleton-color: #f0f0f0;
|
||||
--skeleton-highlight: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 暗色模式变量 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--skeleton-color: #2d2d2d;
|
||||
--skeleton-highlight: #3d3d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--skeleton-color: #2d2d2d;
|
||||
--skeleton-highlight: #3d3d3d;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { ImagePlaceholder };
|
||||
124
src/components/MobileBottomNav.tsx
Normal file
124
src/components/MobileBottomNav.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Cat, Clover, Film, Home, Search, Star, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
/**
|
||||
* 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。
|
||||
*/
|
||||
activePath?: string;
|
||||
}
|
||||
|
||||
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// 当前激活路径:优先使用传入的 activePath,否则回退到浏览器地址
|
||||
const currentActive = activePath ?? pathname;
|
||||
|
||||
const [navItems, setNavItems] = useState([
|
||||
{ icon: Home, label: '首页', href: '/' },
|
||||
{ icon: Search, label: '搜索', href: '/search' },
|
||||
{
|
||||
icon: Film,
|
||||
label: '电影',
|
||||
href: '/douban?type=movie',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: '剧集',
|
||||
href: '/douban?type=tv',
|
||||
},
|
||||
{
|
||||
icon: Cat,
|
||||
label: '动漫',
|
||||
href: '/douban?type=anime',
|
||||
},
|
||||
{
|
||||
icon: Clover,
|
||||
label: '综艺',
|
||||
href: '/douban?type=show',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
|
||||
setNavItems((prevItems) => [
|
||||
...prevItems,
|
||||
{
|
||||
icon: Star,
|
||||
label: '自定义',
|
||||
href: '/douban?type=custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const typeMatch = href.match(/type=([^&]+)/)?.[1];
|
||||
|
||||
// 解码URL以进行正确的比较
|
||||
const decodedActive = decodeURIComponent(currentActive);
|
||||
const decodedItemHref = decodeURIComponent(href);
|
||||
|
||||
return (
|
||||
decodedActive === decodedItemHref ||
|
||||
(decodedActive.startsWith('/douban') &&
|
||||
decodedActive.includes(`type=${typeMatch}`))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className='md:hidden fixed left-0 right-0 z-[600] bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-hidden dark:bg-gray-900/80 dark:border-gray-700/50'
|
||||
style={{
|
||||
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
||||
bottom: 0,
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
minHeight: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<ul className='flex items-center overflow-x-auto scrollbar-hide'>
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li
|
||||
key={item.href}
|
||||
className='flex-shrink-0'
|
||||
style={{ width: '20vw', minWidth: '20vw' }}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
className='flex flex-col items-center justify-center w-full h-14 gap-1 text-xs'
|
||||
>
|
||||
<item.icon
|
||||
className={`h-6 w-6 ${
|
||||
active
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileBottomNav;
|
||||
44
src/components/MobileHeader.tsx
Normal file
44
src/components/MobileHeader.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { BackButton } from './BackButton';
|
||||
import { useSite } from './SiteProvider';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
interface MobileHeaderProps {
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||
const { siteName } = useSite();
|
||||
return (
|
||||
<header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
|
||||
<div className='h-12 flex items-center justify-between px-4'>
|
||||
{/* 左侧:返回按钮和设置按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
{showBackButton && <BackButton />}
|
||||
</div>
|
||||
|
||||
{/* 右侧按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:Logo(绝对居中) */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<Link
|
||||
href='/'
|
||||
className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity'
|
||||
>
|
||||
{siteName}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileHeader;
|
||||
592
src/components/MultiLevelSelector.tsx
Normal file
592
src/components/MultiLevelSelector.tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface MultiLevelOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface MultiLevelCategory {
|
||||
key: string;
|
||||
label: string;
|
||||
options: MultiLevelOption[];
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
interface MultiLevelSelectorProps {
|
||||
onChange: (values: Record<string, string>) => void;
|
||||
contentType?: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie';
|
||||
}
|
||||
|
||||
const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
|
||||
onChange,
|
||||
contentType = 'movie',
|
||||
}) => {
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
}>({ x: 0, y: 0, width: 0 });
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 根据内容类型获取对应的类型选项
|
||||
const getTypeOptions = (
|
||||
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
|
||||
) => {
|
||||
const baseOptions = [{ label: '全部', value: 'all' }];
|
||||
|
||||
switch (contentType) {
|
||||
case 'movie':
|
||||
return [
|
||||
...baseOptions,
|
||||
{ label: '喜剧', value: 'comedy' },
|
||||
{ label: '爱情', value: 'romance' },
|
||||
{ label: '动作', value: 'action' },
|
||||
{ label: '科幻', value: 'sci-fi' },
|
||||
{ label: '悬疑', value: 'suspense' },
|
||||
{ label: '犯罪', value: 'crime' },
|
||||
{ label: '惊悚', value: 'thriller' },
|
||||
{ label: '冒险', value: 'adventure' },
|
||||
{ label: '音乐', value: 'music' },
|
||||
{ label: '历史', value: 'history' },
|
||||
{ label: '奇幻', value: 'fantasy' },
|
||||
{ label: '恐怖', value: 'horror' },
|
||||
{ label: '战争', value: 'war' },
|
||||
{ label: '传记', value: 'biography' },
|
||||
{ label: '歌舞', value: 'musical' },
|
||||
{ label: '武侠', value: 'wuxia' },
|
||||
{ label: '情色', value: 'erotic' },
|
||||
{ label: '灾难', value: 'disaster' },
|
||||
{ label: '西部', value: 'western' },
|
||||
{ label: '纪录片', value: 'documentary' },
|
||||
{ label: '短片', value: 'short' },
|
||||
];
|
||||
case 'tv':
|
||||
return [
|
||||
...baseOptions,
|
||||
{ label: '喜剧', value: 'comedy' },
|
||||
{ label: '爱情', value: 'romance' },
|
||||
{ label: '悬疑', value: 'suspense' },
|
||||
{ label: '武侠', value: 'wuxia' },
|
||||
{ label: '古装', value: 'costume' },
|
||||
{ label: '家庭', value: 'family' },
|
||||
{ label: '犯罪', value: 'crime' },
|
||||
{ label: '科幻', value: 'sci-fi' },
|
||||
{ label: '恐怖', value: 'horror' },
|
||||
{ label: '历史', value: 'history' },
|
||||
{ label: '战争', value: 'war' },
|
||||
{ label: '动作', value: 'action' },
|
||||
{ label: '冒险', value: 'adventure' },
|
||||
{ label: '传记', value: 'biography' },
|
||||
{ label: '剧情', value: 'drama' },
|
||||
{ label: '奇幻', value: 'fantasy' },
|
||||
{ label: '惊悚', value: 'thriller' },
|
||||
{ label: '灾难', value: 'disaster' },
|
||||
{ label: '歌舞', value: 'musical' },
|
||||
{ label: '音乐', value: 'music' },
|
||||
];
|
||||
case 'show':
|
||||
return [
|
||||
...baseOptions,
|
||||
{ label: '真人秀', value: 'reality' },
|
||||
{ label: '脱口秀', value: 'talkshow' },
|
||||
{ label: '音乐', value: 'music' },
|
||||
{ label: '歌舞', value: 'musical' },
|
||||
];
|
||||
case 'anime-tv':
|
||||
case 'anime-movie':
|
||||
default:
|
||||
return baseOptions;
|
||||
}
|
||||
};
|
||||
|
||||
// 根据内容类型获取对应的地区选项
|
||||
const getRegionOptions = (
|
||||
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
|
||||
) => {
|
||||
const baseOptions = [{ label: '全部', value: 'all' }];
|
||||
|
||||
switch (contentType) {
|
||||
case 'movie':
|
||||
case 'anime-movie':
|
||||
return [
|
||||
...baseOptions,
|
||||
{ label: '华语', value: 'chinese' },
|
||||
{ label: '欧美', value: 'western' },
|
||||
{ label: '韩国', value: 'korean' },
|
||||
{ label: '日本', value: 'japanese' },
|
||||
{ label: '中国大陆', value: 'mainland_china' },
|
||||
{ label: '美国', value: 'usa' },
|
||||
{ label: '中国香港', value: 'hong_kong' },
|
||||
{ label: '中国台湾', value: 'taiwan' },
|
||||
{ label: '英国', value: 'uk' },
|
||||
{ label: '法国', value: 'france' },
|
||||
{ label: '德国', value: 'germany' },
|
||||
{ label: '意大利', value: 'italy' },
|
||||
{ label: '西班牙', value: 'spain' },
|
||||
{ label: '印度', value: 'india' },
|
||||
{ label: '泰国', value: 'thailand' },
|
||||
{ label: '俄罗斯', value: 'russia' },
|
||||
{ label: '加拿大', value: 'canada' },
|
||||
{ label: '澳大利亚', value: 'australia' },
|
||||
{ label: '爱尔兰', value: 'ireland' },
|
||||
{ label: '瑞典', value: 'sweden' },
|
||||
{ label: '巴西', value: 'brazil' },
|
||||
{ label: '丹麦', value: 'denmark' },
|
||||
];
|
||||
case 'tv':
|
||||
case 'anime-tv':
|
||||
case 'show':
|
||||
return [
|
||||
...baseOptions,
|
||||
{ label: '华语', value: 'chinese' },
|
||||
{ label: '欧美', value: 'western' },
|
||||
{ label: '国外', value: 'foreign' },
|
||||
{ label: '韩国', value: 'korean' },
|
||||
{ label: '日本', value: 'japanese' },
|
||||
{ label: '中国大陆', value: 'mainland_china' },
|
||||
{ label: '中国香港', value: 'hong_kong' },
|
||||
{ label: '美国', value: 'usa' },
|
||||
{ label: '英国', value: 'uk' },
|
||||
{ label: '泰国', value: 'thailand' },
|
||||
{ label: '中国台湾', value: 'taiwan' },
|
||||
{ label: '意大利', value: 'italy' },
|
||||
{ label: '法国', value: 'france' },
|
||||
{ label: '德国', value: 'germany' },
|
||||
{ label: '西班牙', value: 'spain' },
|
||||
{ label: '俄罗斯', value: 'russia' },
|
||||
{ label: '瑞典', value: 'sweden' },
|
||||
{ label: '巴西', value: 'brazil' },
|
||||
{ label: '丹麦', value: 'denmark' },
|
||||
{ label: '印度', value: 'india' },
|
||||
{ label: '加拿大', value: 'canada' },
|
||||
{ label: '爱尔兰', value: 'ireland' },
|
||||
{ label: '澳大利亚', value: 'australia' },
|
||||
];
|
||||
default:
|
||||
return baseOptions;
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelOptions = (
|
||||
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
|
||||
) => {
|
||||
const baseOptions = [{ label: '全部', value: 'all' }];
|
||||
switch (contentType) {
|
||||
case 'anime-movie':
|
||||
return [
|
||||
...baseOptions,
|
||||
{ label: '定格动画', value: 'stop_motion' },
|
||||
{ label: '传记', value: 'biography' },
|
||||
{ label: '美国动画', value: 'us_animation' },
|
||||
{ label: '爱情', value: 'romance' },
|
||||
{ label: '黑色幽默', value: 'dark_humor' },
|
||||
{ label: '歌舞', value: 'musical' },
|
||||
{ label: '儿童', value: 'children' },
|
||||
{ label: '二次元', value: 'anime' },
|
||||
{ label: '动物', value: 'animal' },
|
||||
{ label: '青春', value: 'youth' },
|
||||
{ label: '历史', value: 'history' },
|
||||
{ label: '励志', value: 'inspirational' },
|
||||
{ label: '恶搞', value: 'parody' },
|
||||
{ label: '治愈', value: 'healing' },
|
||||
{ label: '运动', value: 'sports' },
|
||||
{ label: '后宫', value: 'harem' },
|
||||
{ label: '情色', value: 'erotic' },
|
||||
{ label: '人性', value: 'human_nature' },
|
||||
{ label: '悬疑', value: 'suspense' },
|
||||
{ label: '恋爱', value: 'love' },
|
||||
{ label: '魔幻', value: 'fantasy' },
|
||||
{ label: '科幻', value: 'sci_fi' },
|
||||
];
|
||||
case 'anime-tv':
|
||||
return [
|
||||
...baseOptions,
|
||||
{ label: '黑色幽默', value: 'dark_humor' },
|
||||
{ label: '历史', value: 'history' },
|
||||
{ label: '歌舞', value: 'musical' },
|
||||
{ label: '励志', value: 'inspirational' },
|
||||
{ label: '恶搞', value: 'parody' },
|
||||
{ label: '治愈', value: 'healing' },
|
||||
{ label: '运动', value: 'sports' },
|
||||
{ label: '后宫', value: 'harem' },
|
||||
{ label: '情色', value: 'erotic' },
|
||||
{ label: '国漫', value: 'chinese_anime' },
|
||||
{ label: '人性', value: 'human_nature' },
|
||||
{ label: '悬疑', value: 'suspense' },
|
||||
{ label: '恋爱', value: 'love' },
|
||||
{ label: '魔幻', value: 'fantasy' },
|
||||
{ label: '科幻', value: 'sci_fi' },
|
||||
];
|
||||
default:
|
||||
return baseOptions;
|
||||
}
|
||||
};
|
||||
|
||||
// 根据内容类型获取对应的平台选项
|
||||
const getPlatformOptions = (
|
||||
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
|
||||
) => {
|
||||
const baseOptions = [{ label: '全部', value: 'all' }];
|
||||
|
||||
switch (contentType) {
|
||||
case 'movie':
|
||||
return baseOptions; // 电影不需要平台选项
|
||||
case 'tv':
|
||||
case 'anime-tv':
|
||||
case 'show':
|
||||
return [
|
||||
...baseOptions,
|
||||
{ label: '腾讯视频', value: 'tencent' },
|
||||
{ label: '爱奇艺', value: 'iqiyi' },
|
||||
{ label: '优酷', value: 'youku' },
|
||||
{ label: '湖南卫视', value: 'hunan_tv' },
|
||||
{ label: 'Netflix', value: 'netflix' },
|
||||
{ label: 'HBO', value: 'hbo' },
|
||||
{ label: 'BBC', value: 'bbc' },
|
||||
{ label: 'NHK', value: 'nhk' },
|
||||
{ label: 'CBS', value: 'cbs' },
|
||||
{ label: 'NBC', value: 'nbc' },
|
||||
{ label: 'tvN', value: 'tvn' },
|
||||
];
|
||||
default:
|
||||
return baseOptions;
|
||||
}
|
||||
};
|
||||
|
||||
// 分类配置
|
||||
const categories: MultiLevelCategory[] = [
|
||||
...(contentType !== 'anime-tv' && contentType !== 'anime-movie'
|
||||
? [
|
||||
{
|
||||
key: 'type',
|
||||
label: '类型',
|
||||
options: getTypeOptions(contentType),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'label',
|
||||
label: '类型',
|
||||
options: getLabelOptions(contentType),
|
||||
},
|
||||
]),
|
||||
{
|
||||
key: 'region',
|
||||
label: '地区',
|
||||
options: getRegionOptions(contentType),
|
||||
},
|
||||
{
|
||||
key: 'year',
|
||||
label: '年代',
|
||||
options: [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '2020年代', value: '2020s' },
|
||||
{ label: '2025', value: '2025' },
|
||||
{ label: '2024', value: '2024' },
|
||||
{ label: '2023', value: '2023' },
|
||||
{ label: '2022', value: '2022' },
|
||||
{ label: '2021', value: '2021' },
|
||||
{ label: '2020', value: '2020' },
|
||||
{ label: '2019', value: '2019' },
|
||||
{ label: '2010年代', value: '2010s' },
|
||||
{ label: '2000年代', value: '2000s' },
|
||||
{ label: '90年代', value: '1990s' },
|
||||
{ label: '80年代', value: '1980s' },
|
||||
{ label: '70年代', value: '1970s' },
|
||||
{ label: '60年代', value: '1960s' },
|
||||
{ label: '更早', value: 'earlier' },
|
||||
],
|
||||
},
|
||||
// 只在电视剧和综艺时显示平台选项
|
||||
...(contentType === 'tv' ||
|
||||
contentType === 'show' ||
|
||||
contentType === 'anime-tv'
|
||||
? [
|
||||
{
|
||||
key: 'platform',
|
||||
label: '平台',
|
||||
options: getPlatformOptions(contentType),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'sort',
|
||||
label: '排序',
|
||||
options: [
|
||||
{ label: '综合排序', value: 'T' },
|
||||
{ label: '近期热度', value: 'U' },
|
||||
{
|
||||
label:
|
||||
contentType === 'tv' || contentType === 'show'
|
||||
? '首播时间'
|
||||
: '首映时间',
|
||||
value: 'R',
|
||||
},
|
||||
{ label: '高分优先', value: 'S' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 计算下拉框位置
|
||||
const calculateDropdownPosition = (categoryKey: string) => {
|
||||
const element = categoryRefs.current[categoryKey];
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const isMobile = viewportWidth < 768; // md breakpoint
|
||||
|
||||
let x = rect.left;
|
||||
let dropdownWidth = Math.max(rect.width, 300);
|
||||
let useFixedWidth = false; // 标记是否使用固定宽度
|
||||
|
||||
// 移动端优化:防止下拉框被右侧视口截断
|
||||
if (isMobile) {
|
||||
const padding = 16; // 左右各留16px的边距
|
||||
const maxWidth = viewportWidth - padding * 2;
|
||||
dropdownWidth = Math.min(dropdownWidth, maxWidth);
|
||||
useFixedWidth = true; // 移动端使用固定宽度
|
||||
|
||||
// 如果右侧超出视口,则调整x位置
|
||||
if (x + dropdownWidth > viewportWidth - padding) {
|
||||
x = viewportWidth - dropdownWidth - padding;
|
||||
}
|
||||
|
||||
// 如果左侧超出视口,则贴左边
|
||||
if (x < padding) {
|
||||
x = padding;
|
||||
}
|
||||
}
|
||||
|
||||
setDropdownPosition({
|
||||
x,
|
||||
y: rect.bottom,
|
||||
width: useFixedWidth ? dropdownWidth : rect.width, // PC端保持原有逻辑
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理分类点击
|
||||
const handleCategoryClick = (categoryKey: string) => {
|
||||
if (activeCategory === categoryKey) {
|
||||
setActiveCategory(null);
|
||||
} else {
|
||||
setActiveCategory(categoryKey);
|
||||
calculateDropdownPosition(categoryKey);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理选项选择
|
||||
const handleOptionSelect = (categoryKey: string, optionValue: string) => {
|
||||
// 更新本地状态
|
||||
const newValues = {
|
||||
...values,
|
||||
[categoryKey]: optionValue,
|
||||
};
|
||||
|
||||
// 更新内部状态
|
||||
setValues(newValues);
|
||||
|
||||
// 构建传递给父组件的值,排序传递 value,其他传递 label
|
||||
const selectionsForParent: Record<string, string> = {
|
||||
type: 'all',
|
||||
region: 'all',
|
||||
year: 'all',
|
||||
platform: 'all',
|
||||
label: 'all',
|
||||
sort: 'T',
|
||||
};
|
||||
|
||||
Object.entries(newValues).forEach(([key, value]) => {
|
||||
if (value && value !== 'all' && (key !== 'sort' || value !== 'T')) {
|
||||
const category = categories.find((cat) => cat.key === key);
|
||||
if (category) {
|
||||
const option = category.options.find((opt) => opt.value === value);
|
||||
if (option) {
|
||||
// 排序传递 value,其他传递 label
|
||||
selectionsForParent[key] =
|
||||
key === 'sort' ? option.value : option.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 调用父组件的回调,传递处理后的选择值
|
||||
onChange(selectionsForParent);
|
||||
|
||||
setActiveCategory(null);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = (categoryKey: string) => {
|
||||
const category = categories.find((cat) => cat.key === categoryKey);
|
||||
if (!category) return '';
|
||||
|
||||
const value = values[categoryKey];
|
||||
|
||||
if (
|
||||
!value ||
|
||||
value === 'all' ||
|
||||
(categoryKey === 'sort' && value === 'T')
|
||||
) {
|
||||
return category.label;
|
||||
}
|
||||
const option = category.options.find((opt) => opt.value === value);
|
||||
return option?.label || category.label;
|
||||
};
|
||||
|
||||
// 检查是否为默认值
|
||||
const isDefaultValue = (categoryKey: string) => {
|
||||
const value = values[categoryKey];
|
||||
return (
|
||||
!value || value === 'all' || (categoryKey === 'sort' && value === 'T')
|
||||
);
|
||||
};
|
||||
|
||||
// 检查选项是否被选中
|
||||
const isOptionSelected = (categoryKey: string, optionValue: string) => {
|
||||
let value = values[categoryKey];
|
||||
if (value === undefined) {
|
||||
value = 'all';
|
||||
if (categoryKey === 'sort') {
|
||||
value = 'T';
|
||||
}
|
||||
}
|
||||
return value === optionValue;
|
||||
};
|
||||
|
||||
// 监听滚动和窗口大小变化事件,重新计算位置
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (activeCategory) {
|
||||
calculateDropdownPosition(activeCategory);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (activeCategory) {
|
||||
calculateDropdownPosition(activeCategory);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [activeCategory]);
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
!Object.values(categoryRefs.current).some(
|
||||
(ref) => ref && ref.contains(event.target as Node)
|
||||
)
|
||||
) {
|
||||
setActiveCategory(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 胶囊样式筛选栏 */}
|
||||
<div className='relative inline-flex rounded-full p-0.5 sm:p-1 bg-transparent gap-1 sm:gap-2'>
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.key}
|
||||
ref={(el) => {
|
||||
categoryRefs.current[category.key] = el;
|
||||
}}
|
||||
className='relative'
|
||||
>
|
||||
<button
|
||||
onClick={() => handleCategoryClick(category.key)}
|
||||
className={`relative z-10 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-4 md:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
|
||||
activeCategory === category.key
|
||||
? isDefaultValue(category.key)
|
||||
? 'text-gray-900 dark:text-gray-100 cursor-default'
|
||||
: 'text-green-600 dark:text-green-400 cursor-default'
|
||||
: isDefaultValue(category.key)
|
||||
? 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
|
||||
: 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<span>{getDisplayText(category.key)}</span>
|
||||
<svg
|
||||
className={`inline-block w-2.5 h-2.5 sm:w-3 sm:h-3 ml-0.5 sm:ml-1 transition-transform duration-200 ${
|
||||
activeCategory === category.key ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M19 9l-7 7-7-7'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 展开的筛选选项 - 悬浮显示 */}
|
||||
{activeCategory &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className='fixed z-[9999] bg-white/95 dark:bg-gray-800/95 rounded-xl border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm'
|
||||
style={{
|
||||
left: `${dropdownPosition.x}px`,
|
||||
top: `${dropdownPosition.y}px`,
|
||||
...(window.innerWidth < 768
|
||||
? { width: `${dropdownPosition.width}px` } // 移动端使用固定宽度
|
||||
: { minWidth: `${Math.max(dropdownPosition.width, 300)}px` }), // PC端使用最小宽度
|
||||
maxWidth: '600px',
|
||||
position: 'fixed',
|
||||
}}
|
||||
>
|
||||
<div className='p-2 sm:p-4'>
|
||||
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1 sm:gap-2'>
|
||||
{categories
|
||||
.find((cat) => cat.key === activeCategory)
|
||||
?.options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
handleOptionSelect(activeCategory, option.value)
|
||||
}
|
||||
className={`px-2 py-1.5 sm:px-3 sm:py-2 text-xs sm:text-sm rounded-lg transition-all duration-200 text-left ${
|
||||
isOptionSelected(activeCategory, option.value)
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-700'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-gray-700/80'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiLevelSelector;
|
||||
61
src/components/PageLayout.tsx
Normal file
61
src/components/PageLayout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { BackButton } from './BackButton';
|
||||
import MobileBottomNav from './MobileBottomNav';
|
||||
import MobileHeader from './MobileHeader';
|
||||
import Sidebar from './Sidebar';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
activePath?: string;
|
||||
}
|
||||
|
||||
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
return (
|
||||
<div className='w-full min-h-screen'>
|
||||
{/* 移动端头部 */}
|
||||
<MobileHeader showBackButton={['/play'].includes(activePath)} />
|
||||
|
||||
{/* 主要布局容器 */}
|
||||
<div className='flex md:grid md:grid-cols-[auto_1fr] w-full min-h-screen md:min-h-auto'>
|
||||
{/* 侧边栏 - 桌面端显示,移动端隐藏 */}
|
||||
<div className='hidden md:block'>
|
||||
<Sidebar activePath={activePath} />
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className='relative min-w-0 flex-1 transition-all duration-300'>
|
||||
{/* 桌面端左上角返回按钮 */}
|
||||
{['/play'].includes(activePath) && (
|
||||
<div className='absolute top-3 left-1 z-20 hidden md:flex'>
|
||||
<BackButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 桌面端顶部按钮 */}
|
||||
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
|
||||
{/* 主内容 */}
|
||||
<main
|
||||
className='flex-1 md:min-h-0 mb-14 md:mb-0'
|
||||
style={{
|
||||
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端底部导航 */}
|
||||
<div className='md:hidden'>
|
||||
<MobileBottomNav activePath={activePath} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLayout;
|
||||
169
src/components/ScrollableRow.tsx
Normal file
169
src/components/ScrollableRow.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface ScrollableRowProps {
|
||||
children: React.ReactNode;
|
||||
scrollDistance?: number;
|
||||
}
|
||||
|
||||
export default function ScrollableRow({
|
||||
children,
|
||||
scrollDistance = 1000,
|
||||
}: ScrollableRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftScroll, setShowLeftScroll] = useState(false);
|
||||
const [showRightScroll, setShowRightScroll] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const checkScroll = () => {
|
||||
if (containerRef.current) {
|
||||
const { scrollWidth, clientWidth, scrollLeft } = containerRef.current;
|
||||
|
||||
// 计算是否需要左右滚动按钮
|
||||
const threshold = 1; // 容差值,避免浮点误差
|
||||
const canScrollRight =
|
||||
scrollWidth - (scrollLeft + clientWidth) > threshold;
|
||||
const canScrollLeft = scrollLeft > threshold;
|
||||
|
||||
setShowRightScroll(canScrollRight);
|
||||
setShowLeftScroll(canScrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 多次延迟检查,确保内容已完全渲染
|
||||
checkScroll();
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', checkScroll);
|
||||
|
||||
// 创建一个 ResizeObserver 来监听容器大小变化
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// 延迟执行检查
|
||||
checkScroll();
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [children]); // 依赖 children,当子组件变化时重新检查
|
||||
|
||||
// 添加一个额外的效果来监听子组件的变化
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
// 监听 DOM 变化
|
||||
const observer = new MutationObserver(() => {
|
||||
setTimeout(checkScroll, 100);
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScrollRightClick = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollBy({
|
||||
left: scrollDistance,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollLeftClick = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollBy({
|
||||
left: -scrollDistance,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative'
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
// 当鼠标进入时重新检查一次
|
||||
checkScroll();
|
||||
}}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex space-x-6 overflow-x-auto scrollbar-hide py-1 sm:py-2 pb-12 sm:pb-14 px-4 sm:px-6'
|
||||
onScroll={checkScroll}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{showLeftScroll && (
|
||||
<div
|
||||
className={`hidden sm:flex absolute left-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
pointerEvents: 'none', // 允许点击穿透
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='absolute inset-0 flex items-center justify-center'
|
||||
style={{
|
||||
top: '40%',
|
||||
bottom: '60%',
|
||||
left: '-4.5rem',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleScrollLeftClick}
|
||||
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
|
||||
>
|
||||
<ChevronLeft className='w-6 h-6 text-gray-600 dark:text-gray-300' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRightScroll && (
|
||||
<div
|
||||
className={`hidden sm:flex absolute right-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
pointerEvents: 'none', // 允许点击穿透
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='absolute inset-0 flex items-center justify-center'
|
||||
style={{
|
||||
top: '40%',
|
||||
bottom: '60%',
|
||||
right: '-4.5rem',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleScrollRightClick}
|
||||
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
|
||||
>
|
||||
<ChevronRight className='w-6 h-6 text-gray-600 dark:text-gray-300' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
src/components/SearchSuggestions.tsx
Normal file
190
src/components/SearchSuggestions.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface SearchSuggestionsProps {
|
||||
query: string;
|
||||
isVisible: boolean;
|
||||
onSelect: (suggestion: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SuggestionItem {
|
||||
text: string;
|
||||
type: 'related';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SearchSuggestions({
|
||||
query,
|
||||
isVisible,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: SearchSuggestionsProps) {
|
||||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 防抖定时器
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 用于中止旧请求
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSuggestionsFromAPI = useCallback(async (searchQuery: string) => {
|
||||
// 每次请求前取消上一次的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/search/suggestions?q=${encodeURIComponent(searchQuery)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const apiSuggestions = data.suggestions.map(
|
||||
(item: { text: string }) => ({
|
||||
text: item.text,
|
||||
type: 'related' as const,
|
||||
})
|
||||
);
|
||||
setSuggestions(apiSuggestions);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// 类型保护判断 err 是否是 Error 类型
|
||||
if (err instanceof Error) {
|
||||
if (err.name !== 'AbortError') {
|
||||
// 不是取消请求导致的错误才清空
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
} else {
|
||||
// 如果 err 不是 Error 类型,也清空提示
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 防抖触发
|
||||
const debouncedFetchSuggestions = useCallback(
|
||||
(searchQuery: string) => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
if (searchQuery.trim() && isVisible) {
|
||||
fetchSuggestionsFromAPI(searchQuery);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, 300); //300ms
|
||||
},
|
||||
[isVisible, fetchSuggestionsFromAPI]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim() || !isVisible) {
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
debouncedFetchSuggestions(query);
|
||||
|
||||
// 清理定时器
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [query, isVisible, debouncedFetchSuggestions]);
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isVisible || suggestions.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : suggestions.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
onSelect(suggestions[selectedIndex].text);
|
||||
} else {
|
||||
onSelect(query);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isVisible, query, suggestions, selectedIndex, onSelect, onClose]);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
if (!isVisible || suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto'
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`related-${suggestion.text}`}
|
||||
onClick={() => onSelect(suggestion.text)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3 ${
|
||||
selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<span className='flex-1 text-sm text-gray-700 dark:text-gray-300 truncate'>
|
||||
{suggestion.text}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
src/components/Sidebar.tsx
Normal file
293
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Cat, Clover, Film, Home, Menu, Search, Star, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useSite } from './SiteProvider';
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType>({
|
||||
isCollapsed: false,
|
||||
});
|
||||
|
||||
export const useSidebar = () => useContext(SidebarContext);
|
||||
|
||||
// 可替换为你自己的 logo 图片
|
||||
const Logo = () => {
|
||||
const { siteName } = useSite();
|
||||
return (
|
||||
<Link
|
||||
href='/'
|
||||
className='flex items-center justify-center h-16 select-none hover:opacity-80 transition-opacity duration-200'
|
||||
>
|
||||
<span className='text-2xl font-bold text-green-600 tracking-tight'>
|
||||
{siteName}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
onToggle?: (collapsed: boolean) => void;
|
||||
activePath?: string;
|
||||
}
|
||||
|
||||
// 在浏览器环境下通过全局变量缓存折叠状态,避免组件重新挂载时出现初始值闪烁
|
||||
declare global {
|
||||
interface Window {
|
||||
__sidebarCollapsed?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.__sidebarCollapsed === 'boolean'
|
||||
) {
|
||||
return window.__sidebarCollapsed;
|
||||
}
|
||||
return false; // 默认展开
|
||||
});
|
||||
|
||||
// 首次挂载时读取 localStorage,以便刷新后仍保持上次的折叠状态
|
||||
useLayoutEffect(() => {
|
||||
const saved = localStorage.getItem('sidebarCollapsed');
|
||||
if (saved !== null) {
|
||||
const val = JSON.parse(saved);
|
||||
setIsCollapsed(val);
|
||||
window.__sidebarCollapsed = val;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 当折叠状态变化时,同步到 <html> data 属性,供首屏 CSS 使用
|
||||
useLayoutEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
if (isCollapsed) {
|
||||
document.documentElement.dataset.sidebarCollapsed = 'true';
|
||||
} else {
|
||||
delete document.documentElement.dataset.sidebarCollapsed;
|
||||
}
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
const [active, setActive] = useState(activePath);
|
||||
|
||||
useEffect(() => {
|
||||
// 优先使用传入的 activePath
|
||||
if (activePath) {
|
||||
setActive(activePath);
|
||||
} else {
|
||||
// 否则使用当前路径
|
||||
const getCurrentFullPath = () => {
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `${pathname}?${queryString}` : pathname;
|
||||
};
|
||||
const fullPath = getCurrentFullPath();
|
||||
setActive(fullPath);
|
||||
}
|
||||
}, [activePath, pathname, searchParams]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
const newState = !isCollapsed;
|
||||
setIsCollapsed(newState);
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__sidebarCollapsed = newState;
|
||||
}
|
||||
onToggle?.(newState);
|
||||
}, [isCollapsed, onToggle]);
|
||||
|
||||
const handleSearchClick = useCallback(() => {
|
||||
router.push('/search');
|
||||
}, [router]);
|
||||
|
||||
const contextValue = {
|
||||
isCollapsed,
|
||||
};
|
||||
|
||||
const [menuItems, setMenuItems] = useState([
|
||||
{
|
||||
icon: Film,
|
||||
label: '电影',
|
||||
href: '/douban?type=movie',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: '剧集',
|
||||
href: '/douban?type=tv',
|
||||
},
|
||||
{
|
||||
icon: Cat,
|
||||
label: '动漫',
|
||||
href: '/douban?type=anime',
|
||||
},
|
||||
{
|
||||
icon: Clover,
|
||||
label: '综艺',
|
||||
href: '/douban?type=show',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
|
||||
setMenuItems((prevItems) => [
|
||||
...prevItems,
|
||||
{
|
||||
icon: Star,
|
||||
label: '自定义',
|
||||
href: '/douban?type=custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
{/* 在移动端隐藏侧边栏 */}
|
||||
<div className='hidden md:flex'>
|
||||
<aside
|
||||
data-sidebar
|
||||
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-gray-700/50 ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
style={{
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* 顶部 Logo 区域 */}
|
||||
<div className='relative h-16'>
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
|
||||
isCollapsed ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div className='w-[calc(100%-4rem)] flex justify-center'>
|
||||
{!isCollapsed && <Logo />}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700/50 ${
|
||||
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
|
||||
}`}
|
||||
>
|
||||
<Menu className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 首页和搜索导航 */}
|
||||
<nav className='px-2 mt-4 space-y-1'>
|
||||
<Link
|
||||
href='/'
|
||||
onClick={() => setActive('/')}
|
||||
data-active={active === '/'}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
首页
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href='/search'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSearchClick();
|
||||
setActive('/search');
|
||||
}}
|
||||
data-active={active === '/search'}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
搜索
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className='flex-1 overflow-y-auto px-2 pt-4'>
|
||||
<div className='space-y-1'>
|
||||
{menuItems.map((item) => {
|
||||
// 检查当前路径是否匹配这个菜单项
|
||||
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
||||
|
||||
// 解码URL以进行正确的比较
|
||||
const decodedActive = decodeURIComponent(active);
|
||||
const decodedItemHref = decodeURIComponent(item.href);
|
||||
|
||||
const isActive =
|
||||
decodedActive === decodedItemHref ||
|
||||
(decodedActive.startsWith('/douban') &&
|
||||
decodedActive.includes(`type=${typeMatch}`));
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
data-active={isActive}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-sm text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div
|
||||
className={`transition-all duration-300 sidebar-offset ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
28
src/components/SiteProvider.tsx
Normal file
28
src/components/SiteProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useContext } from 'react';
|
||||
|
||||
const SiteContext = createContext<{ siteName: string; announcement?: string }>({
|
||||
// 默认值
|
||||
siteName: 'MoonTV',
|
||||
announcement:
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
});
|
||||
|
||||
export const useSite = () => useContext(SiteContext);
|
||||
|
||||
export function SiteProvider({
|
||||
children,
|
||||
siteName,
|
||||
announcement,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
siteName: string;
|
||||
announcement?: string;
|
||||
}) {
|
||||
return (
|
||||
<SiteContext.Provider value={{ siteName, announcement }}>
|
||||
{children}
|
||||
</SiteContext.Provider>
|
||||
);
|
||||
}
|
||||
18
src/components/ThemeProvider.tsx
Normal file
18
src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import type { ThemeProviderProps } from 'next-themes';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import * as React from 'react';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
70
src/components/ThemeToggle.tsx
Normal file
70
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
|
||||
const setThemeColor = (theme?: string) => {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta) {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'theme-color';
|
||||
meta.content = theme === 'dark' ? '#0c111c' : '#f9fbfe';
|
||||
document.head.appendChild(meta);
|
||||
} else {
|
||||
meta.setAttribute('content', theme === 'dark' ? '#0c111c' : '#f9fbfe');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 监听主题变化和路由变化,确保主题色始终同步
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
setThemeColor(resolvedTheme);
|
||||
}
|
||||
}, [mounted, resolvedTheme, pathname]);
|
||||
|
||||
if (!mounted) {
|
||||
// 渲染一个占位符以避免布局偏移
|
||||
return <div className='w-10 h-10' />;
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
// 检查浏览器是否支持 View Transitions API
|
||||
const targetTheme = resolvedTheme === 'dark' ? 'light' : 'dark';
|
||||
setThemeColor(targetTheme);
|
||||
if (!(document as any).startViewTransition) {
|
||||
setTheme(targetTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
(document as any).startViewTransition(() => {
|
||||
setTheme(targetTheme);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<Sun className='w-full h-full' />
|
||||
) : (
|
||||
<Moon className='w-full h-full' />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
972
src/components/UserMenu.tsx
Normal file
972
src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,972 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Settings,
|
||||
Shield,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
|
||||
|
||||
import { VersionPanel } from './VersionPanel';
|
||||
|
||||
interface AuthInfo {
|
||||
username?: string;
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
}
|
||||
|
||||
export const UserMenu: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 设置相关状态
|
||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
|
||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||
const [doubanDataSource, setDoubanDataSource] = useState('direct');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('direct');
|
||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
||||
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
||||
useState(false);
|
||||
|
||||
// 豆瓣数据源选项
|
||||
const doubanDataSourceOptions = [
|
||||
{ value: 'direct', label: '直连(服务器直接请求豆瓣)' },
|
||||
{ value: 'cors-proxy-zwei', label: 'Cors Proxy By Zwei' },
|
||||
{
|
||||
value: 'cmliussss-cdn-tencent',
|
||||
label: '豆瓣 CDN By CMLiussss(腾讯云)',
|
||||
},
|
||||
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
|
||||
{ value: 'cors-anywhere', label: 'Cors Anywhere(20 qpm)' },
|
||||
{ value: 'custom', label: '自定义代理' },
|
||||
];
|
||||
|
||||
// 豆瓣图片代理选项
|
||||
const doubanImageProxyTypeOptions = [
|
||||
{ value: 'direct', label: '直连(浏览器直接请求豆瓣)' },
|
||||
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
|
||||
{ value: 'img3', label: '豆瓣精品 CDN(阿里云)' },
|
||||
{
|
||||
value: 'cmliussss-cdn-tencent',
|
||||
label: '豆瓣 CDN By CMLiussss(腾讯云)',
|
||||
},
|
||||
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
|
||||
{ value: 'custom', label: '自定义代理' },
|
||||
];
|
||||
|
||||
// 修改密码相关状态
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
// 版本检查相关状态
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
// 确保组件已挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 获取认证信息和存储类型
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const auth = getAuthInfoFromBrowserCookie();
|
||||
setAuthInfo(auth);
|
||||
|
||||
const type =
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
|
||||
setStorageType(type);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 从 localStorage 读取设置
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedAggregateSearch = localStorage.getItem(
|
||||
'defaultAggregateSearch'
|
||||
);
|
||||
if (savedAggregateSearch !== null) {
|
||||
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
|
||||
}
|
||||
|
||||
const savedDoubanDataSource = localStorage.getItem('doubanDataSource');
|
||||
const defaultDoubanProxyType =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'direct';
|
||||
if (savedDoubanDataSource !== null) {
|
||||
setDoubanDataSource(savedDoubanDataSource);
|
||||
} else if (defaultDoubanProxyType) {
|
||||
setDoubanDataSource(defaultDoubanProxyType);
|
||||
}
|
||||
|
||||
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
|
||||
const defaultDoubanProxy =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
|
||||
if (savedDoubanProxyUrl !== null) {
|
||||
setDoubanProxyUrl(savedDoubanProxyUrl);
|
||||
} else if (defaultDoubanProxy) {
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
}
|
||||
|
||||
const savedDoubanImageProxyType = localStorage.getItem(
|
||||
'doubanImageProxyType'
|
||||
);
|
||||
const defaultDoubanImageProxyType =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'direct';
|
||||
if (savedDoubanImageProxyType !== null) {
|
||||
setDoubanImageProxyType(savedDoubanImageProxyType);
|
||||
} else if (defaultDoubanImageProxyType) {
|
||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||
}
|
||||
|
||||
const savedDoubanImageProxyUrl = localStorage.getItem(
|
||||
'doubanImageProxyUrl'
|
||||
);
|
||||
const defaultDoubanImageProxyUrl =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
|
||||
if (savedDoubanImageProxyUrl !== null) {
|
||||
setDoubanImageProxyUrl(savedDoubanImageProxyUrl);
|
||||
} else if (defaultDoubanImageProxyUrl) {
|
||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||
}
|
||||
|
||||
const savedEnableOptimization =
|
||||
localStorage.getItem('enableOptimization');
|
||||
if (savedEnableOptimization !== null) {
|
||||
setEnableOptimization(JSON.parse(savedEnableOptimization));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 版本检查
|
||||
useEffect(() => {
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
const status = await checkForUpdates();
|
||||
setUpdateStatus(status);
|
||||
} catch (error) {
|
||||
console.warn('版本检查失败:', error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkUpdate();
|
||||
}, []);
|
||||
|
||||
// 点击外部区域关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isDoubanDropdownOpen) {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-dropdown="douban-datasource"]')) {
|
||||
setIsDoubanDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isDoubanDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isDoubanDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isDoubanImageProxyDropdownOpen) {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-dropdown="douban-image-proxy"]')) {
|
||||
setIsDoubanImageProxyDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isDoubanImageProxyDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isDoubanImageProxyDropdownOpen]);
|
||||
|
||||
const handleMenuClick = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('注销请求失败:', error);
|
||||
}
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
const handleAdminPanel = () => {
|
||||
router.push('/admin');
|
||||
};
|
||||
|
||||
const handleChangePassword = () => {
|
||||
setIsOpen(false);
|
||||
setIsChangePasswordOpen(true);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordError('');
|
||||
};
|
||||
|
||||
const handleCloseChangePassword = () => {
|
||||
setIsChangePasswordOpen(false);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordError('');
|
||||
};
|
||||
|
||||
const handleSubmitChangePassword = async () => {
|
||||
setPasswordError('');
|
||||
|
||||
// 验证密码
|
||||
if (!newPassword) {
|
||||
setPasswordError('新密码不得为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setPasswordError(data.error || '修改密码失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 修改成功,关闭弹窗并登出
|
||||
setIsChangePasswordOpen(false);
|
||||
await handleLogout();
|
||||
} catch (error) {
|
||||
setPasswordError('网络错误,请稍后重试');
|
||||
} finally {
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettings = () => {
|
||||
setIsOpen(false);
|
||||
setIsSettingsOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseSettings = () => {
|
||||
setIsSettingsOpen(false);
|
||||
};
|
||||
|
||||
// 设置相关的处理函数
|
||||
const handleAggregateToggle = (value: boolean) => {
|
||||
setDefaultAggregateSearch(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanProxyUrlChange = (value: string) => {
|
||||
setDoubanProxyUrl(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('doubanProxyUrl', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptimizationToggle = (value: boolean) => {
|
||||
setEnableOptimization(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanDataSourceChange = (value: string) => {
|
||||
setDoubanDataSource(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('doubanDataSource', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanImageProxyTypeChange = (value: string) => {
|
||||
setDoubanImageProxyType(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('doubanImageProxyType', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanImageProxyUrlChange = (value: string) => {
|
||||
setDoubanImageProxyUrl(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('doubanImageProxyUrl', value);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取感谢信息
|
||||
const getThanksInfo = (dataSource: string) => {
|
||||
switch (dataSource) {
|
||||
case 'cors-proxy-zwei':
|
||||
return {
|
||||
text: 'Thanks to @Zwei',
|
||||
url: 'https://github.com/bestzwei',
|
||||
};
|
||||
case 'cmliussss-cdn-tencent':
|
||||
case 'cmliussss-cdn-ali':
|
||||
return {
|
||||
text: 'Thanks to @CMLiussss',
|
||||
url: 'https://github.com/cmliu',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = () => {
|
||||
const defaultDoubanProxyType =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'direct';
|
||||
const defaultDoubanProxy =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
|
||||
const defaultDoubanImageProxyType =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'direct';
|
||||
const defaultDoubanImageProxyUrl =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
|
||||
|
||||
setDefaultAggregateSearch(true);
|
||||
setEnableOptimization(true);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
setDoubanDataSource(defaultDoubanProxyType);
|
||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否显示管理面板按钮
|
||||
const showAdminPanel =
|
||||
authInfo?.role === 'owner' || authInfo?.role === 'admin';
|
||||
|
||||
// 检查是否显示修改密码按钮
|
||||
const showChangePassword =
|
||||
authInfo?.role !== 'owner' && storageType !== 'localstorage';
|
||||
|
||||
// 角色中文映射
|
||||
const getRoleText = (role?: string) => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return '站长';
|
||||
case 'admin':
|
||||
return '管理员';
|
||||
case 'user':
|
||||
return '用户';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 菜单面板内容
|
||||
const menuPanel = (
|
||||
<>
|
||||
{/* 背景遮罩 - 普通菜单无需模糊 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-transparent z-[1000]'
|
||||
onClick={handleCloseMenu}
|
||||
/>
|
||||
|
||||
{/* 菜单面板 */}
|
||||
<div className='fixed top-14 right-4 w-56 bg-white dark:bg-gray-900 rounded-lg shadow-xl z-[1001] border border-gray-200/50 dark:border-gray-700/50 overflow-hidden select-none'>
|
||||
{/* 用户信息区域 */}
|
||||
<div className='px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800 dark:to-gray-800/50'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
当前用户
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
(authInfo?.role || 'user') === 'owner'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: (authInfo?.role || 'user') === 'admin'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
}`}
|
||||
>
|
||||
{getRoleText(authInfo?.role || 'user')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='font-semibold text-gray-900 dark:text-gray-100 text-sm truncate'>
|
||||
{authInfo?.username || 'default'}
|
||||
</div>
|
||||
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
|
||||
数据存储:
|
||||
{storageType === 'localstorage' ? '本地' : storageType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className='py-1'>
|
||||
{/* 设置按钮 */}
|
||||
<button
|
||||
onClick={handleSettings}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||
>
|
||||
<Settings className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>设置</span>
|
||||
</button>
|
||||
|
||||
{/* 管理面板按钮 */}
|
||||
{showAdminPanel && (
|
||||
<button
|
||||
onClick={handleAdminPanel}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||
>
|
||||
<Shield className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>管理面板</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 修改密码按钮 */}
|
||||
{showChangePassword && (
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||
>
|
||||
<KeyRound className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>修改密码</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm'
|
||||
>
|
||||
<LogOut className='w-4 h-4' />
|
||||
<span className='font-medium'>登出</span>
|
||||
</button>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 版本信息 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVersionPanelOpen(true);
|
||||
handleCloseMenu();
|
||||
}}
|
||||
className='w-full px-3 py-2 text-center flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors text-xs'
|
||||
>
|
||||
<div className='flex items-center gap-1'>
|
||||
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
||||
{!isChecking &&
|
||||
updateStatus &&
|
||||
updateStatus !== UpdateStatus.FETCH_FAILED && (
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full -translate-y-2 ${
|
||||
updateStatus === UpdateStatus.HAS_UPDATE
|
||||
? 'bg-yellow-500'
|
||||
: updateStatus === UpdateStatus.NO_UPDATE
|
||||
? 'bg-green-400'
|
||||
: ''
|
||||
}`}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// 设置面板内容
|
||||
const settingsPanel = (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={handleCloseSettings}
|
||||
/>
|
||||
|
||||
{/* 设置面板 */}
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6 overflow-y-auto'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
本地设置
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleResetSettings}
|
||||
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
|
||||
title='重置为默认设置'
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseSettings}
|
||||
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='Close'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 设置项 */}
|
||||
<div className='space-y-6'>
|
||||
{/* 豆瓣数据源选择 */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
豆瓣数据代理
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
选择获取豆瓣数据的方式
|
||||
</p>
|
||||
</div>
|
||||
<div className='relative' data-dropdown='douban-datasource'>
|
||||
{/* 自定义下拉选择框 */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
{
|
||||
doubanDataSourceOptions.find(
|
||||
(option) => option.value === doubanDataSource
|
||||
)?.label
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* 下拉箭头 */}
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 下拉选项列表 */}
|
||||
{isDoubanDropdownOpen && (
|
||||
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||
{doubanDataSourceOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleDoubanDataSourceChange(option.value);
|
||||
setIsDoubanDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
doubanDataSource === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{doubanDataSource === option.value && (
|
||||
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 感谢信息 */}
|
||||
{getThanksInfo(doubanDataSource) && (
|
||||
<div className='mt-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
window.open(getThanksInfo(doubanDataSource)!.url, '_blank')
|
||||
}
|
||||
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
|
||||
>
|
||||
<span className='font-medium'>
|
||||
{getThanksInfo(doubanDataSource)!.text}
|
||||
</span>
|
||||
<ExternalLink className='w-3.5 opacity-70' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
|
||||
{doubanDataSource === 'custom' && (
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
豆瓣代理地址
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
自定义代理服务器地址
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||
value={doubanProxyUrl}
|
||||
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 豆瓣图片代理设置 */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
豆瓣图片代理
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
选择获取豆瓣图片的方式
|
||||
</p>
|
||||
</div>
|
||||
<div className='relative' data-dropdown='douban-image-proxy'>
|
||||
{/* 自定义下拉选择框 */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setIsDoubanImageProxyDropdownOpen(
|
||||
!isDoubanImageProxyDropdownOpen
|
||||
)
|
||||
}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
{
|
||||
doubanImageProxyTypeOptions.find(
|
||||
(option) => option.value === doubanImageProxyType
|
||||
)?.label
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* 下拉箭头 */}
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 下拉选项列表 */}
|
||||
{isDoubanImageProxyDropdownOpen && (
|
||||
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||
{doubanImageProxyTypeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleDoubanImageProxyTypeChange(option.value);
|
||||
setIsDoubanImageProxyDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
doubanImageProxyType === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{doubanImageProxyType === option.value && (
|
||||
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 感谢信息 */}
|
||||
{getThanksInfo(doubanImageProxyType) && (
|
||||
<div className='mt-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
window.open(
|
||||
getThanksInfo(doubanImageProxyType)!.url,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
|
||||
>
|
||||
<span className='font-medium'>
|
||||
{getThanksInfo(doubanImageProxyType)!.text}
|
||||
</span>
|
||||
<ExternalLink className='w-3.5 opacity-70' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 豆瓣图片代理地址设置 - 仅在选择自定义代理时显示 */}
|
||||
{doubanImageProxyType === 'custom' && (
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
豆瓣图片代理地址
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
自定义图片代理服务器地址
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||
value={doubanImageProxyUrl}
|
||||
onChange={(e) =>
|
||||
handleDoubanImageProxyUrlChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 默认聚合搜索结果 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
默认聚合搜索结果
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
搜索时默认按标题和年份聚合显示结果
|
||||
</p>
|
||||
</div>
|
||||
<label className='flex items-center cursor-pointer'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={defaultAggregateSearch}
|
||||
onChange={(e) => handleAggregateToggle(e.target.checked)}
|
||||
/>
|
||||
<div className='w-11 h-6 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-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 优选和测速 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
启用优选和测速
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
如出现播放器劫持问题可关闭
|
||||
</p>
|
||||
</div>
|
||||
<label className='flex items-center cursor-pointer'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={enableOptimization}
|
||||
onChange={(e) => handleOptimizationToggle(e.target.checked)}
|
||||
/>
|
||||
<div className='w-11 h-6 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-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部说明 */}
|
||||
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||
这些设置保存在本地浏览器中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// 修改密码面板内容
|
||||
const changePasswordPanel = (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={handleCloseChangePassword}
|
||||
/>
|
||||
|
||||
{/* 修改密码面板 */}
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
修改密码
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCloseChangePassword}
|
||||
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='Close'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<div className='space-y-4'>
|
||||
{/* 新密码输入 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
新密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||
placeholder='请输入新密码'
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 确认密码输入 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||
placeholder='请再次输入新密码'
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{passwordError && (
|
||||
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'>
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={handleCloseChangePassword}
|
||||
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors'
|
||||
disabled={passwordLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitChangePassword}
|
||||
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
disabled={passwordLoading || !newPassword || !confirmPassword}
|
||||
>
|
||||
{passwordLoading ? '修改中...' : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 底部说明 */}
|
||||
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||
修改密码后需要重新登录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative'>
|
||||
<button
|
||||
onClick={handleMenuClick}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||
aria-label='User Menu'
|
||||
>
|
||||
<User className='w-full h-full' />
|
||||
</button>
|
||||
{updateStatus === UpdateStatus.HAS_UPDATE && (
|
||||
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-yellow-500 rounded-full'></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用 Portal 将菜单面板渲染到 document.body */}
|
||||
{isOpen && mounted && createPortal(menuPanel, document.body)}
|
||||
|
||||
{/* 使用 Portal 将设置面板渲染到 document.body */}
|
||||
{isSettingsOpen && mounted && createPortal(settingsPanel, document.body)}
|
||||
|
||||
{/* 使用 Portal 将修改密码面板渲染到 document.body */}
|
||||
{isChangePasswordOpen &&
|
||||
mounted &&
|
||||
createPortal(changePasswordPanel, document.body)}
|
||||
|
||||
{/* 版本面板 */}
|
||||
<VersionPanel
|
||||
isOpen={isVersionPanelOpen}
|
||||
onClose={() => setIsVersionPanelOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
520
src/components/VersionPanel.tsx
Normal file
520
src/components/VersionPanel.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
/* eslint-disable no-console,react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bug,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { changelog, ChangelogEntry } from '@/lib/changelog';
|
||||
import { compareVersions, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
|
||||
|
||||
interface VersionPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface RemoteChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
added: string[];
|
||||
changed: string[];
|
||||
fixed: string[];
|
||||
}
|
||||
|
||||
export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [remoteChangelog, setRemoteChangelog] = useState<ChangelogEntry[]>([]);
|
||||
const [hasUpdate, setIsHasUpdate] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState<string>('');
|
||||
const [showRemoteContent, setShowRemoteContent] = useState(false);
|
||||
|
||||
// 确保组件已挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
// 获取远程变更日志
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchRemoteChangelog();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 获取远程变更日志
|
||||
const fetchRemoteChangelog = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://raw.githubusercontent.com/LunaTechLab/MoonTV/main/CHANGELOG'
|
||||
);
|
||||
if (response.ok) {
|
||||
const content = await response.text();
|
||||
const parsed = parseChangelog(content);
|
||||
setRemoteChangelog(parsed);
|
||||
|
||||
// 检查是否有更新
|
||||
if (parsed.length > 0) {
|
||||
const latest = parsed[0];
|
||||
setLatestVersion(latest.version);
|
||||
setIsHasUpdate(
|
||||
compareVersions(latest.version) === UpdateStatus.HAS_UPDATE
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
'获取远程变更日志失败:',
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取远程变更日志失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 解析变更日志格式
|
||||
const parseChangelog = (content: string): RemoteChangelogEntry[] => {
|
||||
const lines = content.split('\n');
|
||||
const versions: RemoteChangelogEntry[] = [];
|
||||
let currentVersion: RemoteChangelogEntry | null = null;
|
||||
let currentSection: string | null = null;
|
||||
let inVersionContent = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 匹配版本行: ## [X.Y.Z] - YYYY-MM-DD
|
||||
const versionMatch = trimmedLine.match(
|
||||
/^## \[([\d.]+)\] - (\d{4}-\d{2}-\d{2})$/
|
||||
);
|
||||
if (versionMatch) {
|
||||
if (currentVersion) {
|
||||
versions.push(currentVersion);
|
||||
}
|
||||
|
||||
currentVersion = {
|
||||
version: versionMatch[1],
|
||||
date: versionMatch[2],
|
||||
added: [],
|
||||
changed: [],
|
||||
fixed: [],
|
||||
};
|
||||
currentSection = null;
|
||||
inVersionContent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果遇到下一个版本或到达文件末尾,停止处理当前版本
|
||||
if (inVersionContent && currentVersion) {
|
||||
// 匹配章节标题
|
||||
if (trimmedLine === '### Added') {
|
||||
currentSection = 'added';
|
||||
continue;
|
||||
} else if (trimmedLine === '### Changed') {
|
||||
currentSection = 'changed';
|
||||
continue;
|
||||
} else if (trimmedLine === '### Fixed') {
|
||||
currentSection = 'fixed';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 匹配条目: - 内容
|
||||
if (trimmedLine.startsWith('- ') && currentSection) {
|
||||
const entry = trimmedLine.substring(2);
|
||||
if (currentSection === 'added') {
|
||||
currentVersion.added.push(entry);
|
||||
} else if (currentSection === 'changed') {
|
||||
currentVersion.changed.push(entry);
|
||||
} else if (currentSection === 'fixed') {
|
||||
currentVersion.fixed.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一个版本
|
||||
if (currentVersion) {
|
||||
versions.push(currentVersion);
|
||||
}
|
||||
|
||||
return versions;
|
||||
};
|
||||
|
||||
// 渲染变更日志条目
|
||||
const renderChangelogEntry = (
|
||||
entry: ChangelogEntry | RemoteChangelogEntry,
|
||||
isCurrentVersion = false,
|
||||
isRemote = false
|
||||
) => {
|
||||
const isUpdate = isRemote && hasUpdate && entry.version === latestVersion;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.version}
|
||||
className={`p-4 rounded-lg border ${
|
||||
isCurrentVersion
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: isUpdate
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{/* 版本标题 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
v{entry.version}
|
||||
</h4>
|
||||
{isCurrentVersion && (
|
||||
<span className='px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full'>
|
||||
当前版本
|
||||
</span>
|
||||
)}
|
||||
{isUpdate && (
|
||||
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
|
||||
<Download className='w-3 h-3' />
|
||||
可更新
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
|
||||
{entry.date}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 变更内容 */}
|
||||
<div className='space-y-3'>
|
||||
{entry.added.length > 0 && (
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-green-700 dark:text-green-400 mb-2 flex items-center gap-1'>
|
||||
<Plus className='w-4 h-4' />
|
||||
新增功能
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.added.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-green-500 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.changed.length > 0 && (
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-blue-700 dark:text-blue-400 mb-2 flex items-center gap-1'>
|
||||
<RefreshCw className='w-4 h-4' />
|
||||
功能改进
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.changed.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.fixed.length > 0 && (
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
|
||||
<Bug className='w-4 h-4' />
|
||||
问题修复
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.fixed.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 版本面板内容
|
||||
const versionPanelContent = (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 版本面板 */}
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between p-3 sm:p-6 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<h3 className='text-lg sm:text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
版本信息
|
||||
</h3>
|
||||
<div className='flex flex-wrap items-center gap-1 sm:gap-2'>
|
||||
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full'>
|
||||
v{CURRENT_VERSION}
|
||||
</span>
|
||||
{hasUpdate && (
|
||||
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
|
||||
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
|
||||
<span className='hidden sm:inline'>有新版本可用</span>
|
||||
<span className='sm:hidden'>可更新</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='w-6 h-6 sm:w-8 sm:h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='关闭'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className='p-3 sm:p-6 overflow-y-auto max-h-[calc(95vh-140px)] sm:max-h-[calc(90vh-120px)]'>
|
||||
<div className='space-y-3 sm:space-y-6'>
|
||||
{/* 远程更新信息 */}
|
||||
{hasUpdate && (
|
||||
<div className='bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 sm:p-4'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-yellow-100 dark:bg-yellow-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
|
||||
<Download className='w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-sm sm:text-base font-semibold text-yellow-800 dark:text-yellow-200'>
|
||||
发现新版本
|
||||
</h4>
|
||||
<p className='text-xs sm:text-sm text-yellow-700 dark:text-yellow-300 break-all'>
|
||||
v{CURRENT_VERSION} → v{latestVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/LunaTechLab/MoonTV'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
|
||||
>
|
||||
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
|
||||
前往仓库
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 当前为最新版本信息 */}
|
||||
{!hasUpdate && (
|
||||
<div className='bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 sm:p-4'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-green-100 dark:bg-green-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
|
||||
<CheckCircle className='w-4 h-4 sm:w-5 sm:h-5 text-green-600 dark:text-green-400' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-sm sm:text-base font-semibold text-green-800 dark:text-green-200'>
|
||||
当前为最新版本
|
||||
</h4>
|
||||
<p className='text-xs sm:text-sm text-green-700 dark:text-green-300 break-all'>
|
||||
已是最新版本 v{CURRENT_VERSION}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/LunaTechLab/MoonTV'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
|
||||
>
|
||||
<CheckCircle className='w-3 h-3 sm:w-4 sm:h-4' />
|
||||
前往仓库
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 远程可更新内容 */}
|
||||
{hasUpdate && (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-3'>
|
||||
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
|
||||
<Download className='w-5 h-5 text-yellow-500' />
|
||||
远程更新内容
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setShowRemoteContent(!showRemoteContent)}
|
||||
className='inline-flex items-center justify-center gap-2 px-3 py-1.5 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 dark:bg-yellow-800/30 dark:hover:bg-yellow-800/50 dark:text-yellow-200 rounded-lg transition-colors text-sm w-full sm:w-auto'
|
||||
>
|
||||
{showRemoteContent ? (
|
||||
<>
|
||||
<ChevronUp className='w-4 h-4' />
|
||||
收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className='w-4 h-4' />
|
||||
查看更新内容
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRemoteContent && remoteChangelog.length > 0 && (
|
||||
<div className='space-y-4'>
|
||||
{remoteChangelog
|
||||
.filter((entry) => {
|
||||
// 找到第一个本地版本,过滤掉本地已有的版本
|
||||
const localVersions = changelog.map(
|
||||
(local) => local.version
|
||||
);
|
||||
return !localVersions.includes(entry.version);
|
||||
})
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-lg border ${
|
||||
entry.version === latestVersion
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
v{entry.version}
|
||||
</h4>
|
||||
{entry.version === latestVersion && (
|
||||
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
|
||||
远程最新
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
|
||||
{entry.date}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry.added && entry.added.length > 0 && (
|
||||
<div className='mb-3'>
|
||||
<h5 className='text-sm font-medium text-green-600 dark:text-green-400 mb-2 flex items-center gap-1'>
|
||||
<Plus className='w-4 h-4' />
|
||||
新增功能
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.added.map((item, itemIndex) => (
|
||||
<li
|
||||
key={itemIndex}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-green-400 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.changed && entry.changed.length > 0 && (
|
||||
<div className='mb-3'>
|
||||
<h5 className='text-sm font-medium text-blue-600 dark:text-blue-400 mb-2 flex items-center gap-1'>
|
||||
<RefreshCw className='w-4 h-4' />
|
||||
功能改进
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.changed.map((item, itemIndex) => (
|
||||
<li
|
||||
key={itemIndex}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-blue-400 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.fixed && entry.fixed.length > 0 && (
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
|
||||
<Bug className='w-4 h-4' />
|
||||
问题修复
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.fixed.map((item, itemIndex) => (
|
||||
<li
|
||||
key={itemIndex}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变更日志标题 */}
|
||||
<div className='border-b border-gray-200 dark:border-gray-700 pb-4'>
|
||||
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 pb-3 sm:pb-4'>
|
||||
变更日志
|
||||
</h4>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* 本地变更日志 */}
|
||||
{changelog.map((entry) =>
|
||||
renderChangelogEntry(
|
||||
entry,
|
||||
entry.version === CURRENT_VERSION,
|
||||
false
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// 使用 Portal 渲染到 document.body
|
||||
if (!mounted || !isOpen) return null;
|
||||
|
||||
return createPortal(versionPanelContent, document.body);
|
||||
};
|
||||
403
src/components/VideoCard.tsx
Normal file
403
src/components/VideoCard.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
deleteFavorite,
|
||||
deletePlayRecord,
|
||||
generateStorageKey,
|
||||
isFavorited,
|
||||
saveFavorite,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
|
||||
interface VideoCardProps {
|
||||
id?: string;
|
||||
source?: string;
|
||||
title?: string;
|
||||
query?: string;
|
||||
poster?: string;
|
||||
episodes?: number;
|
||||
source_name?: string;
|
||||
progress?: number;
|
||||
year?: string;
|
||||
from: 'playrecord' | 'favorite' | 'search' | 'douban';
|
||||
currentEpisode?: number;
|
||||
douban_id?: number;
|
||||
onDelete?: () => void;
|
||||
rate?: string;
|
||||
items?: SearchResult[];
|
||||
type?: string;
|
||||
isBangumi?: boolean;
|
||||
}
|
||||
|
||||
export default function VideoCard({
|
||||
id,
|
||||
title = '',
|
||||
query = '',
|
||||
poster = '',
|
||||
episodes,
|
||||
source,
|
||||
source_name,
|
||||
progress = 0,
|
||||
year,
|
||||
from,
|
||||
currentEpisode,
|
||||
douban_id,
|
||||
onDelete,
|
||||
rate,
|
||||
items,
|
||||
type = '',
|
||||
isBangumi = false,
|
||||
}: VideoCardProps) {
|
||||
const router = useRouter();
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const isAggregate = from === 'search' && !!items?.length;
|
||||
|
||||
const aggregateData = useMemo(() => {
|
||||
if (!isAggregate || !items) return null;
|
||||
const countMap = new Map<number, number>();
|
||||
const episodeCountMap = new Map<number, number>();
|
||||
items.forEach((item) => {
|
||||
if (item.douban_id && item.douban_id !== 0) {
|
||||
countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1);
|
||||
}
|
||||
const len = item.episodes?.length || 0;
|
||||
if (len > 0) {
|
||||
episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const getMostFrequent = (map: Map<number, number>) => {
|
||||
let maxCount = 0;
|
||||
let result: number | undefined;
|
||||
map.forEach((cnt, key) => {
|
||||
if (cnt > maxCount) {
|
||||
maxCount = cnt;
|
||||
result = key;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
first: items[0],
|
||||
mostFrequentDoubanId: getMostFrequent(countMap),
|
||||
mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0,
|
||||
};
|
||||
}, [isAggregate, items]);
|
||||
|
||||
const actualTitle = aggregateData?.first.title ?? title;
|
||||
const actualPoster = aggregateData?.first.poster ?? poster;
|
||||
const actualSource = aggregateData?.first.source ?? source;
|
||||
const actualId = aggregateData?.first.id ?? id;
|
||||
const actualDoubanId = aggregateData?.mostFrequentDoubanId ?? douban_id;
|
||||
const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes;
|
||||
const actualYear = aggregateData?.first.year ?? year;
|
||||
const actualQuery = query || '';
|
||||
const actualSearchType = isAggregate
|
||||
? aggregateData?.first.episodes?.length === 1
|
||||
? 'movie'
|
||||
: 'tv'
|
||||
: type;
|
||||
|
||||
// 获取收藏状态
|
||||
useEffect(() => {
|
||||
if (from === 'douban' || !actualSource || !actualId) return;
|
||||
|
||||
const fetchFavoriteStatus = async () => {
|
||||
try {
|
||||
const fav = await isFavorited(actualSource, actualId);
|
||||
setFavorited(fav);
|
||||
} catch (err) {
|
||||
throw new Error('检查收藏状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
fetchFavoriteStatus();
|
||||
|
||||
// 监听收藏状态更新事件
|
||||
const storageKey = generateStorageKey(actualSource, actualId);
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'favoritesUpdated',
|
||||
(newFavorites: Record<string, any>) => {
|
||||
// 检查当前项目是否在新的收藏列表中
|
||||
const isNowFavorited = !!newFavorites[storageKey];
|
||||
setFavorited(isNowFavorited);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [from, actualSource, actualId]);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (from === 'douban' || !actualSource || !actualId) return;
|
||||
try {
|
||||
if (favorited) {
|
||||
// 如果已收藏,删除收藏
|
||||
await deleteFavorite(actualSource, actualId);
|
||||
setFavorited(false);
|
||||
} else {
|
||||
// 如果未收藏,添加收藏
|
||||
await saveFavorite(actualSource, actualId, {
|
||||
title: actualTitle,
|
||||
source_name: source_name || '',
|
||||
year: actualYear || '',
|
||||
cover: actualPoster,
|
||||
total_episodes: actualEpisodes ?? 1,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
setFavorited(true);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error('切换收藏状态失败');
|
||||
}
|
||||
},
|
||||
[
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
actualTitle,
|
||||
source_name,
|
||||
actualYear,
|
||||
actualPoster,
|
||||
actualEpisodes,
|
||||
favorited,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDeleteRecord = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (from !== 'playrecord' || !actualSource || !actualId) return;
|
||||
try {
|
||||
await deletePlayRecord(actualSource, actualId);
|
||||
onDelete?.();
|
||||
} catch (err) {
|
||||
throw new Error('删除播放记录失败');
|
||||
}
|
||||
},
|
||||
[from, actualSource, actualId, onDelete]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (from === 'douban') {
|
||||
router.push(
|
||||
`/play?title=${encodeURIComponent(actualTitle.trim())}${
|
||||
actualYear ? `&year=${actualYear}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
|
||||
);
|
||||
} else if (actualSource && actualId) {
|
||||
router.push(
|
||||
`/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||
actualTitle
|
||||
)}${actualYear ? `&year=${actualYear}` : ''}${
|
||||
isAggregate ? '&prefer=true' : ''
|
||||
}${
|
||||
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
|
||||
);
|
||||
}
|
||||
}, [
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
router,
|
||||
actualTitle,
|
||||
actualYear,
|
||||
isAggregate,
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
const configs = {
|
||||
playrecord: {
|
||||
showSourceName: true,
|
||||
showProgress: true,
|
||||
showPlayButton: true,
|
||||
showHeart: true,
|
||||
showCheckCircle: true,
|
||||
showDoubanLink: false,
|
||||
showRating: false,
|
||||
},
|
||||
favorite: {
|
||||
showSourceName: true,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: true,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: false,
|
||||
showRating: false,
|
||||
},
|
||||
search: {
|
||||
showSourceName: true,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: !isAggregate,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: !!actualDoubanId,
|
||||
showRating: false,
|
||||
},
|
||||
douban: {
|
||||
showSourceName: false,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: false,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: true,
|
||||
showRating: !!rate,
|
||||
},
|
||||
};
|
||||
return configs[from] || configs.search;
|
||||
}, [from, isAggregate, actualDoubanId, rate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]'
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 海报容器 */}
|
||||
<div className='relative aspect-[2/3] overflow-hidden rounded-lg'>
|
||||
{/* 骨架屏 */}
|
||||
{!isLoading && <ImagePlaceholder aspectRatio='aspect-[2/3]' />}
|
||||
{/* 图片 */}
|
||||
<Image
|
||||
src={processImageUrl(actualPoster)}
|
||||
alt={actualTitle}
|
||||
fill
|
||||
className='object-cover'
|
||||
referrerPolicy='no-referrer'
|
||||
loading='lazy'
|
||||
onLoadingComplete={() => setIsLoading(true)}
|
||||
onError={(e) => {
|
||||
// 图片加载失败时的重试机制
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (!img.dataset.retried) {
|
||||
img.dataset.retried = 'true';
|
||||
setTimeout(() => {
|
||||
img.src = processImageUrl(actualPoster);
|
||||
}, 2000);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 悬浮遮罩 */}
|
||||
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100' />
|
||||
|
||||
{/* 播放按钮 */}
|
||||
{config.showPlayButton && (
|
||||
<div className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'>
|
||||
<PlayCircleIcon
|
||||
size={50}
|
||||
strokeWidth={0.8}
|
||||
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{(config.showHeart || config.showCheckCircle) && (
|
||||
<div className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out group-hover:opacity-100 group-hover:translate-y-0'>
|
||||
{config.showCheckCircle && (
|
||||
<Trash2
|
||||
onClick={handleDeleteRecord}
|
||||
size={20}
|
||||
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
|
||||
/>
|
||||
)}
|
||||
{config.showHeart && (
|
||||
<Heart
|
||||
onClick={handleToggleFavorite}
|
||||
size={20}
|
||||
className={`transition-all duration-300 ease-out ${
|
||||
favorited
|
||||
? 'fill-red-600 stroke-red-600'
|
||||
: 'fill-transparent stroke-white hover:stroke-red-400'
|
||||
} hover:scale-[1.1]`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 徽章 */}
|
||||
{config.showRating && rate && (
|
||||
<div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
|
||||
{rate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actualEpisodes && actualEpisodes > 1 && (
|
||||
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
|
||||
{currentEpisode
|
||||
? `${currentEpisode}/${actualEpisodes}`
|
||||
: actualEpisodes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 豆瓣链接 */}
|
||||
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
|
||||
<a
|
||||
href={
|
||||
isBangumi
|
||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
|
||||
}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0'
|
||||
>
|
||||
<div className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
|
||||
<Link size={16} />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{config.showProgress && progress !== undefined && (
|
||||
<div className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'>
|
||||
<div
|
||||
className='h-full bg-green-500 transition-all duration-500 ease-out'
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标题与来源 */}
|
||||
<div className='mt-2 text-center'>
|
||||
<div className='relative'>
|
||||
<span className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'>
|
||||
{actualTitle}
|
||||
</span>
|
||||
{/* 自定义 tooltip */}
|
||||
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none'>
|
||||
{actualTitle}
|
||||
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
|
||||
</div>
|
||||
</div>
|
||||
{config.showSourceName && source_name && (
|
||||
<span className='block text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-in-out group-hover:border-green-500/60 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||
{source_name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/WeekdaySelector.tsx
Normal file
74
src/components/WeekdaySelector.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface WeekdaySelectorProps {
|
||||
onWeekdayChange: (weekday: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const weekdays = [
|
||||
{ value: 'Mon', label: '周一', shortLabel: '周一' },
|
||||
{ value: 'Tue', label: '周二', shortLabel: '周二' },
|
||||
{ value: 'Wed', label: '周三', shortLabel: '周三' },
|
||||
{ value: 'Thu', label: '周四', shortLabel: '周四' },
|
||||
{ value: 'Fri', label: '周五', shortLabel: '周五' },
|
||||
{ value: 'Sat', label: '周六', shortLabel: '周六' },
|
||||
{ value: 'Sun', label: '周日', shortLabel: '周日' },
|
||||
];
|
||||
|
||||
const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
|
||||
onWeekdayChange,
|
||||
className = '',
|
||||
}) => {
|
||||
// 获取今天的星期数,默认选中今天
|
||||
const getTodayWeekday = (): string => {
|
||||
const today = new Date().getDay();
|
||||
// getDay() 返回 0-6,0 是周日,1-6 是周一到周六
|
||||
const weekdayMap = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return weekdayMap[today];
|
||||
};
|
||||
|
||||
const [selectedWeekday, setSelectedWeekday] = useState<string>(
|
||||
getTodayWeekday()
|
||||
);
|
||||
|
||||
// 组件初始化时通知父组件默认选中的星期
|
||||
useEffect(() => {
|
||||
onWeekdayChange(getTodayWeekday());
|
||||
}, []); // 只在组件挂载时执行一次
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative inline-flex rounded-full p-0.5 sm:p-1 ${className}`}
|
||||
>
|
||||
{weekdays.map((weekday) => {
|
||||
const isActive = selectedWeekday === weekday.value;
|
||||
return (
|
||||
<button
|
||||
key={weekday.value}
|
||||
onClick={() => {
|
||||
setSelectedWeekday(weekday.value);
|
||||
onWeekdayChange(weekday.value);
|
||||
}}
|
||||
className={`
|
||||
relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap
|
||||
${
|
||||
isActive
|
||||
? 'text-green-600 dark:text-green-400 font-semibold'
|
||||
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
title={weekday.label}
|
||||
>
|
||||
{weekday.shortLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeekdaySelector;
|
||||
41
src/lib/admin.types.ts
Normal file
41
src/lib/admin.types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface AdminConfig {
|
||||
SiteConfig: {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
SearchDownstreamMaxPage: number;
|
||||
SiteInterfaceCacheTime: number;
|
||||
DoubanProxyType: string;
|
||||
DoubanProxy: string;
|
||||
DoubanImageProxyType: string;
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
};
|
||||
UserConfig: {
|
||||
AllowRegister: boolean;
|
||||
Users: {
|
||||
username: string;
|
||||
role: 'user' | 'admin' | 'owner';
|
||||
banned?: boolean;
|
||||
}[];
|
||||
};
|
||||
SourceConfig: {
|
||||
key: string;
|
||||
name: string;
|
||||
api: string;
|
||||
detail?: string;
|
||||
from: 'config' | 'custom';
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
CustomCategories: {
|
||||
name?: string;
|
||||
type: 'movie' | 'tv';
|
||||
query: string;
|
||||
from: 'config' | 'custom';
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AdminConfigResult {
|
||||
Role: 'owner' | 'admin';
|
||||
Config: AdminConfig;
|
||||
}
|
||||
72
src/lib/auth.ts
Normal file
72
src/lib/auth.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// 从cookie获取认证信息 (服务端使用)
|
||||
export function getAuthInfoFromCookie(request: NextRequest): {
|
||||
password?: string;
|
||||
username?: string;
|
||||
signature?: string;
|
||||
timestamp?: number;
|
||||
} | null {
|
||||
const authCookie = request.cookies.get('auth');
|
||||
|
||||
if (!authCookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decodeURIComponent(authCookie.value);
|
||||
const authData = JSON.parse(decoded);
|
||||
return authData;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 从cookie获取认证信息 (客户端使用)
|
||||
export function getAuthInfoFromBrowserCookie(): {
|
||||
password?: string;
|
||||
username?: string;
|
||||
signature?: string;
|
||||
timestamp?: number;
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
} | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析 document.cookie
|
||||
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
|
||||
const trimmed = cookie.trim();
|
||||
const firstEqualIndex = trimmed.indexOf('=');
|
||||
|
||||
if (firstEqualIndex > 0) {
|
||||
const key = trimmed.substring(0, firstEqualIndex);
|
||||
const value = trimmed.substring(firstEqualIndex + 1);
|
||||
if (key && value) {
|
||||
acc[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const authCookie = cookies['auth'];
|
||||
if (!authCookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理可能的双重编码
|
||||
let decoded = decodeURIComponent(authCookie);
|
||||
|
||||
// 如果解码后仍然包含 %,说明是双重编码,需要再次解码
|
||||
if (decoded.includes('%')) {
|
||||
decoded = decodeURIComponent(decoded);
|
||||
}
|
||||
|
||||
const authData = JSON.parse(decoded);
|
||||
return authData;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
29
src/lib/bangumi.client.ts
Normal file
29
src/lib/bangumi.client.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
export interface BangumiCalendarData {
|
||||
weekday: {
|
||||
en: string;
|
||||
};
|
||||
items: {
|
||||
id: number;
|
||||
name: string;
|
||||
name_cn: string;
|
||||
rating: {
|
||||
score: number;
|
||||
};
|
||||
air_date: string;
|
||||
images: {
|
||||
large: string;
|
||||
common: string;
|
||||
medium: string;
|
||||
small: string;
|
||||
grid: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function GetBangumiCalendarData(): Promise<BangumiCalendarData[]> {
|
||||
const response = await fetch('https://api.bgm.tv/calendar');
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
122
src/lib/changelog.ts
Normal file
122
src/lib/changelog.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// 此文件由 scripts/convert-changelog.js 自动生成
|
||||
// 请勿手动编辑
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
added: string[];
|
||||
changed: string[];
|
||||
fixed: string[];
|
||||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: "1.1.1",
|
||||
date: "2025-08-12",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
"修正 zwei 提供的 cors proxy 地址",
|
||||
"移除废弃代码"
|
||||
],
|
||||
fixed: [
|
||||
"[运维] docker workflow release 日期使用东八区日期"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2025-08-12",
|
||||
added: [
|
||||
"每日新番放送功能,展示每日新番放送的番剧"
|
||||
],
|
||||
changed: [
|
||||
// 无变更内容
|
||||
],
|
||||
fixed: [
|
||||
"修复远程 CHANGELOG 无法提取变更内容的问题"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.5",
|
||||
date: "2025-08-12",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
"实现基于 Git 标签的自动 Release 工作流"
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.4",
|
||||
date: "2025-08-11",
|
||||
added: [
|
||||
"优化版本管理工作流,实现单点修改"
|
||||
],
|
||||
changed: [
|
||||
"版本号现在从 CHANGELOG 自动提取,无需手动维护 VERSION.txt"
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.3",
|
||||
date: "2025-08-11",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
"升级播放器 Artplayer 至版本 5.2.5"
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.2",
|
||||
date: "2025-08-11",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
"版本号比较机制恢复为数字比较,仅当最新版本大于本地版本时才认为有更新",
|
||||
"[运维] 自动替换 version.ts 中的版本号为 VERSION.txt 中的版本号"
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.1",
|
||||
date: "2025-08-11",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
// 无变更内容
|
||||
],
|
||||
fixed: [
|
||||
"修复版本检查功能,只要与最新版本号不一致即认为有更新"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.0",
|
||||
date: "2025-08-10",
|
||||
added: [
|
||||
"基于 Semantic Versioning 的版本号机制",
|
||||
"版本信息面板,展示本地变更日志和远程更新日志"
|
||||
],
|
||||
changed: [
|
||||
// 无变更内容
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default changelog;
|
||||
509
src/lib/config.ts
Normal file
509
src/lib/config.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { getStorage } from '@/lib/db';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import runtimeConfig from './runtime';
|
||||
|
||||
export interface ApiSite {
|
||||
key: string;
|
||||
api: string;
|
||||
name: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface ConfigFileStruct {
|
||||
cache_time?: number;
|
||||
api_site: {
|
||||
[key: string]: ApiSite;
|
||||
};
|
||||
custom_category?: {
|
||||
name?: string;
|
||||
type: 'movie' | 'tv';
|
||||
query: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
search: {
|
||||
path: '?ac=videolist&wd=',
|
||||
pagePath: '?ac=videolist&wd={query}&pg={page}',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
path: '?ac=videolist&ids=',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 在模块加载时根据环境决定配置来源
|
||||
let fileConfig: ConfigFileStruct;
|
||||
let cachedConfig: AdminConfig;
|
||||
|
||||
async function initConfig() {
|
||||
if (cachedConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.DOCKER_ENV === 'true') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
const _require = eval('require') as NodeRequire;
|
||||
const fs = _require('fs') as typeof import('fs');
|
||||
const path = _require('path') as typeof import('path');
|
||||
|
||||
const configPath = path.join(process.cwd(), 'config.json');
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
fileConfig = JSON.parse(raw) as ConfigFileStruct;
|
||||
console.log('load dynamic config success');
|
||||
} else {
|
||||
// 默认使用编译时生成的配置
|
||||
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||
}
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType !== 'localstorage') {
|
||||
// 数据库存储,读取并补全管理员配置
|
||||
const storage = getStorage();
|
||||
|
||||
try {
|
||||
// 尝试从数据库获取管理员配置
|
||||
let adminConfig: AdminConfig | null = null;
|
||||
if (storage && typeof (storage as any).getAdminConfig === 'function') {
|
||||
adminConfig = await (storage as any).getAdminConfig();
|
||||
}
|
||||
|
||||
// 获取所有用户名,用于补全 Users
|
||||
let userNames: string[] = [];
|
||||
if (storage && typeof (storage as any).getAllUsers === 'function') {
|
||||
try {
|
||||
userNames = await (storage as any).getAllUsers();
|
||||
} catch (e) {
|
||||
console.error('获取用户列表失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 从文件中获取源信息,用于补全源
|
||||
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||
const customCategories = fileConfig.custom_category || [];
|
||||
|
||||
if (adminConfig) {
|
||||
// 补全 SourceConfig
|
||||
const sourceConfigMap = new Map(
|
||||
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
|
||||
);
|
||||
|
||||
apiSiteEntries.forEach(([key, site]) => {
|
||||
sourceConfigMap.set(key, {
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
|
||||
|
||||
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
|
||||
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
|
||||
adminConfig.SourceConfig.forEach((source) => {
|
||||
if (!apiSiteKeys.has(source.key)) {
|
||||
source.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
// 确保 CustomCategories 被初始化
|
||||
if (!adminConfig.CustomCategories) {
|
||||
adminConfig.CustomCategories = [];
|
||||
}
|
||||
|
||||
// 补全 CustomCategories
|
||||
const customCategoriesMap = new Map(
|
||||
adminConfig.CustomCategories.map((c) => [c.query + c.type, c])
|
||||
);
|
||||
|
||||
customCategories.forEach((category) => {
|
||||
customCategoriesMap.set(category.query + category.type, {
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 检查现有 CustomCategories 是否在 fileConfig.custom_category 中,如果不在则标记为 custom
|
||||
const customCategoriesKeys = new Set(
|
||||
customCategories.map((c) => c.query + c.type)
|
||||
);
|
||||
customCategoriesMap.forEach((category) => {
|
||||
if (!customCategoriesKeys.has(category.query + category.type)) {
|
||||
category.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.CustomCategories = Array.from(customCategoriesMap.values());
|
||||
|
||||
const existedUsers = new Set(
|
||||
(adminConfig.UserConfig.Users || []).map((u) => u.username)
|
||||
);
|
||||
userNames.forEach((uname) => {
|
||||
if (!existedUsers.has(uname)) {
|
||||
adminConfig!.UserConfig.Users.push({
|
||||
username: uname,
|
||||
role: 'user',
|
||||
});
|
||||
}
|
||||
});
|
||||
// 站长
|
||||
const ownerUser = process.env.USERNAME;
|
||||
if (ownerUser) {
|
||||
adminConfig!.UserConfig.Users = adminConfig!.UserConfig.Users.filter(
|
||||
(u) => u.username !== ownerUser
|
||||
);
|
||||
adminConfig!.UserConfig.Users.unshift({
|
||||
username: ownerUser,
|
||||
role: 'owner',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 数据库中没有配置,创建新的管理员配置
|
||||
let allUsers = userNames.map((uname) => ({
|
||||
username: uname,
|
||||
role: 'user',
|
||||
}));
|
||||
const ownerUser = process.env.USERNAME;
|
||||
if (ownerUser) {
|
||||
allUsers = allUsers.filter((u) => u.username !== ownerUser);
|
||||
allUsers.unshift({
|
||||
username: ownerUser,
|
||||
role: 'owner',
|
||||
});
|
||||
}
|
||||
adminConfig = {
|
||||
SiteConfig: {
|
||||
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
DoubanProxyType:
|
||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
DoubanImageProxyType:
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
|
||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||
DisableYellowFilter:
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
},
|
||||
UserConfig: {
|
||||
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||
Users: allUsers as any,
|
||||
},
|
||||
SourceConfig: apiSiteEntries.map(([key, site]) => ({
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})),
|
||||
CustomCategories: customCategories.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// 写回数据库(更新/创建)
|
||||
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||
await (storage as any).setAdminConfig(adminConfig);
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
cachedConfig = adminConfig;
|
||||
} catch (err) {
|
||||
console.error('加载管理员配置失败:', err);
|
||||
}
|
||||
} else {
|
||||
// 本地存储直接使用文件配置
|
||||
cachedConfig = {
|
||||
SiteConfig: {
|
||||
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
DoubanImageProxyType:
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
|
||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||
DisableYellowFilter:
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
},
|
||||
UserConfig: {
|
||||
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||
Users: [],
|
||||
},
|
||||
SourceConfig: Object.entries(fileConfig.api_site).map(([key, site]) => ({
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})),
|
||||
CustomCategories:
|
||||
fileConfig.custom_category?.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})) || [],
|
||||
} as AdminConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<AdminConfig> {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (process.env.DOCKER_ENV === 'true' || storageType === 'localstorage') {
|
||||
await initConfig();
|
||||
return cachedConfig;
|
||||
}
|
||||
// 非 docker 环境且 DB 存储,直接读 db 配置
|
||||
const storage = getStorage();
|
||||
let adminConfig: AdminConfig | null = null;
|
||||
if (storage && typeof (storage as any).getAdminConfig === 'function') {
|
||||
adminConfig = await (storage as any).getAdminConfig();
|
||||
}
|
||||
if (adminConfig) {
|
||||
// 确保 CustomCategories 被初始化
|
||||
if (!adminConfig.CustomCategories) {
|
||||
adminConfig.CustomCategories = [];
|
||||
}
|
||||
|
||||
// 合并一些环境变量配置
|
||||
adminConfig.SiteConfig.SiteName =
|
||||
process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
adminConfig.SiteConfig.Announcement =
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||
adminConfig.UserConfig.AllowRegister =
|
||||
process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
||||
adminConfig.SiteConfig.DoubanProxyType =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct';
|
||||
adminConfig.SiteConfig.DoubanProxy =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||
adminConfig.SiteConfig.DoubanImageProxyType =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct';
|
||||
adminConfig.SiteConfig.DoubanImageProxy =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
|
||||
adminConfig.SiteConfig.DisableYellowFilter =
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||
|
||||
// 合并文件中的源信息
|
||||
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||
const sourceConfigMap = new Map(
|
||||
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
|
||||
);
|
||||
|
||||
apiSiteEntries.forEach(([key, site]) => {
|
||||
const existingSource = sourceConfigMap.get(key);
|
||||
if (existingSource) {
|
||||
// 如果已存在,只覆盖 name、api、detail 和 from
|
||||
existingSource.name = site.name;
|
||||
existingSource.api = site.api;
|
||||
existingSource.detail = site.detail;
|
||||
existingSource.from = 'config';
|
||||
} else {
|
||||
// 如果不存在,创建新条目
|
||||
sourceConfigMap.set(key, {
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
|
||||
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
|
||||
sourceConfigMap.forEach((source) => {
|
||||
if (!apiSiteKeys.has(source.key)) {
|
||||
source.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
|
||||
|
||||
// 覆盖 CustomCategories
|
||||
const customCategories = fileConfig.custom_category || [];
|
||||
adminConfig.CustomCategories = customCategories.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
}));
|
||||
|
||||
const ownerUser = process.env.USERNAME || '';
|
||||
// 检查配置中的站长用户是否和 USERNAME 匹配,如果不匹配则降级为普通用户
|
||||
let containOwner = false;
|
||||
adminConfig.UserConfig.Users.forEach((user) => {
|
||||
if (user.username !== ownerUser && user.role === 'owner') {
|
||||
user.role = 'user';
|
||||
}
|
||||
if (user.username === ownerUser) {
|
||||
containOwner = true;
|
||||
user.role = 'owner';
|
||||
}
|
||||
});
|
||||
|
||||
// 如果不在则添加
|
||||
if (!containOwner) {
|
||||
adminConfig.UserConfig.Users.unshift({
|
||||
username: ownerUser,
|
||||
role: 'owner',
|
||||
});
|
||||
}
|
||||
cachedConfig = adminConfig;
|
||||
} else {
|
||||
// DB 无配置,执行一次初始化
|
||||
await initConfig();
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export async function resetConfig() {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
const storage = getStorage();
|
||||
// 获取所有用户名,用于补全 Users
|
||||
let userNames: string[] = [];
|
||||
if (storage && typeof (storage as any).getAllUsers === 'function') {
|
||||
try {
|
||||
userNames = await (storage as any).getAllUsers();
|
||||
} catch (e) {
|
||||
console.error('获取用户列表失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.DOCKER_ENV === 'true') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
const _require = eval('require') as NodeRequire;
|
||||
const fs = _require('fs') as typeof import('fs');
|
||||
const path = _require('path') as typeof import('path');
|
||||
|
||||
const configPath = path.join(process.cwd(), 'config.json');
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
fileConfig = JSON.parse(raw) as ConfigFileStruct;
|
||||
console.log('load dynamic config success');
|
||||
} else {
|
||||
// 默认使用编译时生成的配置
|
||||
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||
}
|
||||
|
||||
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||
const customCategories = fileConfig.custom_category || [];
|
||||
let allUsers = userNames.map((uname) => ({
|
||||
username: uname,
|
||||
role: 'user',
|
||||
}));
|
||||
const ownerUser = process.env.USERNAME;
|
||||
if (ownerUser) {
|
||||
allUsers = allUsers.filter((u) => u.username !== ownerUser);
|
||||
allUsers.unshift({
|
||||
username: ownerUser,
|
||||
role: 'owner',
|
||||
});
|
||||
}
|
||||
const adminConfig = {
|
||||
SiteConfig: {
|
||||
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
DoubanImageProxyType:
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
|
||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||
DisableYellowFilter:
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
},
|
||||
UserConfig: {
|
||||
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||
Users: allUsers as any,
|
||||
},
|
||||
SourceConfig: apiSiteEntries.map(([key, site]) => ({
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})),
|
||||
CustomCategories:
|
||||
storageType === 'redis'
|
||||
? customCategories?.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})) || []
|
||||
: [],
|
||||
} as AdminConfig;
|
||||
|
||||
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||
await (storage as any).setAdminConfig(adminConfig);
|
||||
}
|
||||
if (cachedConfig == null) {
|
||||
// serverless 环境,直接使用 adminConfig
|
||||
cachedConfig = adminConfig;
|
||||
}
|
||||
cachedConfig.SiteConfig = adminConfig.SiteConfig;
|
||||
cachedConfig.UserConfig = adminConfig.UserConfig;
|
||||
cachedConfig.SourceConfig = adminConfig.SourceConfig;
|
||||
cachedConfig.CustomCategories = adminConfig.CustomCategories;
|
||||
}
|
||||
|
||||
export async function getCacheTime(): Promise<number> {
|
||||
const config = await getConfig();
|
||||
return config.SiteConfig.SiteInterfaceCacheTime || 7200;
|
||||
}
|
||||
|
||||
export async function getAvailableApiSites(): Promise<ApiSite[]> {
|
||||
const config = await getConfig();
|
||||
return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
api: s.api,
|
||||
detail: s.detail,
|
||||
}));
|
||||
}
|
||||
1657
src/lib/db.client.ts
Normal file
1657
src/lib/db.client.ts
Normal file
File diff suppressed because it is too large
Load Diff
224
src/lib/db.ts
Normal file
224
src/lib/db.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { RedisStorage } from './redis.db';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { UpstashRedisStorage } from './upstash.db';
|
||||
|
||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 创建存储实例
|
||||
function createStorage(): IStorage {
|
||||
switch (STORAGE_TYPE) {
|
||||
case 'redis':
|
||||
return new RedisStorage();
|
||||
case 'upstash':
|
||||
return new UpstashRedisStorage();
|
||||
case 'localstorage':
|
||||
default:
|
||||
return null as unknown as IStorage;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例存储实例
|
||||
let storageInstance: IStorage | null = null;
|
||||
|
||||
export function getStorage(): IStorage {
|
||||
if (!storageInstance) {
|
||||
storageInstance = createStorage();
|
||||
}
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
// 工具函数:生成存储key
|
||||
export function generateStorageKey(source: string, id: string): string {
|
||||
return `${source}+${id}`;
|
||||
}
|
||||
|
||||
// 导出便捷方法
|
||||
export class DbManager {
|
||||
private storage: IStorage;
|
||||
|
||||
constructor() {
|
||||
this.storage = getStorage();
|
||||
}
|
||||
|
||||
// 播放记录相关方法
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<PlayRecord | null> {
|
||||
const key = generateStorageKey(source, id);
|
||||
return this.storage.getPlayRecord(userName, key);
|
||||
}
|
||||
|
||||
async savePlayRecord(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.setPlayRecord(userName, key, record);
|
||||
}
|
||||
|
||||
async getAllPlayRecords(userName: string): Promise<{
|
||||
[key: string]: PlayRecord;
|
||||
}> {
|
||||
return this.storage.getAllPlayRecords(userName);
|
||||
}
|
||||
|
||||
async deletePlayRecord(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.deletePlayRecord(userName, key);
|
||||
}
|
||||
|
||||
// 收藏相关方法
|
||||
async getFavorite(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<Favorite | null> {
|
||||
const key = generateStorageKey(source, id);
|
||||
return this.storage.getFavorite(userName, key);
|
||||
}
|
||||
|
||||
async saveFavorite(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.setFavorite(userName, key, favorite);
|
||||
}
|
||||
|
||||
async getAllFavorites(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: Favorite }> {
|
||||
return this.storage.getAllFavorites(userName);
|
||||
}
|
||||
|
||||
async deleteFavorite(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.deleteFavorite(userName, key);
|
||||
}
|
||||
|
||||
async isFavorited(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<boolean> {
|
||||
const favorite = await this.getFavorite(userName, source, id);
|
||||
return favorite !== null;
|
||||
}
|
||||
|
||||
// ---------- 用户相关 ----------
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
await this.storage.registerUser(userName, password);
|
||||
}
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
return this.storage.verifyUser(userName, password);
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
return this.storage.checkUserExist(userName);
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
return this.storage.getSearchHistory(userName);
|
||||
}
|
||||
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
await this.storage.addSearchHistory(userName, keyword);
|
||||
}
|
||||
|
||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||
await this.storage.deleteSearchHistory(userName, keyword);
|
||||
}
|
||||
|
||||
// 获取全部用户名
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
if (typeof (this.storage as any).getAllUsers === 'function') {
|
||||
return (this.storage as any).getAllUsers();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||
if (typeof (this.storage as any).getAdminConfig === 'function') {
|
||||
return (this.storage as any).getAdminConfig();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async saveAdminConfig(config: AdminConfig): Promise<void> {
|
||||
if (typeof (this.storage as any).setAdminConfig === 'function') {
|
||||
await (this.storage as any).setAdminConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 跳过片头片尾配置 ----------
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
if (typeof (this.storage as any).getSkipConfig === 'function') {
|
||||
return (this.storage as any).getSkipConfig(userName, source, id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).setSkipConfig === 'function') {
|
||||
await (this.storage as any).setSkipConfig(userName, source, id, config);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteSkipConfig === 'function') {
|
||||
await (this.storage as any).deleteSkipConfig(userName, source, id);
|
||||
}
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: SkipConfig }> {
|
||||
if (typeof (this.storage as any).getAllSkipConfigs === 'function') {
|
||||
return (this.storage as any).getAllSkipConfigs(userName);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export const db = new DbManager();
|
||||
479
src/lib/douban.client.ts
Normal file
479
src/lib/douban.client.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,no-case-declarations */
|
||||
|
||||
import { DoubanItem, DoubanResult } from './types';
|
||||
|
||||
interface DoubanCategoriesParams {
|
||||
kind: 'tv' | 'movie';
|
||||
category: string;
|
||||
type: string;
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
}
|
||||
|
||||
interface DoubanCategoryApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
card_subtitle: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DoubanListApiResponse {
|
||||
total: number;
|
||||
subjects: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
card_subtitle: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DoubanRecommendApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
year: string;
|
||||
type: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时的 fetch 请求
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
proxyUrl: string
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
// 检查是否使用代理
|
||||
const finalUrl =
|
||||
proxyUrl === 'https://cors-anywhere.com/'
|
||||
? `${proxyUrl}${url}`
|
||||
: proxyUrl
|
||||
? `${proxyUrl}${encodeURIComponent(url)}`
|
||||
: url;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
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: 'application/json, text/plain, */*',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(finalUrl, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getDoubanProxyConfig(): {
|
||||
proxyType:
|
||||
| 'direct'
|
||||
| 'cors-proxy-zwei'
|
||||
| 'cmliussss-cdn-tencent'
|
||||
| 'cmliussss-cdn-ali'
|
||||
| 'cors-anywhere'
|
||||
| 'custom';
|
||||
proxyUrl: string;
|
||||
} {
|
||||
const doubanProxyType =
|
||||
localStorage.getItem('doubanDataSource') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE ||
|
||||
'direct';
|
||||
const doubanProxy =
|
||||
localStorage.getItem('doubanProxyUrl') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
|
||||
'';
|
||||
return {
|
||||
proxyType: doubanProxyType,
|
||||
proxyUrl: doubanProxy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器端豆瓣分类数据获取函数
|
||||
*/
|
||||
export async function fetchDoubanCategories(
|
||||
params: DoubanCategoriesParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
|
||||
// 验证参数
|
||||
if (!['tv', 'movie'].includes(kind)) {
|
||||
throw new Error('kind 参数必须是 tv 或 movie');
|
||||
}
|
||||
|
||||
if (!category || !type) {
|
||||
throw new Error('category 和 type 参数不能为空');
|
||||
}
|
||||
|
||||
if (pageLimit < 1 || pageLimit > 100) {
|
||||
throw new Error('pageLimit 必须在 1-100 之间');
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
throw new Error('pageStart 不能小于 0');
|
||||
}
|
||||
|
||||
const target = useTencentCDN
|
||||
? `https://m.douban.cmliussss.net/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`
|
||||
: useAliCDN
|
||||
? `https://m.douban.cmliussss.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`
|
||||
: `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
target,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanCategoryApiResponse = await response.json();
|
||||
|
||||
// 转换数据格式
|
||||
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] || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣分类数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
|
||||
*/
|
||||
export async function getDoubanCategories(
|
||||
params: DoubanCategoriesParams
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanCategories(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanCategories(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanCategories(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
interface DoubanListParams {
|
||||
tag: string;
|
||||
type: string;
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
}
|
||||
|
||||
export async function getDoubanList(
|
||||
params: DoubanListParams
|
||||
): Promise<DoubanResult> {
|
||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanList(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanList(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanList(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanList(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDoubanList(
|
||||
params: DoubanListParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): Promise<DoubanResult> {
|
||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
|
||||
// 验证参数
|
||||
if (!tag || !type) {
|
||||
throw new Error('tag 和 type 参数不能为空');
|
||||
}
|
||||
|
||||
if (!['tv', 'movie'].includes(type)) {
|
||||
throw new Error('type 参数必须是 tv 或 movie');
|
||||
}
|
||||
|
||||
if (pageLimit < 1 || pageLimit > 100) {
|
||||
throw new Error('pageLimit 必须在 1-100 之间');
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
throw new Error('pageStart 不能小于 0');
|
||||
}
|
||||
|
||||
const target = useTencentCDN
|
||||
? `https://movie.douban.cmliussss.net/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`
|
||||
: useAliCDN
|
||||
? `https://movie.douban.cmliussss.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`
|
||||
: `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
target,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanListApiResponse = await response.json();
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.cover,
|
||||
rate: item.rate,
|
||||
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣列表数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface DoubanRecommendsParams {
|
||||
kind: 'tv' | 'movie';
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
category?: string;
|
||||
format?: string;
|
||||
label?: string;
|
||||
region?: string;
|
||||
year?: string;
|
||||
platform?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export async function getDoubanRecommends(
|
||||
params: DoubanRecommendsParams
|
||||
): Promise<DoubanResult> {
|
||||
const {
|
||||
kind,
|
||||
pageLimit = 20,
|
||||
pageStart = 0,
|
||||
category,
|
||||
format,
|
||||
label,
|
||||
region,
|
||||
year,
|
||||
platform,
|
||||
sort,
|
||||
} = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanRecommends(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanRecommends(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanRecommends(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDoubanRecommends(
|
||||
params: DoubanRecommendsParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, pageLimit = 20, pageStart = 0 } = params;
|
||||
let { category, format, region, year, platform, sort, label } = params;
|
||||
if (category === 'all') {
|
||||
category = '';
|
||||
}
|
||||
if (format === 'all') {
|
||||
format = '';
|
||||
}
|
||||
if (label === 'all') {
|
||||
label = '';
|
||||
}
|
||||
if (region === 'all') {
|
||||
region = '';
|
||||
}
|
||||
if (year === 'all') {
|
||||
year = '';
|
||||
}
|
||||
if (platform === 'all') {
|
||||
platform = '';
|
||||
}
|
||||
if (sort === 'T') {
|
||||
sort = '';
|
||||
}
|
||||
|
||||
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 = useTencentCDN
|
||||
? `https://m.douban.cmliussss.net/rexxar/api/v2/${kind}/recommend`
|
||||
: useAliCDN
|
||||
? `https://m.douban.cmliussss.com/rexxar/api/v2/${kind}/recommend`
|
||||
: `https://m.douban.com/rexxar/api/v2/${kind}/recommend`;
|
||||
const reqParams = new URLSearchParams();
|
||||
reqParams.append('refresh', '0');
|
||||
reqParams.append('start', pageStart.toString());
|
||||
reqParams.append('count', pageLimit.toString());
|
||||
reqParams.append('selected_categories', JSON.stringify(selectedCategories));
|
||||
reqParams.append('uncollect', 'false');
|
||||
reqParams.append('score_range', '0,10');
|
||||
reqParams.append('tags', tags.join(','));
|
||||
if (sort) {
|
||||
reqParams.append('sort', sort);
|
||||
}
|
||||
const target = `${baseUrl}?${reqParams.toString()}`;
|
||||
console.log(target);
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
target,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanRecommendApiResponse = await response.json();
|
||||
const list: DoubanItem[] = 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,
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`获取豆瓣推荐数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
36
src/lib/douban.ts
Normal file
36
src/lib/douban.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 通用的豆瓣数据获取函数
|
||||
* @param url 请求的URL
|
||||
* @returns Promise<T> 返回指定类型的数据
|
||||
*/
|
||||
export async function fetchDoubanData<T>(url: string): Promise<T> {
|
||||
// 添加超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
// 设置请求选项,包括信号和头部
|
||||
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: 'application/json, text/plain, */*',
|
||||
Origin: 'https://movie.douban.com',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
384
src/lib/downstream.ts
Normal file
384
src/lib/downstream.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { API_CONFIG, ApiSite, getConfig } from '@/lib/config';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { cleanHtmlTags } from '@/lib/utils';
|
||||
|
||||
interface ApiSearchItem {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic: string;
|
||||
vod_remarks?: string;
|
||||
vod_play_url?: string;
|
||||
vod_class?: string;
|
||||
vod_year?: string;
|
||||
vod_content?: string;
|
||||
vod_douban_id?: number;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
export async function searchFromApi(
|
||||
apiSite: ApiSite,
|
||||
query: string
|
||||
): Promise<SearchResult[]> {
|
||||
try {
|
||||
const apiBaseUrl = apiSite.api;
|
||||
const apiUrl =
|
||||
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
|
||||
const apiName = apiSite.name;
|
||||
|
||||
// 添加超时处理
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
// 处理第一页结果
|
||||
const results = data.list.map((item: ApiSearchItem) => {
|
||||
let episodes: string[] = [];
|
||||
let titles: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = item.vod_play_url.split('$$$');
|
||||
// 分集之间#分割,标题和播放链接 $ 分割
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matchEpisodes: string[] = [];
|
||||
const matchTitles: string[] = [];
|
||||
const title_url_array = url.split('#');
|
||||
title_url_array.forEach((title_url: string) => {
|
||||
const episode_title_url = title_url.split('$');
|
||||
if (
|
||||
episode_title_url.length === 2 &&
|
||||
episode_title_url[1].endsWith('.m3u8')
|
||||
) {
|
||||
matchTitles.push(episode_title_url[0]);
|
||||
matchEpisodes.push(episode_title_url[1]);
|
||||
}
|
||||
});
|
||||
if (matchEpisodes.length > episodes.length) {
|
||||
episodes = matchEpisodes;
|
||||
titles = matchTitles;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.vod_id.toString(),
|
||||
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||
poster: item.vod_pic,
|
||||
episodes,
|
||||
episodes_titles: titles,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
class: item.vod_class,
|
||||
year: item.vod_year
|
||||
? item.vod_year.match(/\d{4}/)?.[0] || ''
|
||||
: 'unknown',
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
douban_id: item.vod_douban_id,
|
||||
};
|
||||
});
|
||||
|
||||
const config = await getConfig();
|
||||
const MAX_SEARCH_PAGES: number = config.SiteConfig.SearchDownstreamMaxPage;
|
||||
|
||||
// 获取总页数
|
||||
const pageCount = data.pagecount || 1;
|
||||
// 确定需要获取的额外页数
|
||||
const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1);
|
||||
|
||||
// 如果有额外页数,获取更多页的结果
|
||||
if (pagesToFetch > 0) {
|
||||
const additionalPagePromises = [];
|
||||
|
||||
for (let page = 2; page <= pagesToFetch + 1; page++) {
|
||||
const pageUrl =
|
||||
apiBaseUrl +
|
||||
API_CONFIG.search.pagePath
|
||||
.replace('{query}', encodeURIComponent(query))
|
||||
.replace('{page}', page.toString());
|
||||
|
||||
const pagePromise = (async () => {
|
||||
try {
|
||||
const pageController = new AbortController();
|
||||
const pageTimeoutId = setTimeout(
|
||||
() => pageController.abort(),
|
||||
8000
|
||||
);
|
||||
|
||||
const pageResponse = await fetch(pageUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: pageController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(pageTimeoutId);
|
||||
|
||||
if (!pageResponse.ok) return [];
|
||||
|
||||
const pageData = await pageResponse.json();
|
||||
|
||||
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
|
||||
return [];
|
||||
|
||||
return pageData.list.map((item: ApiSearchItem) => {
|
||||
let episodes: string[] = [];
|
||||
let titles: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = item.vod_play_url.split('$$$');
|
||||
// 分集之间#分割,标题和播放链接 $ 分割
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matchEpisodes: string[] = [];
|
||||
const matchTitles: string[] = [];
|
||||
const title_url_array = url.split('#');
|
||||
title_url_array.forEach((title_url: string) => {
|
||||
const episode_title_url = title_url.split('$');
|
||||
if (
|
||||
episode_title_url.length === 2 &&
|
||||
episode_title_url[1].endsWith('.m3u8')
|
||||
) {
|
||||
matchTitles.push(episode_title_url[0]);
|
||||
matchEpisodes.push(episode_title_url[1]);
|
||||
}
|
||||
});
|
||||
if (matchEpisodes.length > episodes.length) {
|
||||
episodes = matchEpisodes;
|
||||
titles = matchTitles;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.vod_id.toString(),
|
||||
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||
poster: item.vod_pic,
|
||||
episodes,
|
||||
episodes_titles: titles,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
class: item.vod_class,
|
||||
year: item.vod_year
|
||||
? item.vod_year.match(/\d{4}/)?.[0] || ''
|
||||
: 'unknown',
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
douban_id: item.vod_douban_id,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
additionalPagePromises.push(pagePromise);
|
||||
}
|
||||
|
||||
// 等待所有额外页的结果
|
||||
const additionalResults = await Promise.all(additionalPagePromises);
|
||||
|
||||
// 合并所有页的结果
|
||||
additionalResults.forEach((pageResults) => {
|
||||
if (pageResults.length > 0) {
|
||||
results.push(...pageResults);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配 m3u8 链接的正则
|
||||
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
|
||||
export async function getDetailFromApi(
|
||||
apiSite: ApiSite,
|
||||
id: string
|
||||
): Promise<SearchResult> {
|
||||
if (apiSite.detail) {
|
||||
return handleSpecialSourceDetail(id, apiSite);
|
||||
}
|
||||
|
||||
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
throw new Error('获取到的详情内容无效');
|
||||
}
|
||||
|
||||
const videoDetail = data.list[0];
|
||||
let episodes: string[] = [];
|
||||
let titles: string[] = [];
|
||||
|
||||
// 处理播放源拆分
|
||||
if (videoDetail.vod_play_url) {
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = videoDetail.vod_play_url.split('$$$');
|
||||
// 分集之间#分割,标题和播放链接 $ 分割
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matchEpisodes: string[] = [];
|
||||
const matchTitles: string[] = [];
|
||||
const title_url_array = url.split('#');
|
||||
title_url_array.forEach((title_url: string) => {
|
||||
const episode_title_url = title_url.split('$');
|
||||
if (
|
||||
episode_title_url.length === 2 &&
|
||||
episode_title_url[1].endsWith('.m3u8')
|
||||
) {
|
||||
matchTitles.push(episode_title_url[0]);
|
||||
matchEpisodes.push(episode_title_url[1]);
|
||||
}
|
||||
});
|
||||
if (matchEpisodes.length > episodes.length) {
|
||||
episodes = matchEpisodes;
|
||||
titles = matchTitles;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 如果播放源为空,则尝试从内容中解析 m3u8
|
||||
if (episodes.length === 0 && videoDetail.vod_content) {
|
||||
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
|
||||
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
|
||||
}
|
||||
|
||||
return {
|
||||
id: id.toString(),
|
||||
title: videoDetail.vod_name,
|
||||
poster: videoDetail.vod_pic,
|
||||
episodes,
|
||||
episodes_titles: titles,
|
||||
source: apiSite.key,
|
||||
source_name: apiSite.name,
|
||||
class: videoDetail.vod_class,
|
||||
year: videoDetail.vod_year
|
||||
? videoDetail.vod_year.match(/\d{4}/)?.[0] || ''
|
||||
: 'unknown',
|
||||
desc: cleanHtmlTags(videoDetail.vod_content),
|
||||
type_name: videoDetail.type_name,
|
||||
douban_id: videoDetail.vod_douban_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSpecialSourceDetail(
|
||||
id: string,
|
||||
apiSite: ApiSite
|
||||
): Promise<SearchResult> {
|
||||
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情页请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
let matches: string[] = [];
|
||||
|
||||
if (apiSite.key === 'ffzy') {
|
||||
const ffzyPattern =
|
||||
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
|
||||
matches = html.match(ffzyPattern) || [];
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
matches = html.match(generalPattern) || [];
|
||||
}
|
||||
|
||||
// 去重并清理链接前缀
|
||||
matches = Array.from(new Set(matches)).map((link: string) => {
|
||||
link = link.substring(1); // 去掉开头的 $
|
||||
const parenIndex = link.indexOf('(');
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
// 根据 matches 数量生成剧集标题
|
||||
const episodes_titles = Array.from({ length: matches.length }, (_, i) =>
|
||||
(i + 1).toString()
|
||||
);
|
||||
|
||||
// 提取标题
|
||||
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
|
||||
const titleText = titleMatch ? titleMatch[1].trim() : '';
|
||||
|
||||
// 提取描述
|
||||
const descMatch = html.match(
|
||||
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
|
||||
);
|
||||
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
|
||||
|
||||
// 提取封面
|
||||
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
|
||||
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
|
||||
|
||||
// 提取年份
|
||||
const yearMatch = html.match(/>(\d{4})</);
|
||||
const yearText = yearMatch ? yearMatch[1] : 'unknown';
|
||||
|
||||
return {
|
||||
id,
|
||||
title: titleText,
|
||||
poster: coverUrl,
|
||||
episodes: matches,
|
||||
episodes_titles,
|
||||
source: apiSite.key,
|
||||
source_name: apiSite.name,
|
||||
class: '',
|
||||
year: yearText,
|
||||
desc: descText,
|
||||
type_name: '',
|
||||
douban_id: 0,
|
||||
};
|
||||
}
|
||||
51
src/lib/fetchVideoDetail.ts
Normal file
51
src/lib/fetchVideoDetail.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getAvailableApiSites } from '@/lib/config';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
import { getDetailFromApi, searchFromApi } from './downstream';
|
||||
|
||||
interface FetchVideoDetailOptions {
|
||||
source: string;
|
||||
id: string;
|
||||
fallbackTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 source 与 id 获取视频详情。
|
||||
* 1. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。
|
||||
* 2. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。
|
||||
*/
|
||||
export async function fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle = '',
|
||||
}: FetchVideoDetailOptions): Promise<SearchResult> {
|
||||
// 优先通过搜索接口查找精确匹配
|
||||
const apiSites = await getAvailableApiSites();
|
||||
const apiSite = apiSites.find((site) => site.key === source);
|
||||
if (!apiSite) {
|
||||
throw new Error('无效的API来源');
|
||||
}
|
||||
if (fallbackTitle) {
|
||||
try {
|
||||
const searchData = await searchFromApi(apiSite, fallbackTitle.trim());
|
||||
const exactMatch = searchData.find(
|
||||
(item: SearchResult) =>
|
||||
item.source.toString() === source.toString() &&
|
||||
item.id.toString() === id.toString()
|
||||
);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 /api/detail 接口
|
||||
const detail = await getDetailFromApi(apiSite, id);
|
||||
if (!detail) {
|
||||
throw new Error('获取视频详情失败');
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
434
src/lib/redis.db.ts
Normal file
434
src/lib/redis.db.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
|
||||
// 数据类型转换辅助函数
|
||||
function ensureString(value: any): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function ensureStringArray(value: any[]): string[] {
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
// 添加Redis操作重试包装器
|
||||
async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 3
|
||||
): Promise<T> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err: any) {
|
||||
const isLastAttempt = i === maxRetries - 1;
|
||||
const isConnectionError =
|
||||
err.message?.includes('Connection') ||
|
||||
err.message?.includes('ECONNREFUSED') ||
|
||||
err.message?.includes('ENOTFOUND') ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'EPIPE';
|
||||
|
||||
if (isConnectionError && !isLastAttempt) {
|
||||
console.log(
|
||||
`Redis operation failed, retrying... (${i + 1}/${maxRetries})`
|
||||
);
|
||||
console.error('Error:', err.message);
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
||||
|
||||
// 尝试重新连接
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
await client.connect();
|
||||
}
|
||||
} catch (reconnectErr) {
|
||||
console.error('Failed to reconnect:', reconnectErr);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
export class RedisStorage implements IStorage {
|
||||
private client: RedisClientType;
|
||||
|
||||
constructor() {
|
||||
this.client = getRedisClient();
|
||||
}
|
||||
|
||||
// ---------- 播放记录 ----------
|
||||
private prKey(user: string, key: string) {
|
||||
return `u:${user}:pr:${key}`; // u:username:pr:source+id
|
||||
}
|
||||
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<PlayRecord | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.prKey(userName, key))
|
||||
);
|
||||
return val ? (JSON.parse(val) as PlayRecord) : null;
|
||||
}
|
||||
|
||||
async setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.prKey(userName, key), JSON.stringify(record))
|
||||
);
|
||||
}
|
||||
|
||||
async getAllPlayRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, PlayRecord>> {
|
||||
const pattern = `u:${userName}:pr:*`;
|
||||
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
if (keys.length === 0) return {};
|
||||
const values = await withRetry(() => this.client.mGet(keys));
|
||||
const result: Record<string, PlayRecord> = {};
|
||||
keys.forEach((fullKey: string, idx: number) => {
|
||||
const raw = values[idx];
|
||||
if (raw) {
|
||||
const rec = JSON.parse(raw) as PlayRecord;
|
||||
// 截取 source+id 部分
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||
result[keyPart] = rec;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.prKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 收藏 ----------
|
||||
private favKey(user: string, key: string) {
|
||||
return `u:${user}:fav:${key}`;
|
||||
}
|
||||
|
||||
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.favKey(userName, key))
|
||||
);
|
||||
return val ? (JSON.parse(val) as Favorite) : null;
|
||||
}
|
||||
|
||||
async setFavorite(
|
||||
userName: string,
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.favKey(userName, key), JSON.stringify(favorite))
|
||||
);
|
||||
}
|
||||
|
||||
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||
const pattern = `u:${userName}:fav:*`;
|
||||
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
if (keys.length === 0) return {};
|
||||
const values = await withRetry(() => this.client.mGet(keys));
|
||||
const result: Record<string, Favorite> = {};
|
||||
keys.forEach((fullKey: string, idx: number) => {
|
||||
const raw = values[idx];
|
||||
if (raw) {
|
||||
const fav = JSON.parse(raw) as Favorite;
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||
result[keyPart] = fav;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录 ----------
|
||||
private userPwdKey(user: string) {
|
||||
return `u:${user}:pwd`;
|
||||
}
|
||||
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
|
||||
}
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
const stored = await withRetry(() =>
|
||||
this.client.get(this.userPwdKey(userName))
|
||||
);
|
||||
if (stored === null) return false;
|
||||
// 确保比较时都是字符串类型
|
||||
return ensureString(stored) === password;
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
// 使用 EXISTS 判断 key 是否存在
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userPwdKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// 修改用户密码
|
||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() =>
|
||||
this.client.set(this.userPwdKey(userName), newPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除用户及其所有数据
|
||||
async deleteUser(userName: string): Promise<void> {
|
||||
// 删除用户密码
|
||||
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
||||
|
||||
// 删除搜索历史
|
||||
await withRetry(() => this.client.del(this.shKey(userName)));
|
||||
|
||||
// 删除播放记录
|
||||
const playRecordPattern = `u:${userName}:pr:*`;
|
||||
const playRecordKeys = await withRetry(() =>
|
||||
this.client.keys(playRecordPattern)
|
||||
);
|
||||
if (playRecordKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(playRecordKeys));
|
||||
}
|
||||
|
||||
// 删除收藏夹
|
||||
const favoritePattern = `u:${userName}:fav:*`;
|
||||
const favoriteKeys = await withRetry(() =>
|
||||
this.client.keys(favoritePattern)
|
||||
);
|
||||
if (favoriteKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(favoriteKeys));
|
||||
}
|
||||
|
||||
// 删除跳过片头片尾配置
|
||||
const skipConfigPattern = `u:${userName}:skip:*`;
|
||||
const skipConfigKeys = await withRetry(() =>
|
||||
this.client.keys(skipConfigPattern)
|
||||
);
|
||||
if (skipConfigKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(skipConfigKeys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
private shKey(user: string) {
|
||||
return `u:${user}:sh`; // u:username:sh
|
||||
}
|
||||
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
const result = await withRetry(() =>
|
||||
this.client.lRange(this.shKey(userName), 0, -1)
|
||||
);
|
||||
// 确保返回的都是字符串类型
|
||||
return ensureStringArray(result as any[]);
|
||||
}
|
||||
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
// 先去重
|
||||
await withRetry(() => this.client.lRem(key, 0, ensureString(keyword)));
|
||||
// 插入到最前
|
||||
await withRetry(() => this.client.lPush(key, ensureString(keyword)));
|
||||
// 限制最大长度
|
||||
await withRetry(() => this.client.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||
}
|
||||
|
||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
if (keyword) {
|
||||
await withRetry(() => this.client.lRem(key, 0, ensureString(keyword)));
|
||||
} else {
|
||||
await withRetry(() => this.client.del(key));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||
return keys
|
||||
.map((k) => {
|
||||
const match = k.match(/^u:(.+?):pwd$/);
|
||||
return match ? ensureString(match[1]) : undefined;
|
||||
})
|
||||
.filter((u): u is string => typeof u === 'string');
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
private adminConfigKey() {
|
||||
return 'admin:config';
|
||||
}
|
||||
|
||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
||||
return val ? (JSON.parse(val) as AdminConfig) : null;
|
||||
}
|
||||
|
||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.adminConfigKey(), JSON.stringify(config))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 跳过片头片尾配置 ----------
|
||||
private skipConfigKey(user: string, source: string, id: string) {
|
||||
return `u:${user}:skip:${source}+${id}`;
|
||||
}
|
||||
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.skipConfigKey(userName, source, id))
|
||||
);
|
||||
return val ? (JSON.parse(val) as SkipConfig) : null;
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(
|
||||
this.skipConfigKey(userName, source, id),
|
||||
JSON.stringify(config)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.del(this.skipConfigKey(userName, source, id))
|
||||
);
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: SkipConfig }> {
|
||||
const pattern = `u:${userName}:skip:*`;
|
||||
const keys = await withRetry(() => this.client.keys(pattern));
|
||||
|
||||
if (keys.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const configs: { [key: string]: SkipConfig } = {};
|
||||
|
||||
// 批量获取所有配置
|
||||
const values = await withRetry(() => this.client.mGet(keys));
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
const value = values[index];
|
||||
if (value) {
|
||||
// 从key中提取source+id
|
||||
const match = key.match(/^u:.+?:skip:(.+)$/);
|
||||
if (match) {
|
||||
const sourceAndId = match[1];
|
||||
configs[sourceAndId] = JSON.parse(value as string) as SkipConfig;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Redis 客户端
|
||||
function getRedisClient(): RedisClientType {
|
||||
const globalKey = Symbol.for('__MOONTV_REDIS_CLIENT__');
|
||||
let client: RedisClientType | undefined = (global as any)[globalKey];
|
||||
|
||||
if (!client) {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) {
|
||||
throw new Error('REDIS_URL env variable not set');
|
||||
}
|
||||
|
||||
// 创建客户端,配置重连策略
|
||||
client = createClient({
|
||||
url,
|
||||
socket: {
|
||||
// 重连策略:指数退避,最大30秒
|
||||
reconnectStrategy: (retries: number) => {
|
||||
console.log(`Redis reconnection attempt ${retries + 1}`);
|
||||
if (retries > 10) {
|
||||
console.error('Redis max reconnection attempts exceeded');
|
||||
return false; // 停止重连
|
||||
}
|
||||
return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒
|
||||
},
|
||||
connectTimeout: 10000, // 10秒连接超时
|
||||
// 设置no delay,减少延迟
|
||||
noDelay: true,
|
||||
},
|
||||
// 添加其他配置
|
||||
pingInterval: 30000, // 30秒ping一次,保持连接活跃
|
||||
});
|
||||
|
||||
// 添加错误事件监听
|
||||
client.on('error', (err) => {
|
||||
console.error('Redis client error:', err);
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log('Redis connected');
|
||||
});
|
||||
|
||||
client.on('reconnecting', () => {
|
||||
console.log('Redis reconnecting...');
|
||||
});
|
||||
|
||||
client.on('ready', () => {
|
||||
console.log('Redis ready');
|
||||
});
|
||||
|
||||
// 初始连接,带重试机制
|
||||
const connectWithRetry = async () => {
|
||||
try {
|
||||
await client!.connect();
|
||||
console.log('Redis connected successfully');
|
||||
} catch (err) {
|
||||
console.error('Redis initial connection failed:', err);
|
||||
console.log('Will retry in 5 seconds...');
|
||||
setTimeout(connectWithRetry, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
connectWithRetry();
|
||||
|
||||
(global as any)[globalKey] = client;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
120
src/lib/types.ts
Normal file
120
src/lib/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { AdminConfig } from './admin.types';
|
||||
|
||||
// 播放记录数据结构
|
||||
export interface PlayRecord {
|
||||
title: string;
|
||||
source_name: string;
|
||||
cover: string;
|
||||
year: string;
|
||||
index: number; // 第几集
|
||||
total_episodes: number; // 总集数
|
||||
play_time: number; // 播放进度(秒)
|
||||
total_time: number; // 总进度(秒)
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
search_title: string; // 搜索时使用的标题
|
||||
}
|
||||
|
||||
// 收藏数据结构
|
||||
export interface Favorite {
|
||||
source_name: string;
|
||||
total_episodes: number; // 总集数
|
||||
title: string;
|
||||
year: string;
|
||||
cover: string;
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
search_title: string; // 搜索时使用的标题
|
||||
}
|
||||
|
||||
// 存储接口
|
||||
export interface IStorage {
|
||||
// 播放记录相关
|
||||
getPlayRecord(userName: string, key: string): Promise<PlayRecord | null>;
|
||||
setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void>;
|
||||
getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>;
|
||||
deletePlayRecord(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 收藏相关
|
||||
getFavorite(userName: string, key: string): Promise<Favorite | null>;
|
||||
setFavorite(userName: string, key: string, favorite: Favorite): Promise<void>;
|
||||
getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>;
|
||||
deleteFavorite(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 用户相关
|
||||
registerUser(userName: string, password: string): Promise<void>;
|
||||
verifyUser(userName: string, password: string): Promise<boolean>;
|
||||
// 检查用户是否存在(无需密码)
|
||||
checkUserExist(userName: string): Promise<boolean>;
|
||||
// 修改用户密码
|
||||
changePassword(userName: string, newPassword: string): Promise<void>;
|
||||
// 删除用户(包括密码、搜索历史、播放记录、收藏夹)
|
||||
deleteUser(userName: string): Promise<void>;
|
||||
|
||||
// 搜索历史相关
|
||||
getSearchHistory(userName: string): Promise<string[]>;
|
||||
addSearchHistory(userName: string, keyword: string): Promise<void>;
|
||||
deleteSearchHistory(userName: string, keyword?: string): Promise<void>;
|
||||
|
||||
// 用户列表
|
||||
getAllUsers(): Promise<string[]>;
|
||||
|
||||
// 管理员配置相关
|
||||
getAdminConfig(): Promise<AdminConfig | null>;
|
||||
setAdminConfig(config: AdminConfig): Promise<void>;
|
||||
|
||||
// 跳过片头片尾配置相关
|
||||
getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null>;
|
||||
setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void>;
|
||||
deleteSkipConfig(userName: string, source: string, id: string): Promise<void>;
|
||||
getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>;
|
||||
}
|
||||
|
||||
// 搜索结果数据结构
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes: string[];
|
||||
episodes_titles: string[];
|
||||
source: string;
|
||||
source_name: string;
|
||||
class?: string;
|
||||
year: string;
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
douban_id?: number;
|
||||
}
|
||||
|
||||
// 豆瓣数据结构
|
||||
export interface DoubanItem {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
rate: string;
|
||||
year: string;
|
||||
}
|
||||
|
||||
export interface DoubanResult {
|
||||
code: number;
|
||||
message: string;
|
||||
list: DoubanItem[];
|
||||
}
|
||||
|
||||
// 跳过片头片尾配置数据结构
|
||||
export interface SkipConfig {
|
||||
enable: boolean; // 是否启用跳过片头片尾
|
||||
intro_time: number; // 片头时间(秒)
|
||||
outro_time: number; // 片尾时间(秒)
|
||||
}
|
||||
381
src/lib/upstash.db.ts
Normal file
381
src/lib/upstash.db.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
|
||||
// 数据类型转换辅助函数
|
||||
function ensureString(value: any): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function ensureStringArray(value: any[]): string[] {
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
// 添加Upstash Redis操作重试包装器
|
||||
async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 3
|
||||
): Promise<T> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err: any) {
|
||||
const isLastAttempt = i === maxRetries - 1;
|
||||
const isConnectionError =
|
||||
err.message?.includes('Connection') ||
|
||||
err.message?.includes('ECONNREFUSED') ||
|
||||
err.message?.includes('ENOTFOUND') ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'EPIPE' ||
|
||||
err.name === 'UpstashError';
|
||||
|
||||
if (isConnectionError && !isLastAttempt) {
|
||||
console.log(
|
||||
`Upstash Redis operation failed, retrying... (${i + 1}/${maxRetries})`
|
||||
);
|
||||
console.error('Error:', err.message);
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
export class UpstashRedisStorage implements IStorage {
|
||||
private client: Redis;
|
||||
|
||||
constructor() {
|
||||
this.client = getUpstashRedisClient();
|
||||
}
|
||||
|
||||
// ---------- 播放记录 ----------
|
||||
private prKey(user: string, key: string) {
|
||||
return `u:${user}:pr:${key}`; // u:username:pr:source+id
|
||||
}
|
||||
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<PlayRecord | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.prKey(userName, key))
|
||||
);
|
||||
return val ? (val as PlayRecord) : null;
|
||||
}
|
||||
|
||||
async setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
await withRetry(() => this.client.set(this.prKey(userName, key), record));
|
||||
}
|
||||
|
||||
async getAllPlayRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, PlayRecord>> {
|
||||
const pattern = `u:${userName}:pr:*`;
|
||||
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
if (keys.length === 0) return {};
|
||||
|
||||
const result: Record<string, PlayRecord> = {};
|
||||
for (const fullKey of keys) {
|
||||
const value = await withRetry(() => this.client.get(fullKey));
|
||||
if (value) {
|
||||
// 截取 source+id 部分
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||
result[keyPart] = value as PlayRecord;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.prKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 收藏 ----------
|
||||
private favKey(user: string, key: string) {
|
||||
return `u:${user}:fav:${key}`;
|
||||
}
|
||||
|
||||
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.favKey(userName, key))
|
||||
);
|
||||
return val ? (val as Favorite) : null;
|
||||
}
|
||||
|
||||
async setFavorite(
|
||||
userName: string,
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.favKey(userName, key), favorite)
|
||||
);
|
||||
}
|
||||
|
||||
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||
const pattern = `u:${userName}:fav:*`;
|
||||
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
if (keys.length === 0) return {};
|
||||
|
||||
const result: Record<string, Favorite> = {};
|
||||
for (const fullKey of keys) {
|
||||
const value = await withRetry(() => this.client.get(fullKey));
|
||||
if (value) {
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||
result[keyPart] = value as Favorite;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录 ----------
|
||||
private userPwdKey(user: string) {
|
||||
return `u:${user}:pwd`;
|
||||
}
|
||||
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
|
||||
}
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
const stored = await withRetry(() =>
|
||||
this.client.get(this.userPwdKey(userName))
|
||||
);
|
||||
if (stored === null) return false;
|
||||
// 确保比较时都是字符串类型
|
||||
return ensureString(stored) === password;
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
// 使用 EXISTS 判断 key 是否存在
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userPwdKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// 修改用户密码
|
||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() =>
|
||||
this.client.set(this.userPwdKey(userName), newPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除用户及其所有数据
|
||||
async deleteUser(userName: string): Promise<void> {
|
||||
// 删除用户密码
|
||||
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
||||
|
||||
// 删除搜索历史
|
||||
await withRetry(() => this.client.del(this.shKey(userName)));
|
||||
|
||||
// 删除播放记录
|
||||
const playRecordPattern = `u:${userName}:pr:*`;
|
||||
const playRecordKeys = await withRetry(() =>
|
||||
this.client.keys(playRecordPattern)
|
||||
);
|
||||
if (playRecordKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...playRecordKeys));
|
||||
}
|
||||
|
||||
// 删除收藏夹
|
||||
const favoritePattern = `u:${userName}:fav:*`;
|
||||
const favoriteKeys = await withRetry(() =>
|
||||
this.client.keys(favoritePattern)
|
||||
);
|
||||
if (favoriteKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...favoriteKeys));
|
||||
}
|
||||
|
||||
// 删除跳过片头片尾配置
|
||||
const skipConfigPattern = `u:${userName}:skip:*`;
|
||||
const skipConfigKeys = await withRetry(() =>
|
||||
this.client.keys(skipConfigPattern)
|
||||
);
|
||||
if (skipConfigKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...skipConfigKeys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
private shKey(user: string) {
|
||||
return `u:${user}:sh`; // u:username:sh
|
||||
}
|
||||
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
const result = await withRetry(() =>
|
||||
this.client.lrange(this.shKey(userName), 0, -1)
|
||||
);
|
||||
// 确保返回的都是字符串类型
|
||||
return ensureStringArray(result as any[]);
|
||||
}
|
||||
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
// 先去重
|
||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||
// 插入到最前
|
||||
await withRetry(() => this.client.lpush(key, ensureString(keyword)));
|
||||
// 限制最大长度
|
||||
await withRetry(() => this.client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||
}
|
||||
|
||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
if (keyword) {
|
||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||
} else {
|
||||
await withRetry(() => this.client.del(key));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||
return keys
|
||||
.map((k) => {
|
||||
const match = k.match(/^u:(.+?):pwd$/);
|
||||
return match ? ensureString(match[1]) : undefined;
|
||||
})
|
||||
.filter((u): u is string => typeof u === 'string');
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
private adminConfigKey() {
|
||||
return 'admin:config';
|
||||
}
|
||||
|
||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
||||
return val ? (val as AdminConfig) : null;
|
||||
}
|
||||
|
||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||
await withRetry(() => this.client.set(this.adminConfigKey(), config));
|
||||
}
|
||||
|
||||
// ---------- 跳过片头片尾配置 ----------
|
||||
private skipConfigKey(user: string, source: string, id: string) {
|
||||
return `u:${user}:skip:${source}+${id}`;
|
||||
}
|
||||
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.skipConfigKey(userName, source, id))
|
||||
);
|
||||
return val ? (val as SkipConfig) : null;
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.skipConfigKey(userName, source, id), config)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.del(this.skipConfigKey(userName, source, id))
|
||||
);
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: SkipConfig }> {
|
||||
const pattern = `u:${userName}:skip:*`;
|
||||
const keys = await withRetry(() => this.client.keys(pattern));
|
||||
|
||||
if (keys.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const configs: { [key: string]: SkipConfig } = {};
|
||||
|
||||
// 批量获取所有配置
|
||||
const values = await withRetry(() => this.client.mget(keys));
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
const value = values[index];
|
||||
if (value) {
|
||||
// 从key中提取source+id
|
||||
const match = key.match(/^u:.+?:skip:(.+)$/);
|
||||
if (match) {
|
||||
const sourceAndId = match[1];
|
||||
configs[sourceAndId] = value as SkipConfig;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Upstash Redis 客户端
|
||||
function getUpstashRedisClient(): Redis {
|
||||
const globalKey = Symbol.for('__MOONTV_UPSTASH_REDIS_CLIENT__');
|
||||
let client: Redis | undefined = (global as any)[globalKey];
|
||||
|
||||
if (!client) {
|
||||
const upstashUrl = process.env.UPSTASH_URL;
|
||||
const upstashToken = process.env.UPSTASH_TOKEN;
|
||||
|
||||
if (!upstashUrl || !upstashToken) {
|
||||
throw new Error(
|
||||
'UPSTASH_URL and UPSTASH_TOKEN env variables must be set'
|
||||
);
|
||||
}
|
||||
|
||||
// 创建 Upstash Redis 客户端
|
||||
client = new Redis({
|
||||
url: upstashUrl,
|
||||
token: upstashToken,
|
||||
// 可选配置
|
||||
retry: {
|
||||
retries: 3,
|
||||
backoff: (retryCount: number) =>
|
||||
Math.min(1000 * Math.pow(2, retryCount), 30000),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Upstash Redis client created successfully');
|
||||
|
||||
(global as any)[globalKey] = client;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
233
src/lib/utils.ts
Normal file
233
src/lib/utils.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
import he from 'he';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
function getDoubanImageProxyConfig(): {
|
||||
proxyType:
|
||||
| 'direct'
|
||||
| 'server'
|
||||
| 'img3'
|
||||
| 'cmliussss-cdn-tencent'
|
||||
| 'cmliussss-cdn-ali'
|
||||
| 'custom';
|
||||
proxyUrl: string;
|
||||
} {
|
||||
const doubanImageProxyType =
|
||||
localStorage.getItem('doubanImageProxyType') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE ||
|
||||
'direct';
|
||||
const doubanImageProxy =
|
||||
localStorage.getItem('doubanImageProxyUrl') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
||||
'';
|
||||
return {
|
||||
proxyType: doubanImageProxyType,
|
||||
proxyUrl: doubanImageProxy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片 URL,如果设置了图片代理则使用代理
|
||||
*/
|
||||
export function processImageUrl(originalUrl: string): string {
|
||||
if (!originalUrl) return originalUrl;
|
||||
|
||||
// 仅处理豆瓣图片代理
|
||||
if (!originalUrl.includes('doubanio.com')) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'server':
|
||||
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
|
||||
case 'img3':
|
||||
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return originalUrl.replace(
|
||||
/img\d+\.doubanio\.com/g,
|
||||
'img.doubanio.cmliussss.net'
|
||||
);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return originalUrl.replace(
|
||||
/img\d+\.doubanio\.com/g,
|
||||
'img.doubanio.cmliussss.com'
|
||||
);
|
||||
case 'custom':
|
||||
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
||||
case 'direct':
|
||||
default:
|
||||
return originalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从m3u8地址获取视频质量等级和网络信息
|
||||
* @param m3u8Url m3u8播放列表的URL
|
||||
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number}> 视频质量等级和网络信息
|
||||
*/
|
||||
export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
quality: string; // 如720p、1080p等
|
||||
loadSpeed: string; // 自动转换为KB/s或MB/s
|
||||
pingTime: number; // 网络延迟(毫秒)
|
||||
}> {
|
||||
try {
|
||||
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
|
||||
// 测量网络延迟(ping时间) - 使用m3u8 URL而不是ts文件
|
||||
const pingStart = performance.now();
|
||||
let pingTime = 0;
|
||||
|
||||
// 测量ping时间(使用m3u8 URL)
|
||||
fetch(m3u8Url, { method: 'HEAD', mode: 'no-cors' })
|
||||
.then(() => {
|
||||
pingTime = performance.now() - pingStart;
|
||||
})
|
||||
.catch(() => {
|
||||
pingTime = performance.now() - pingStart; // 记录到失败为止的时间
|
||||
});
|
||||
|
||||
// 固定使用hls.js加载
|
||||
const hls = new Hls();
|
||||
|
||||
// 设置超时处理
|
||||
const timeout = setTimeout(() => {
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
reject(new Error('Timeout loading video metadata'));
|
||||
}, 4000);
|
||||
|
||||
video.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
reject(new Error('Failed to load video metadata'));
|
||||
};
|
||||
|
||||
let actualLoadSpeed = '未知';
|
||||
let hasSpeedCalculated = false;
|
||||
let hasMetadataLoaded = false;
|
||||
|
||||
let fragmentStartTime = 0;
|
||||
|
||||
// 检查是否可以返回结果
|
||||
const checkAndResolve = () => {
|
||||
if (
|
||||
hasMetadataLoaded &&
|
||||
(hasSpeedCalculated || actualLoadSpeed !== '未知')
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
const width = video.videoWidth;
|
||||
if (width && width > 0) {
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
|
||||
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
|
||||
const quality =
|
||||
width >= 3840
|
||||
? '4K' // 4K: 3840x2160
|
||||
: width >= 2560
|
||||
? '2K' // 2K: 2560x1440
|
||||
: width >= 1920
|
||||
? '1080p' // 1080p: 1920x1080
|
||||
: width >= 1280
|
||||
? '720p' // 720p: 1280x720
|
||||
: width >= 854
|
||||
? '480p'
|
||||
: 'SD'; // 480p: 854x480
|
||||
|
||||
resolve({
|
||||
quality,
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
});
|
||||
} else {
|
||||
// webkit 无法获取尺寸,直接返回
|
||||
resolve({
|
||||
quality: '未知',
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听片段加载开始
|
||||
hls.on(Hls.Events.FRAG_LOADING, () => {
|
||||
fragmentStartTime = performance.now();
|
||||
});
|
||||
|
||||
// 监听片段加载完成,只需首个分片即可计算速度
|
||||
hls.on(Hls.Events.FRAG_LOADED, (event: any, data: any) => {
|
||||
if (
|
||||
fragmentStartTime > 0 &&
|
||||
data &&
|
||||
data.payload &&
|
||||
!hasSpeedCalculated
|
||||
) {
|
||||
const loadTime = performance.now() - fragmentStartTime;
|
||||
const size = data.payload.byteLength || 0;
|
||||
|
||||
if (loadTime > 0 && size > 0) {
|
||||
const speedKBps = size / 1024 / (loadTime / 1000);
|
||||
|
||||
// 立即计算速度,无需等待更多分片
|
||||
const avgSpeedKBps = speedKBps;
|
||||
|
||||
if (avgSpeedKBps >= 1024) {
|
||||
actualLoadSpeed = `${(avgSpeedKBps / 1024).toFixed(1)} MB/s`;
|
||||
} else {
|
||||
actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`;
|
||||
}
|
||||
hasSpeedCalculated = true;
|
||||
checkAndResolve(); // 尝试返回结果
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hls.loadSource(m3u8Url);
|
||||
hls.attachMedia(video);
|
||||
|
||||
// 监听hls.js错误
|
||||
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||
console.error('HLS错误:', data);
|
||||
if (data.fatal) {
|
||||
clearTimeout(timeout);
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
reject(new Error(`HLS播放失败: ${data.type}`));
|
||||
}
|
||||
});
|
||||
|
||||
// 监听视频元数据加载完成
|
||||
video.onloadedmetadata = () => {
|
||||
hasMetadataLoaded = true;
|
||||
checkAndResolve(); // 尝试返回结果
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Error getting video resolution: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanHtmlTags(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
const cleanedText = text
|
||||
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
|
||||
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
||||
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
||||
.trim(); // 去掉首尾空格
|
||||
|
||||
// 使用 he 库解码 HTML 实体
|
||||
return he.decode(cleanedText);
|
||||
}
|
||||
152
src/lib/version.ts
Normal file
152
src/lib/version.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
const CURRENT_VERSION = '1.1.1';
|
||||
|
||||
// 版本检查结果枚举
|
||||
export enum UpdateStatus {
|
||||
HAS_UPDATE = 'has_update', // 有新版本
|
||||
NO_UPDATE = 'no_update', // 无新版本
|
||||
FETCH_FAILED = 'fetch_failed', // 获取失败
|
||||
}
|
||||
|
||||
// 远程版本检查URL配置
|
||||
const VERSION_CHECK_URLS = [
|
||||
'https://raw.githubusercontent.com/LunaTechLab/MoonTV/main/VERSION.txt',
|
||||
'https://cdn.jsdelivr.net/gh/LunaTechLab/moontv/VERSION.txt',
|
||||
];
|
||||
|
||||
/**
|
||||
* 检查是否有新版本可用
|
||||
* @returns Promise<UpdateStatus> - 返回版本检查状态
|
||||
*/
|
||||
export async function checkForUpdates(): Promise<UpdateStatus> {
|
||||
try {
|
||||
// 尝试从主要URL获取版本信息
|
||||
const primaryVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[0]);
|
||||
if (primaryVersion) {
|
||||
return compareVersions(primaryVersion);
|
||||
}
|
||||
|
||||
// 如果主要URL失败,尝试备用URL
|
||||
const backupVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[1]);
|
||||
if (backupVersion) {
|
||||
return compareVersions(backupVersion);
|
||||
}
|
||||
|
||||
// 如果两个URL都失败,返回获取失败状态
|
||||
return UpdateStatus.FETCH_FAILED;
|
||||
} catch (error) {
|
||||
console.error('版本检查失败:', error);
|
||||
return UpdateStatus.FETCH_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定URL获取版本信息
|
||||
* @param url - 版本信息URL
|
||||
* @returns Promise<string | null> - 版本字符串或null
|
||||
*/
|
||||
async function fetchVersionFromUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
|
||||
|
||||
// 添加时间戳参数以避免缓存
|
||||
const timestamp = Date.now();
|
||||
const urlWithTimestamp = url.includes('?')
|
||||
? `${url}&_t=${timestamp}`
|
||||
: `${url}?_t=${timestamp}`;
|
||||
|
||||
const response = await fetch(urlWithTimestamp, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const version = await response.text();
|
||||
return version.trim();
|
||||
} catch (error) {
|
||||
console.warn(`从 ${url} 获取版本信息失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较版本号
|
||||
* @param remoteVersion - 远程版本号
|
||||
* @returns UpdateStatus - 返回版本比较结果
|
||||
*/
|
||||
function compareVersions(remoteVersion: string): UpdateStatus {
|
||||
// 如果版本号相同,无需更新
|
||||
if (remoteVersion === CURRENT_VERSION) {
|
||||
return UpdateStatus.NO_UPDATE;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析版本号为数字数组 [X, Y, Z]
|
||||
const currentParts = CURRENT_VERSION.split('.').map((part) => {
|
||||
const num = parseInt(part, 10);
|
||||
if (isNaN(num) || num < 0) {
|
||||
throw new Error(`无效的版本号格式: ${CURRENT_VERSION}`);
|
||||
}
|
||||
return num;
|
||||
});
|
||||
|
||||
const remoteParts = remoteVersion.split('.').map((part) => {
|
||||
const num = parseInt(part, 10);
|
||||
if (isNaN(num) || num < 0) {
|
||||
throw new Error(`无效的版本号格式: ${remoteVersion}`);
|
||||
}
|
||||
return num;
|
||||
});
|
||||
|
||||
// 标准化版本号到3个部分
|
||||
const normalizeVersion = (parts: number[]) => {
|
||||
if (parts.length >= 3) {
|
||||
return parts.slice(0, 3); // 取前三个元素
|
||||
} else {
|
||||
// 不足3个的部分补0
|
||||
const normalized = [...parts];
|
||||
while (normalized.length < 3) {
|
||||
normalized.push(0);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedCurrent = normalizeVersion(currentParts);
|
||||
const normalizedRemote = normalizeVersion(remoteParts);
|
||||
|
||||
// 逐级比较版本号
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (normalizedRemote[i] > normalizedCurrent[i]) {
|
||||
return UpdateStatus.HAS_UPDATE;
|
||||
} else if (normalizedRemote[i] < normalizedCurrent[i]) {
|
||||
return UpdateStatus.NO_UPDATE;
|
||||
}
|
||||
// 如果当前级别相等,继续比较下一级
|
||||
}
|
||||
|
||||
// 所有级别都相等,无需更新
|
||||
return UpdateStatus.NO_UPDATE;
|
||||
} catch (error) {
|
||||
console.error('版本号比较失败:', error);
|
||||
// 如果版本号格式无效,回退到字符串比较
|
||||
return remoteVersion !== CURRENT_VERSION
|
||||
? UpdateStatus.HAS_UPDATE
|
||||
: UpdateStatus.NO_UPDATE;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { compareVersions, CURRENT_VERSION };
|
||||
22
src/lib/yellow.ts
Normal file
22
src/lib/yellow.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const yellowWords = [
|
||||
'伦理片',
|
||||
'福利',
|
||||
'里番动漫',
|
||||
'门事件',
|
||||
'萝莉少女',
|
||||
'制服诱惑',
|
||||
'国产传媒',
|
||||
'cosplay',
|
||||
'黑丝诱惑',
|
||||
'无码',
|
||||
'日本无码',
|
||||
'有码',
|
||||
'日本有码',
|
||||
'SWAG',
|
||||
'网红主播',
|
||||
'色情片',
|
||||
'同性片',
|
||||
'福利视频',
|
||||
'福利片',
|
||||
'写真热舞',
|
||||
];
|
||||
138
src/middleware.ts
Normal file
138
src/middleware.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// 跳过不需要认证的路径
|
||||
if (shouldSkipAuth(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
if (!process.env.PASSWORD) {
|
||||
// 如果没有设置密码,重定向到警告页面
|
||||
const warningUrl = new URL('/warning', request.url);
|
||||
return NextResponse.redirect(warningUrl);
|
||||
}
|
||||
|
||||
// 从cookie获取认证信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo) {
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// localstorage模式:在middleware中完成验证
|
||||
if (storageType === 'localstorage') {
|
||||
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 其他模式:只验证签名
|
||||
// 检查是否有用户名(非localStorage模式下密码不存储在cookie中)
|
||||
if (!authInfo.username || !authInfo.signature) {
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// 验证签名(如果存在)
|
||||
if (authInfo.signature) {
|
||||
const isValidSignature = await verifySignature(
|
||||
authInfo.username,
|
||||
authInfo.signature,
|
||||
process.env.PASSWORD || ''
|
||||
);
|
||||
|
||||
// 签名验证通过即可
|
||||
if (isValidSignature) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
}
|
||||
|
||||
// 签名验证失败或不存在签名
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
async function verifySignature(
|
||||
data: string,
|
||||
signature: string,
|
||||
secret: string
|
||||
): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const messageData = encoder.encode(data);
|
||||
|
||||
try {
|
||||
// 导入密钥
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
|
||||
// 将十六进制字符串转换为Uint8Array
|
||||
const signatureBuffer = new Uint8Array(
|
||||
signature.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []
|
||||
);
|
||||
|
||||
// 验证签名
|
||||
return await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
signatureBuffer,
|
||||
messageData
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('签名验证失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理认证失败的情况
|
||||
function handleAuthFailure(
|
||||
request: NextRequest,
|
||||
pathname: string
|
||||
): NextResponse {
|
||||
// 如果是 API 路由,返回 401 状态码
|
||||
if (pathname.startsWith('/api')) {
|
||||
return new NextResponse('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// 否则重定向到登录页面
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
// 保留完整的URL,包括查询参数
|
||||
const fullUrl = `${pathname}${request.nextUrl.search}`;
|
||||
loginUrl.searchParams.set('redirect', fullUrl);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// 判断是否需要跳过认证的路径
|
||||
function shouldSkipAuth(pathname: string): boolean {
|
||||
const skipPaths = [
|
||||
'/_next',
|
||||
'/favicon.ico',
|
||||
'/robots.txt',
|
||||
'/manifest.json',
|
||||
'/icons/',
|
||||
'/logo.png',
|
||||
'/screenshot.png',
|
||||
];
|
||||
|
||||
return skipPaths.some((path) => pathname.startsWith(path));
|
||||
}
|
||||
|
||||
// 配置middleware匹配规则
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config).*)',
|
||||
],
|
||||
};
|
||||
550
src/styles/colors.css
Normal file
550
src/styles/colors.css
Normal file
@@ -0,0 +1,550 @@
|
||||
/* //!STARTERCONF Remove this file after copying your desired color, this is a large file you should remove it. */
|
||||
|
||||
.slate {
|
||||
--tw-color-primary-50: 248 250 252;
|
||||
--tw-color-primary-100: 241 245 249;
|
||||
--tw-color-primary-200: 226 232 240;
|
||||
--tw-color-primary-300: 203 213 225;
|
||||
--tw-color-primary-400: 148 163 184;
|
||||
--tw-color-primary-500: 100 116 139;
|
||||
--tw-color-primary-600: 71 85 105;
|
||||
--tw-color-primary-700: 51 65 85;
|
||||
--tw-color-primary-800: 30 41 59;
|
||||
--tw-color-primary-900: 15 23 42;
|
||||
--tw-color-primary-950: 2 6 23;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f8fafc */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f1f5f9 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e2e8f0 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #cbd5e1 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #94a3b8 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #64748b */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #475569 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #334155 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1e293b */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0f172a */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #020617 */
|
||||
}
|
||||
|
||||
.gray {
|
||||
--tw-color-primary-50: 249 250 251;
|
||||
--tw-color-primary-100: 243 244 246;
|
||||
--tw-color-primary-200: 229 231 235;
|
||||
--tw-color-primary-300: 209 213 219;
|
||||
--tw-color-primary-400: 156 163 175;
|
||||
--tw-color-primary-500: 107 114 128;
|
||||
--tw-color-primary-600: 75 85 99;
|
||||
--tw-color-primary-700: 55 65 81;
|
||||
--tw-color-primary-800: 31 41 55;
|
||||
--tw-color-primary-900: 17 24 39;
|
||||
--tw-color-primary-950: 3 7 18;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f9fafb */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f3f4f6 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e5e7eb */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d1d5db */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #9ca3af */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #6b7280 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #4b5563 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #374151 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1f2937 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #111827 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #030712 */
|
||||
}
|
||||
|
||||
.zinc {
|
||||
--tw-color-primary-50: 250 250 250;
|
||||
--tw-color-primary-100: 244 244 245;
|
||||
--tw-color-primary-200: 228 228 231;
|
||||
--tw-color-primary-300: 212 212 216;
|
||||
--tw-color-primary-400: 161 161 170;
|
||||
--tw-color-primary-500: 113 113 122;
|
||||
--tw-color-primary-600: 82 82 91;
|
||||
--tw-color-primary-700: 63 63 70;
|
||||
--tw-color-primary-800: 39 39 42;
|
||||
--tw-color-primary-900: 24 24 27;
|
||||
--tw-color-primary-950: 9 9 11;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafafa */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f4f4f5 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e4e4e7 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d4d4d8 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a1a1aa */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #71717a */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #52525b */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #3f3f46 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #27272a */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #18181b */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #09090b */
|
||||
}
|
||||
|
||||
.neutral {
|
||||
--tw-color-primary-50: 250 250 250;
|
||||
--tw-color-primary-100: 245 245 245;
|
||||
--tw-color-primary-200: 229 229 229;
|
||||
--tw-color-primary-300: 212 212 212;
|
||||
--tw-color-primary-400: 163 163 163;
|
||||
--tw-color-primary-500: 115 115 115;
|
||||
--tw-color-primary-600: 82 82 82;
|
||||
--tw-color-primary-700: 64 64 64;
|
||||
--tw-color-primary-800: 38 38 38;
|
||||
--tw-color-primary-900: 23 23 23;
|
||||
--tw-color-primary-950: 10 10 10;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafafa */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f5f5f5 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e5e5e5 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d4d4d4 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a3a3a3 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #737373 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #525252 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #404040 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #262626 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #171717 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #0a0a0a */
|
||||
}
|
||||
|
||||
.stone {
|
||||
--tw-color-primary-50: 250 250 249;
|
||||
--tw-color-primary-100: 245 245 244;
|
||||
--tw-color-primary-200: 231 229 228;
|
||||
--tw-color-primary-300: 214 211 209;
|
||||
--tw-color-primary-400: 168 162 158;
|
||||
--tw-color-primary-500: 120 113 108;
|
||||
--tw-color-primary-600: 87 83 78;
|
||||
--tw-color-primary-700: 68 64 60;
|
||||
--tw-color-primary-800: 41 37 36;
|
||||
--tw-color-primary-900: 28 25 23;
|
||||
--tw-color-primary-950: 12 10 9;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafaf9 */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f5f5f4 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e7e5e4 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d6d3d1 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a8a29e */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #78716c */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #57534e */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #44403c */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #292524 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #1c1917 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #0c0a09 */
|
||||
}
|
||||
|
||||
.red {
|
||||
--tw-color-primary-50: 254 242 242;
|
||||
--tw-color-primary-100: 254 226 226;
|
||||
--tw-color-primary-200: 254 202 202;
|
||||
--tw-color-primary-300: 252 165 165;
|
||||
--tw-color-primary-400: 248 113 113;
|
||||
--tw-color-primary-500: 239 68 68;
|
||||
--tw-color-primary-600: 220 38 38;
|
||||
--tw-color-primary-700: 185 28 28;
|
||||
--tw-color-primary-800: 153 27 27;
|
||||
--tw-color-primary-900: 127 29 29;
|
||||
--tw-color-primary-950: 69 10 10;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fef2f2 */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fee2e2 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fecaca */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fca5a5 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #f87171 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #ef4444 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #dc2626 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #b91c1c */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #991b1b */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #7f1d1d */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #450a0a */
|
||||
}
|
||||
|
||||
.orange {
|
||||
--tw-color-primary-50: 255 247 237;
|
||||
--tw-color-primary-100: 255 237 213;
|
||||
--tw-color-primary-200: 254 215 170;
|
||||
--tw-color-primary-300: 253 186 116;
|
||||
--tw-color-primary-400: 251 146 60;
|
||||
--tw-color-primary-500: 249 115 22;
|
||||
--tw-color-primary-600: 234 88 12;
|
||||
--tw-color-primary-700: 194 65 12;
|
||||
--tw-color-primary-800: 154 52 18;
|
||||
--tw-color-primary-900: 124 45 18;
|
||||
--tw-color-primary-950: 67 20 7;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fff7ed */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ffedd5 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fed7aa */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fdba74 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fb923c */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f97316 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #ea580c */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #c2410c */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9a3412 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #7c2d12 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #431407 */
|
||||
}
|
||||
|
||||
.amber {
|
||||
--tw-color-primary-50: 255 251 235;
|
||||
--tw-color-primary-100: 254 243 199;
|
||||
--tw-color-primary-200: 253 230 138;
|
||||
--tw-color-primary-300: 252 211 77;
|
||||
--tw-color-primary-400: 251 191 36;
|
||||
--tw-color-primary-500: 245 158 11;
|
||||
--tw-color-primary-600: 217 119 6;
|
||||
--tw-color-primary-700: 180 83 9;
|
||||
--tw-color-primary-800: 146 64 14;
|
||||
--tw-color-primary-900: 120 53 15;
|
||||
--tw-color-primary-950: 69 26 3;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fffbeb */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fef3c7 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fde68a */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fcd34d */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fbbf24 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f59e0b */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #d97706 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #b45309 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #92400e */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #78350f */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #451a03 */
|
||||
}
|
||||
|
||||
.yellow {
|
||||
--tw-color-primary-50: 254 252 232;
|
||||
--tw-color-primary-100: 254 249 195;
|
||||
--tw-color-primary-200: 254 240 138;
|
||||
--tw-color-primary-300: 253 224 71;
|
||||
--tw-color-primary-400: 250 204 21;
|
||||
--tw-color-primary-500: 234 179 8;
|
||||
--tw-color-primary-600: 202 138 4;
|
||||
--tw-color-primary-700: 161 98 7;
|
||||
--tw-color-primary-800: 133 77 14;
|
||||
--tw-color-primary-900: 113 63 18;
|
||||
--tw-color-primary-950: 66 32 6;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fefce8 */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fef9c3 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fef08a */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fde047 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #facc15 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #eab308 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #ca8a04 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #a16207 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #854d0e */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #713f12 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #422006 */
|
||||
}
|
||||
.lime {
|
||||
--tw-color-primary-50: 247 254 231;
|
||||
--tw-color-primary-100: 236 252 203;
|
||||
--tw-color-primary-200: 217 249 157;
|
||||
--tw-color-primary-300: 190 242 100;
|
||||
--tw-color-primary-400: 163 230 53;
|
||||
--tw-color-primary-500: 132 204 22;
|
||||
--tw-color-primary-600: 101 163 13;
|
||||
--tw-color-primary-700: 77 124 15;
|
||||
--tw-color-primary-800: 63 98 18;
|
||||
--tw-color-primary-900: 54 83 20;
|
||||
--tw-color-primary-950: 26 46 5;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f7fee7 */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ecfccb */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #d9f99d */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #bef264 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a3e635 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #84cc16 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #65a30d */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #4d7c0f */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #3f6212 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #365314 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #1a2e05 */
|
||||
}
|
||||
|
||||
.green {
|
||||
--tw-color-primary-50: 240 253 244;
|
||||
--tw-color-primary-100: 220 252 231;
|
||||
--tw-color-primary-200: 187 247 208;
|
||||
--tw-color-primary-300: 134 239 172;
|
||||
--tw-color-primary-400: 74 222 128;
|
||||
--tw-color-primary-500: 34 197 94;
|
||||
--tw-color-primary-600: 22 163 74;
|
||||
--tw-color-primary-700: 21 128 61;
|
||||
--tw-color-primary-800: 22 101 52;
|
||||
--tw-color-primary-900: 20 83 45;
|
||||
--tw-color-primary-950: 5 46 22;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0fdf4 */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #dcfce7 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bbf7d0 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #86efac */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #4ade80 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #22c55e */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #16a34a */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #15803d */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #166534 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #14532d */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #052e16 */
|
||||
}
|
||||
|
||||
.emerald {
|
||||
--tw-color-primary-50: 236 253 245;
|
||||
--tw-color-primary-100: 209 250 229;
|
||||
--tw-color-primary-200: 167 243 208;
|
||||
--tw-color-primary-300: 110 231 183;
|
||||
--tw-color-primary-400: 52 211 153;
|
||||
--tw-color-primary-500: 16 185 129;
|
||||
--tw-color-primary-600: 5 150 105;
|
||||
--tw-color-primary-700: 4 120 87;
|
||||
--tw-color-primary-800: 6 95 70;
|
||||
--tw-color-primary-900: 6 78 59;
|
||||
--tw-color-primary-950: 2 44 34;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #ecfdf5 */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #d1fae5 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #a7f3d0 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #6ee7b7 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #34d399 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #10b981 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #059669 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #047857 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #065f46 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #064e3b */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #022c22 */
|
||||
}
|
||||
|
||||
.teal {
|
||||
--tw-color-primary-50: 240 253 250;
|
||||
--tw-color-primary-100: 204 251 241;
|
||||
--tw-color-primary-200: 153 246 228;
|
||||
--tw-color-primary-300: 94 234 212;
|
||||
--tw-color-primary-400: 45 212 191;
|
||||
--tw-color-primary-500: 20 184 166;
|
||||
--tw-color-primary-600: 13 148 136;
|
||||
--tw-color-primary-700: 15 118 110;
|
||||
--tw-color-primary-800: 17 94 89;
|
||||
--tw-color-primary-900: 19 78 74;
|
||||
--tw-color-primary-950: 4 47 46;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0fdfa */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ccfbf1 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #99f6e4 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #5eead4 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #2dd4bf */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #14b8a6 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0d9488 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0f766e */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #115e59 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #134e4a */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #042f2e */
|
||||
}
|
||||
|
||||
.cyan {
|
||||
--tw-color-primary-50: 236 254 255;
|
||||
--tw-color-primary-100: 207 250 254;
|
||||
--tw-color-primary-200: 165 243 252;
|
||||
--tw-color-primary-300: 103 232 249;
|
||||
--tw-color-primary-400: 34 211 238;
|
||||
--tw-color-primary-500: 6 182 212;
|
||||
--tw-color-primary-600: 8 145 178;
|
||||
--tw-color-primary-700: 14 116 144;
|
||||
--tw-color-primary-800: 21 94 117;
|
||||
--tw-color-primary-900: 22 78 99;
|
||||
--tw-color-primary-950: 8 51 68;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #ecfeff */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #cffafe */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #a5f3fc */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #67e8f9 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #22d3ee */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #06b6d4 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0891b2 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0e7490 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #155e75 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #164e63 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #083344 */
|
||||
}
|
||||
|
||||
.sky {
|
||||
--tw-color-primary-50: 240 249 255;
|
||||
--tw-color-primary-100: 224 242 254;
|
||||
--tw-color-primary-200: 186 230 253;
|
||||
--tw-color-primary-300: 125 211 252;
|
||||
--tw-color-primary-400: 56 189 248;
|
||||
--tw-color-primary-500: 14 165 233;
|
||||
--tw-color-primary-600: 2 132 199;
|
||||
--tw-color-primary-700: 3 105 161;
|
||||
--tw-color-primary-800: 7 89 133;
|
||||
--tw-color-primary-900: 12 74 110;
|
||||
--tw-color-primary-950: 8 47 73;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0f9ff */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0f2fe */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bae6fd */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #7dd3fc */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #38bdf8 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #0ea5e9 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0284c7 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0369a1 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #075985 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0c4a6e */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #082f49 */
|
||||
}
|
||||
|
||||
.blue {
|
||||
--tw-color-primary-50: 239 246 255;
|
||||
--tw-color-primary-100: 219 234 254;
|
||||
--tw-color-primary-200: 191 219 254;
|
||||
--tw-color-primary-300: 147 197 253;
|
||||
--tw-color-primary-400: 96 165 250;
|
||||
--tw-color-primary-500: 59 130 246;
|
||||
--tw-color-primary-600: 37 99 235;
|
||||
--tw-color-primary-700: 29 78 216;
|
||||
--tw-color-primary-800: 30 64 175;
|
||||
--tw-color-primary-900: 30 58 138;
|
||||
--tw-color-primary-950: 23 37 84;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #eff6ff */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #dbeafe */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bfdbfe */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #93c5fd */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #60a5fa */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #3b82f6 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #2563eb */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #1d4ed8 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1e40af */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #1e3a8a */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #172554 */
|
||||
}
|
||||
|
||||
.indigo {
|
||||
--tw-color-primary-50: 238 242 255;
|
||||
--tw-color-primary-100: 224 231 255;
|
||||
--tw-color-primary-200: 199 210 254;
|
||||
--tw-color-primary-300: 165 180 252;
|
||||
--tw-color-primary-400: 129 140 248;
|
||||
--tw-color-primary-500: 99 102 241;
|
||||
--tw-color-primary-600: 79 70 229;
|
||||
--tw-color-primary-700: 67 56 202;
|
||||
--tw-color-primary-800: 55 48 163;
|
||||
--tw-color-primary-900: 49 46 129;
|
||||
--tw-color-primary-950: 30 27 75;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #eef2ff */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0e7ff */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #c7d2fe */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #a5b4fc */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #818cf8 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #6366f1 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #4f46e5 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #4338ca */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #3730a3 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #312e81 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #1e1b4b */
|
||||
}
|
||||
|
||||
.violet {
|
||||
--tw-color-primary-50: 245 243 255;
|
||||
--tw-color-primary-100: 237 233 254;
|
||||
--tw-color-primary-200: 221 214 254;
|
||||
--tw-color-primary-300: 196 181 253;
|
||||
--tw-color-primary-400: 167 139 250;
|
||||
--tw-color-primary-500: 139 92 246;
|
||||
--tw-color-primary-600: 124 58 237;
|
||||
--tw-color-primary-700: 109 40 217;
|
||||
--tw-color-primary-800: 91 33 182;
|
||||
--tw-color-primary-900: 76 29 149;
|
||||
--tw-color-primary-950: 46 16 101;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f5f3ff */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ede9fe */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #ddd6fe */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #c4b5fd */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a78bfa */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #8b5cf6 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #7c3aed */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #6d28d9 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #5b21b6 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #4c1d95 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #2e1065 */
|
||||
}
|
||||
|
||||
.purple {
|
||||
--tw-color-primary-50: 250 245 255;
|
||||
--tw-color-primary-100: 243 232 255;
|
||||
--tw-color-primary-200: 233 213 255;
|
||||
--tw-color-primary-300: 216 180 254;
|
||||
--tw-color-primary-400: 192 132 252;
|
||||
--tw-color-primary-500: 168 85 247;
|
||||
--tw-color-primary-600: 147 51 234;
|
||||
--tw-color-primary-700: 126 34 206;
|
||||
--tw-color-primary-800: 107 33 168;
|
||||
--tw-color-primary-900: 88 28 135;
|
||||
--tw-color-primary-950: 59 7 100;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #faf5ff */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f3e8ff */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e9d5ff */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d8b4fe */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #c084fc */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #a855f7 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #9333ea */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #7e22ce */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #6b21a8 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #581c87 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #3b0764 */
|
||||
}
|
||||
|
||||
.fuchsia {
|
||||
--tw-color-primary-50: 253 244 255;
|
||||
--tw-color-primary-100: 250 232 255;
|
||||
--tw-color-primary-200: 245 208 254;
|
||||
--tw-color-primary-300: 240 171 252;
|
||||
--tw-color-primary-400: 232 121 249;
|
||||
--tw-color-primary-500: 217 70 239;
|
||||
--tw-color-primary-600: 192 38 211;
|
||||
--tw-color-primary-700: 162 28 175;
|
||||
--tw-color-primary-800: 134 25 143;
|
||||
--tw-color-primary-900: 112 26 117;
|
||||
--tw-color-primary-950: 74 4 78;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fdf4ff */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fae8ff */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #f5d0fe */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #f0abfc */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #e879f9 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #d946ef */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #c026d3 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #a21caf */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #86198f */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #701a75 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #4a044e */
|
||||
}
|
||||
|
||||
.pink {
|
||||
--tw-color-primary-50: 253 242 248;
|
||||
--tw-color-primary-100: 252 231 243;
|
||||
--tw-color-primary-200: 251 207 232;
|
||||
--tw-color-primary-300: 249 168 212;
|
||||
--tw-color-primary-400: 244 114 182;
|
||||
--tw-color-primary-500: 236 72 153;
|
||||
--tw-color-primary-600: 219 39 119;
|
||||
--tw-color-primary-700: 190 24 93;
|
||||
--tw-color-primary-800: 157 23 77;
|
||||
--tw-color-primary-900: 131 24 67;
|
||||
--tw-color-primary-950: 80 4 36;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fdf2f8 */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fce7f3 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fbcfe8 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #f9a8d4 */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #f472b6 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #ec4899 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #db2777 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #be185d */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9d174d */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #831843 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #500724 */
|
||||
}
|
||||
|
||||
.rose {
|
||||
--tw-color-primary-50: 255 241 242;
|
||||
--tw-color-primary-100: 255 228 230;
|
||||
--tw-color-primary-200: 254 205 211;
|
||||
--tw-color-primary-300: 253 164 175;
|
||||
--tw-color-primary-400: 251 113 133;
|
||||
--tw-color-primary-500: 244 63 94;
|
||||
--tw-color-primary-600: 225 29 72;
|
||||
--tw-color-primary-700: 190 18 60;
|
||||
--tw-color-primary-800: 159 18 57;
|
||||
--tw-color-primary-900: 136 19 55;
|
||||
--tw-color-primary-950: 76 5 25;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fff1f2 */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ffe4e6 */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fecdd3 */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fda4af */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fb7185 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f43f5e */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #e11d48 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #be123c */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9f1239 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #881337 */
|
||||
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #4c0519 */
|
||||
}
|
||||
118
src/styles/globals.css
Normal file
118
src/styles/globals.css
Normal file
@@ -0,0 +1,118 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* #region /**=========== Primary Color =========== */
|
||||
/* !STARTERCONF Customize these variable, copy and paste from /styles/colors.css for list of colors */
|
||||
--tw-color-primary-50: 240 249 255;
|
||||
--tw-color-primary-100: 224 242 254;
|
||||
--tw-color-primary-200: 186 230 253;
|
||||
--tw-color-primary-300: 125 211 252;
|
||||
--tw-color-primary-400: 56 189 248;
|
||||
--tw-color-primary-500: 14 165 233;
|
||||
--tw-color-primary-600: 2 132 199;
|
||||
--tw-color-primary-700: 3 105 161;
|
||||
--tw-color-primary-800: 7 89 133;
|
||||
--tw-color-primary-900: 12 74 110;
|
||||
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0f9ff */
|
||||
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0f2fe */
|
||||
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bae6fd */
|
||||
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #7dd3fc */
|
||||
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #38bdf8 */
|
||||
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #0ea5e9 */
|
||||
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0284c7 */
|
||||
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0369a1 */
|
||||
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #075985 */
|
||||
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0c4a6e */
|
||||
/* #endregion /**======== Primary Color =========== */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* inter var - latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: block;
|
||||
src: url('/fonts/inter-var-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
.cursor-newtab {
|
||||
cursor: url('/images/new-tab.png') 10 10, pointer;
|
||||
}
|
||||
|
||||
/* #region /**=========== Typography =========== */
|
||||
.h0 {
|
||||
@apply font-primary text-3xl font-bold md:text-5xl;
|
||||
}
|
||||
|
||||
h1,
|
||||
.h1 {
|
||||
@apply font-primary text-2xl font-bold md:text-4xl;
|
||||
}
|
||||
|
||||
h2,
|
||||
.h2 {
|
||||
@apply font-primary text-xl font-bold md:text-3xl;
|
||||
}
|
||||
|
||||
h3,
|
||||
.h3 {
|
||||
@apply font-primary text-lg font-bold md:text-2xl;
|
||||
}
|
||||
|
||||
h4,
|
||||
.h4 {
|
||||
@apply font-primary text-base font-bold md:text-lg;
|
||||
}
|
||||
|
||||
body,
|
||||
.p {
|
||||
@apply font-primary text-sm md:text-base;
|
||||
}
|
||||
/* #endregion /**======== Typography =========== */
|
||||
|
||||
.layout {
|
||||
/* 1100px */
|
||||
max-width: 68.75rem;
|
||||
@apply mx-auto w-11/12;
|
||||
}
|
||||
|
||||
.bg-dark a.custom-link {
|
||||
@apply border-gray-200 hover:border-gray-200/0;
|
||||
}
|
||||
|
||||
/* Class to adjust with sticky footer */
|
||||
.min-h-main {
|
||||
@apply min-h-[calc(100vh-56px)];
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animated-underline {
|
||||
background-image: linear-gradient(#33333300, #33333300),
|
||||
linear-gradient(
|
||||
to right,
|
||||
var(--color-primary-400),
|
||||
var(--color-primary-500)
|
||||
);
|
||||
background-size: 100% 2px, 0 2px;
|
||||
background-position: 100% 100%, 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.animated-underline {
|
||||
transition: 0.3s ease;
|
||||
transition-property: background-size, color, background-color,
|
||||
border-color;
|
||||
}
|
||||
}
|
||||
.animated-underline:hover,
|
||||
.animated-underline:focus-visible {
|
||||
background-size: 0 2px, 100% 2px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user