first commit

This commit is contained in:
JohnsonRan
2025-08-12 21:50:58 +08:00
commit b4ed660eca
121 changed files with 36497 additions and 0 deletions
+2110
View File
@@ -0,0 +1,2110 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
'use client';
import {
closestCenter,
DndContext,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
Check,
ChevronDown,
ChevronUp,
ExternalLink,
FolderOpen,
Settings,
Users,
Video,
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import { Suspense, useCallback, useEffect, useState } from 'react';
import Swal from 'sweetalert2';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import PageLayout from '@/components/PageLayout';
// 统一弹窗方法(必须在首次使用前定义)
const showError = (message: string) =>
Swal.fire({ icon: 'error', title: '错误', text: message });
const showSuccess = (message: string) =>
Swal.fire({
icon: 'success',
title: '成功',
text: message,
timer: 2000,
showConfirmButton: false,
});
// 新增站点配置类型
interface SiteConfig {
SiteName: string;
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
DoubanProxyType: string;
DoubanProxy: string;
DoubanImageProxyType: string;
DoubanImageProxy: string;
DisableYellowFilter: boolean;
}
// 视频源数据类型
interface DataSource {
name: string;
key: string;
api: string;
detail?: string;
disabled?: boolean;
from: 'config' | 'custom';
}
// 自定义分类数据类型
interface CustomCategory {
name?: string;
type: 'movie' | 'tv';
query: string;
disabled?: boolean;
from: 'config' | 'custom';
}
// 可折叠标签组件
interface CollapsibleTabProps {
title: string;
icon?: React.ReactNode;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
const CollapsibleTab = ({
title,
icon,
isExpanded,
onToggle,
children,
}: CollapsibleTabProps) => {
return (
<div className='rounded-xl shadow-sm mb-4 overflow-hidden bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700'>
<button
onClick={onToggle}
className='w-full px-6 py-4 flex items-center justify-between bg-gray-50/70 dark:bg-gray-800/60 hover:bg-gray-100/80 dark:hover:bg-gray-700/60 transition-colors'
>
<div className='flex items-center gap-3'>
{icon}
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
{title}
</h3>
</div>
<div className='text-gray-500 dark:text-gray-400'>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</button>
{isExpanded && <div className='px-6 py-4'>{children}</div>}
</div>
);
};
// 用户配置组件
interface UserConfigProps {
config: AdminConfig | null;
role: 'owner' | 'admin' | null;
refreshConfig: () => Promise<void>;
}
const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
const [userSettings, setUserSettings] = useState({
enableRegistration: false,
});
const [showAddUserForm, setShowAddUserForm] = useState(false);
const [showChangePasswordForm, setShowChangePasswordForm] = useState(false);
const [newUser, setNewUser] = useState({
username: '',
password: '',
});
const [changePasswordUser, setChangePasswordUser] = useState({
username: '',
password: '',
});
// 当前登录用户名
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
useEffect(() => {
if (config?.UserConfig) {
setUserSettings({
enableRegistration: config.UserConfig.AllowRegister,
});
}
}, [config]);
// 切换允许注册设置
const toggleAllowRegister = async (value: boolean) => {
try {
// 先更新本地 UI
setUserSettings((prev) => ({ ...prev, enableRegistration: value }));
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'setAllowRegister',
allowRegister: value,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
// revert toggle UI
setUserSettings((prev) => ({ ...prev, enableRegistration: !value }));
}
};
const handleBanUser = async (uname: string) => {
await handleUserAction('ban', uname);
};
const handleUnbanUser = async (uname: string) => {
await handleUserAction('unban', uname);
};
const handleSetAdmin = async (uname: string) => {
await handleUserAction('setAdmin', uname);
};
const handleRemoveAdmin = async (uname: string) => {
await handleUserAction('cancelAdmin', uname);
};
const handleAddUser = async () => {
if (!newUser.username || !newUser.password) return;
await handleUserAction('add', newUser.username, newUser.password);
setNewUser({ username: '', password: '' });
setShowAddUserForm(false);
};
const handleChangePassword = async () => {
if (!changePasswordUser.username || !changePasswordUser.password) return;
await handleUserAction(
'changePassword',
changePasswordUser.username,
changePasswordUser.password
);
setChangePasswordUser({ username: '', password: '' });
setShowChangePasswordForm(false);
};
const handleShowChangePasswordForm = (username: string) => {
setChangePasswordUser({ username, password: '' });
setShowChangePasswordForm(true);
setShowAddUserForm(false); // 关闭添加用户表单
};
const handleDeleteUser = async (username: string) => {
const { isConfirmed } = await Swal.fire({
title: '确认删除用户',
text: `删除用户 ${username} 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: '确认删除',
cancelButtonText: '取消',
confirmButtonColor: '#dc2626',
});
if (!isConfirmed) return;
await handleUserAction('deleteUser', username);
};
// 通用请求函数
const handleUserAction = async (
action:
| 'add'
| 'ban'
| 'unban'
| 'setAdmin'
| 'cancelAdmin'
| 'changePassword'
| 'deleteUser',
targetUsername: string,
targetPassword?: string
) => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername,
...(targetPassword ? { targetPassword } : {}),
action,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
// 成功后刷新配置(无需整页刷新)
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
}
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 用户统计 */}
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
</h4>
<div className='p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800'>
<div className='text-2xl font-bold text-green-800 dark:text-green-300'>
{config.UserConfig.Users.length}
</div>
<div className='text-sm text-green-600 dark:text-green-400'>
</div>
</div>
</div>
{/* 注册设置 */}
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
</h4>
<div className='flex items-center justify-between'>
<label
className={`text-gray-700 dark:text-gray-300 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<button
onClick={() =>
!isUpstashStorage &&
toggleAllowRegister(!userSettings.enableRegistration)
}
disabled={isUpstashStorage}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
userSettings.enableRegistration
? 'bg-green-600'
: 'bg-gray-200 dark:bg-gray-700'
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
userSettings.enableRegistration
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
</div>
{/* 用户列表 */}
<div>
<div className='flex items-center justify-between mb-3'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
onClick={() => {
setShowAddUserForm(!showAddUserForm);
if (showChangePasswordForm) {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}
}}
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
>
{showAddUserForm ? '取消' : '添加用户'}
</button>
</div>
{/* 添加用户表单 */}
{showAddUserForm && (
<div className='mb-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'>
<div className='flex flex-col sm:flex-row gap-4 sm:gap-3'>
<input
type='text'
placeholder='用户名'
value={newUser.username}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, username: e.target.value }))
}
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<input
type='password'
placeholder='密码'
value={newUser.password}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, password: e.target.value }))
}
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<button
onClick={handleAddUser}
disabled={!newUser.username || !newUser.password}
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 修改密码表单 */}
{showChangePasswordForm && (
<div className='mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700'>
<h5 className='text-sm font-medium text-blue-800 dark:text-blue-300 mb-3'>
</h5>
<div className='flex flex-col sm:flex-row gap-4 sm:gap-3'>
<input
type='text'
placeholder='用户名'
value={changePasswordUser.username}
disabled
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 cursor-not-allowed'
/>
<input
type='password'
placeholder='新密码'
value={changePasswordUser.password}
onChange={(e) =>
setChangePasswordUser((prev) => ({
...prev,
password: e.target.value,
}))
}
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent'
/>
<button
onClick={handleChangePassword}
disabled={!changePasswordUser.password}
className='w-full sm:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
<button
onClick={() => {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}}
className='w-full sm:w-auto px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 用户列表 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<tr>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
</tr>
</thead>
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */}
{(() => {
const sortedUsers = [...config.UserConfig.Users].sort((a, b) => {
type UserInfo = (typeof config.UserConfig.Users)[number];
const priority = (u: UserInfo) => {
if (u.username === currentUsername) return 0;
if (u.role === 'owner') return 1;
if (u.role === 'admin') return 2;
return 3;
};
return priority(a) - priority(b);
});
return (
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{sortedUsers.map((user) => {
// 修改密码权限:站长可修改管理员和普通用户密码,管理员可修改普通用户和自己的密码,但任何人都不能修改站长密码
const canChangePassword =
user.role !== 'owner' && // 不能修改站长密码
(role === 'owner' || // 站长可以修改管理员和普通用户密码
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))); // 管理员可以修改普通用户和自己的密码
// 删除用户权限:站长可删除除自己外的所有用户,管理员仅可删除普通用户
const canDeleteUser =
user.username !== currentUsername &&
(role === 'owner' || // 站长可以删除除自己外的所有用户
(role === 'admin' && user.role === 'user')); // 管理员仅可删除普通用户
// 其他操作权限:不能操作自己,站长可操作所有用户,管理员可操作普通用户
const canOperate =
user.username !== currentUsername &&
(role === 'owner' ||
(role === 'admin' && user.role === 'user'));
return (
<tr
key={user.username}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'
>
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
{user.username}
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
className={`px-2 py-1 text-xs rounded-full ${
user.role === 'owner'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
: user.role === 'admin'
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{user.role === 'owner'
? '站长'
: user.role === 'admin'
? '管理员'
: '普通用户'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
className={`px-2 py-1 text-xs rounded-full ${
!user.banned
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!user.banned ? '正常' : '已封禁'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
{/* 修改密码按钮 */}
{canChangePassword && (
<button
onClick={() =>
handleShowChangePasswordForm(user.username)
}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors'
>
</button>
)}
{canOperate && (
<>
{/* 其他操作按钮 */}
{user.role === 'user' && (
<button
onClick={() => handleSetAdmin(user.username)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors'
>
</button>
)}
{user.role === 'admin' && (
<button
onClick={() =>
handleRemoveAdmin(user.username)
}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
>
</button>
)}
{user.role !== 'owner' &&
(!user.banned ? (
<button
onClick={() => handleBanUser(user.username)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-300 transition-colors'
>
</button>
) : (
<button
onClick={() =>
handleUnbanUser(user.username)
}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-300 transition-colors'
>
</button>
))}
</>
)}
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
{canDeleteUser && (
<button
onClick={() => handleDeleteUser(user.username)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 transition-colors'
>
</button>
)}
</td>
</tr>
);
})}
</tbody>
);
})()}
</table>
</div>
</div>
</div>
);
};
// 视频源配置组件
const VideoSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const [sources, setSources] = useState<DataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newSource, setNewSource] = useState<DataSource>({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'config',
});
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.SourceConfig) {
setSources(config.SourceConfig);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callSourceApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (key: string) => {
const target = sources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
callSourceApi({ action, key }).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
callSourceApi({ action: 'delete', key }).catch(() => {
console.error('操作失败', 'delete', key);
});
};
const handleAddSource = () => {
if (!newSource.name || !newSource.key || !newSource.api) return;
callSourceApi({
action: 'add',
key: newSource.key,
name: newSource.name,
api: newSource.api,
detail: newSource.detail,
})
.then(() => {
setNewSource({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newSource);
});
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sources.findIndex((s) => s.key === active.id);
const newIndex = sources.findIndex((s) => s.key === over.id);
setSources((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = sources.map((s) => s.key);
callSourceApi({ action: 'sort', order })
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ source }: { source: DataSource }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: source.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className='px-2 py-4 cursor-grab text-gray-400'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{source.name}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{source.key}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={source.api}
>
{source.api}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={source.detail || '-'}
>
{source.detail || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${
!source.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!source.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => handleToggleEnable(source.key)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
!source.disabled
? 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60'
: 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/60'
} transition-colors`}
>
{!source.disabled ? '禁用' : '启用'}
</button>
{source.from !== 'config' && (
<button
onClick={() => handleDelete(source.key)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
>
</button>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加视频源表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
onClick={() => setShowAddForm(!showAddForm)}
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
>
{showAddForm ? '取消' : '添加视频源'}
</button>
</div>
{showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='名称'
value={newSource.name}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, name: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='Key'
value={newSource.key}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, key: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='API 地址'
value={newSource.api}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, api: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='Detail 地址(选填)'
value={newSource.detail}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, detail: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddSource}
disabled={!newSource.name || !newSource.key || !newSource.api}
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 视频源表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Key
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
API
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Detail
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={sources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{sources.map((source) => (
<DraggableRow key={source.key} source={source} />
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
className='px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
>
</button>
</div>
)}
</div>
);
};
// 分类配置组件
const CategoryConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const [categories, setCategories] = useState<CustomCategory[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newCategory, setNewCategory] = useState<CustomCategory>({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'config',
});
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.CustomCategories) {
setCategories(config.CustomCategories);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callCategoryApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/category', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (query: string, type: 'movie' | 'tv') => {
const target = categories.find((c) => c.query === query && c.type === type);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
callCategoryApi({ action, query, type }).catch(() => {
console.error('操作失败', action, query, type);
});
};
const handleDelete = (query: string, type: 'movie' | 'tv') => {
callCategoryApi({ action: 'delete', query, type }).catch(() => {
console.error('操作失败', 'delete', query, type);
});
};
const handleAddCategory = () => {
if (!newCategory.name || !newCategory.query) return;
callCategoryApi({
action: 'add',
name: newCategory.name,
type: newCategory.type,
query: newCategory.query,
})
.then(() => {
setNewCategory({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newCategory);
});
};
const handleDragEnd = (event: any) => {
if (isUpstashStorage) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = categories.findIndex(
(c) => `${c.query}:${c.type}` === active.id
);
const newIndex = categories.findIndex(
(c) => `${c.query}:${c.type}` === over.id
);
setCategories((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = categories.map((c) => `${c.query}:${c.type}`);
callCategoryApi({ action: 'sort', order })
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ category }: { category: CustomCategory }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: `${category.query}:${category.type}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className={`px-2 py-4 ${
isUpstashStorage ? 'text-gray-200' : 'cursor-grab text-gray-400'
}`}
style={{ touchAction: 'none' }}
{...(isUpstashStorage ? {} : { ...attributes, ...listeners })}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{category.name || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
<span
className={`px-2 py-1 text-xs rounded-full ${
category.type === 'movie'
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
}`}
>
{category.type === 'movie' ? '电影' : '电视剧'}
</span>
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={category.query}
>
{category.query}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${
!category.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!category.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() =>
!isUpstashStorage &&
handleToggleEnable(category.query, category.type)
}
disabled={isUpstashStorage}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
isUpstashStorage
? 'bg-gray-400 cursor-not-allowed text-white'
: !category.disabled
? 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60'
: 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/60'
} transition-colors`}
>
{!category.disabled ? '禁用' : '启用'}
</button>
{category.from !== 'config' && !isUpstashStorage && (
<button
onClick={() => handleDelete(category.query, category.type)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
>
</button>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加分类表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</h4>
<button
onClick={() => !isUpstashStorage && setShowAddForm(!showAddForm)}
disabled={isUpstashStorage}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
isUpstashStorage
? 'bg-gray-400 cursor-not-allowed text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{showAddForm ? '取消' : '添加分类'}
</button>
</div>
{showAddForm && !isUpstashStorage && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='分类名称'
value={newCategory.name}
onChange={(e) =>
setNewCategory((prev) => ({ ...prev, name: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<select
value={newCategory.type}
onChange={(e) =>
setNewCategory((prev) => ({
...prev,
type: e.target.value as 'movie' | 'tv',
}))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
>
<option value='movie'></option>
<option value='tv'></option>
</select>
<input
type='text'
placeholder='搜索关键词'
value={newCategory.query}
onChange={(e) =>
setNewCategory((prev) => ({ ...prev, query: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddCategory}
disabled={!newCategory.name || !newCategory.query}
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 分类表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={isUpstashStorage ? [] : sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={categories.map((c) => `${c.query}:${c.type}`)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{categories.map((category) => (
<DraggableRow
key={`${category.query}:${category.type}`}
category={category}
/>
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && !isUpstashStorage && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
className='px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
>
</button>
</div>
)}
</div>
);
};
// 新增站点配置组件
const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
SiteName: '',
Announcement: '',
SearchDownstreamMaxPage: 1,
SiteInterfaceCacheTime: 7200,
DoubanProxyType: 'direct',
DoubanProxy: '',
DoubanImageProxyType: 'direct',
DoubanImageProxy: '',
DisableYellowFilter: false,
});
// 保存状态
const [saving, setSaving] = useState(false);
// 豆瓣数据源相关状态
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 Anywhere20 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 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;
}
};
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
useEffect(() => {
if (config?.SiteConfig) {
setSiteSettings({
...config.SiteConfig,
DoubanProxyType: config.SiteConfig.DoubanProxyType || 'direct',
DoubanProxy: config.SiteConfig.DoubanProxy || '',
DoubanImageProxyType:
config.SiteConfig.DoubanImageProxyType || 'direct',
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
});
}
}, [config]);
// 点击外部区域关闭下拉框
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 handleDoubanDataSourceChange = (value: string) => {
if (!isUpstashStorage) {
setSiteSettings((prev) => ({
...prev,
DoubanProxyType: value,
}));
}
};
// 处理豆瓣图片代理变化
const handleDoubanImageProxyChange = (value: string) => {
if (!isUpstashStorage) {
setSiteSettings((prev) => ({
...prev,
DoubanImageProxyType: value,
}));
}
};
// 保存站点配置
const handleSave = async () => {
try {
setSaving(true);
const resp = await fetch('/api/admin/site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...siteSettings }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `保存失败: ${resp.status}`);
}
showSuccess('保存成功, 请刷新页面');
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 站点名称 */}
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<input
type='text'
value={siteSettings.SiteName}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
}
disabled={isUpstashStorage}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
</div>
{/* 站点公告 */}
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<textarea
value={siteSettings.Announcement}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
Announcement: e.target.value,
}))
}
disabled={isUpstashStorage}
rows={3}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
</div>
{/* 豆瓣数据源设置 */}
<div className='space-y-3'>
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
disabled={isUpstashStorage}
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 ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{
doubanDataSourceOptions.find(
(option) => option.value === siteSettings.DoubanProxyType
)?.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 && !isUpstashStorage && (
<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 ${
siteSettings.DoubanProxyType === 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>
{siteSettings.DoubanProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
{/* 感谢信息 */}
{getThanksInfo(siteSettings.DoubanProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(siteSettings.DoubanProxyType)!.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(siteSettings.DoubanProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{siteSettings.DoubanProxyType === 'custom' && (
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
</label>
<input
type='text'
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanProxy}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DoubanProxy: e.target.value,
}))
}
disabled={isUpstashStorage}
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 ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
)}
</div>
{/* 豆瓣图片代理设置 */}
<div className='space-y-3'>
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<div className='relative' data-dropdown='douban-image-proxy'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
setIsDoubanImageProxyDropdownOpen(
!isDoubanImageProxyDropdownOpen
)
}
disabled={isUpstashStorage}
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 ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === siteSettings.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 ${
isDoubanImageProxyDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && !isUpstashStorage && (
<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={() => {
handleDoubanImageProxyChange(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 ${
siteSettings.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>
{siteSettings.DoubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
{/* 感谢信息 */}
{getThanksInfo(siteSettings.DoubanImageProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(siteSettings.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(siteSettings.DoubanImageProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{siteSettings.DoubanImageProxyType === 'custom' && (
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
</label>
<input
type='text'
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanImageProxy}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DoubanImageProxy: e.target.value,
}))
}
disabled={isUpstashStorage}
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 ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
)}
</div>
{/* 搜索接口可拉取最大页数 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='number'
min={1}
value={siteSettings.SearchDownstreamMaxPage}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
SearchDownstreamMaxPage: Number(e.target.value),
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
</div>
{/* 站点接口缓存时间 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='number'
min={1}
value={siteSettings.SiteInterfaceCacheTime}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
SiteInterfaceCacheTime: Number(e.target.value),
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
</div>
{/* 禁用黄色过滤器 */}
<div>
<div className='flex items-center justify-between'>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<button
type='button'
onClick={() =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DisableYellowFilter: !prev.DisableYellowFilter,
}))
}
disabled={isUpstashStorage}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.DisableYellowFilter
? 'bg-green-600'
: 'bg-gray-200 dark:bg-gray-700'
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
siteSettings.DisableYellowFilter
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button
onClick={handleSave}
disabled={saving}
className={`px-4 py-2 ${
saving
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700'
} text-white rounded-lg transition-colors`}
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
);
};
function AdminPageClient() {
const [config, setConfig] = useState<AdminConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [role, setRole] = useState<'owner' | 'admin' | null>(null);
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
userConfig: false,
videoSource: false,
siteConfig: false,
categoryConfig: false,
});
// 获取管理员配置
// showLoading 用于控制是否在请求期间显示整体加载骨架。
const fetchConfig = useCallback(async (showLoading = false) => {
try {
if (showLoading) {
setLoading(true);
}
const response = await fetch(`/api/admin/config`);
if (!response.ok) {
const data = (await response.json()) as any;
throw new Error(`获取配置失败: ${data.error}`);
}
const data = (await response.json()) as AdminConfigResult;
setConfig(data.Config);
setRole(data.Role);
} catch (err) {
const msg = err instanceof Error ? err.message : '获取配置失败';
showError(msg);
setError(msg);
} finally {
if (showLoading) {
setLoading(false);
}
}
}, []);
useEffect(() => {
// 首次加载时显示骨架
fetchConfig(true);
}, [fetchConfig]);
// 切换标签展开状态
const toggleTab = (tabKey: string) => {
setExpandedTabs((prev) => ({
...prev,
[tabKey]: !prev[tabKey],
}));
};
// 新增: 重置配置处理函数
const handleResetConfig = async () => {
const { isConfirmed } = await Swal.fire({
title: '确认重置配置',
text: '此操作将重置用户封禁和管理员设置、自定义视频源,站点配置将重置为默认值,是否继续?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: '确认',
cancelButtonText: '取消',
});
if (!isConfirmed) return;
try {
const response = await fetch(`/api/admin/reset`);
if (!response.ok) {
throw new Error(`重置失败: ${response.status}`);
}
showSuccess('重置成功,请刷新页面!');
} catch (err) {
showError(err instanceof Error ? err.message : '重置失败');
}
};
if (loading) {
return (
<PageLayout activePath='/admin'>
<div className='px-2 sm:px-10 py-4 sm:py-8'>
<div className='max-w-[95%] mx-auto'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-8'>
</h1>
<div className='space-y-4'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='h-20 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse'
/>
))}
</div>
</div>
</div>
</PageLayout>
);
}
if (error) {
// 错误已通过 SweetAlert2 展示,此处直接返回空
return null;
}
return (
<PageLayout activePath='/admin'>
<div className='px-2 sm:px-10 py-4 sm:py-8'>
<div className='max-w-[95%] mx-auto'>
{/* 标题 + 重置配置按钮 */}
<div className='flex items-center gap-2 mb-8'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
</h1>
{config && role === 'owner' && (
<button
onClick={handleResetConfig}
className='px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-md transition-colors'
>
</button>
)}
</div>
{/* 站点配置标签 */}
<CollapsibleTab
title='站点配置'
icon={
<Settings
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.siteConfig}
onToggle={() => toggleTab('siteConfig')}
>
<SiteConfigComponent config={config} />
</CollapsibleTab>
<div className='space-y-4'>
{/* 用户配置标签 */}
<CollapsibleTab
title='用户配置'
icon={
<Users size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.userConfig}
onToggle={() => toggleTab('userConfig')}
>
<UserConfig
config={config}
role={role}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 视频源配置标签 */}
<CollapsibleTab
title='视频源配置'
icon={
<Video size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.videoSource}
onToggle={() => toggleTab('videoSource')}
>
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 分类配置标签 */}
<CollapsibleTab
title='分类配置'
icon={
<FolderOpen
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.categoryConfig}
onToggle={() => toggleTab('categoryConfig')}
>
<CategoryConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
</div>
</div>
</div>
</PageLayout>
);
}
export default function AdminPage() {
return (
<Suspense>
<AdminPageClient />
</Suspense>
);
}
+209
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File
@@ -0,0 +1,2108 @@
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
'use client';
import Artplayer from 'artplayer';
import Hls from 'hls.js';
import { Heart } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
import {
deleteFavorite,
deletePlayRecord,
deleteSkipConfig,
generateStorageKey,
getAllPlayRecords,
getSkipConfig,
isFavorited,
saveFavorite,
savePlayRecord,
saveSkipConfig,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
declare global {
interface HTMLVideoElement {
hls?: any;
}
}
// Wake Lock API 类型声明
interface WakeLockSentinel {
released: boolean;
release(): Promise<void>;
addEventListener(type: 'release', listener: () => void): void;
removeEventListener(type: 'release', listener: () => void): void;
}
function PlayPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
// -----------------------------------------------------------------------------
// 状态变量(State
// -----------------------------------------------------------------------------
const [loading, setLoading] = useState(true);
const [loadingStage, setLoadingStage] = useState<
'searching' | 'preferring' | 'fetching' | 'ready'
>('searching');
const [loadingMessage, setLoadingMessage] = useState('正在搜索播放源...');
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<SearchResult | null>(null);
// 收藏状态
const [favorited, setFavorited] = useState(false);
// 跳过片头片尾配置
const [skipConfig, setSkipConfig] = useState<{
enable: boolean;
intro_time: number;
outro_time: number;
}>({
enable: false,
intro_time: 0,
outro_time: 0,
});
const skipConfigRef = useRef(skipConfig);
useEffect(() => {
skipConfigRef.current = skipConfig;
}, [
skipConfig,
skipConfig.enable,
skipConfig.intro_time,
skipConfig.outro_time,
]);
// 跳过检查的时间间隔控制
const lastSkipCheckRef = useRef(0);
// 去广告开关(从 localStorage 继承,默认 true
const [blockAdEnabled, setBlockAdEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('enable_blockad');
if (v !== null) return v === 'true';
}
return true;
});
const blockAdEnabledRef = useRef(blockAdEnabled);
useEffect(() => {
blockAdEnabledRef.current = blockAdEnabled;
}, [blockAdEnabled]);
// 视频基本信息
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
const [videoCover, setVideoCover] = useState('');
const [videoDoubanId, setVideoDoubanId] = useState(0);
// 当前源和ID
const [currentSource, setCurrentSource] = useState(
searchParams.get('source') || ''
);
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
// 搜索所需信息
const [searchTitle] = useState(searchParams.get('stitle') || '');
const [searchType] = useState(searchParams.get('stype') || '');
// 是否需要优选
const [needPrefer, setNeedPrefer] = useState(
searchParams.get('prefer') === 'true'
);
const needPreferRef = useRef(needPrefer);
useEffect(() => {
needPreferRef.current = needPrefer;
}, [needPrefer]);
// 集数相关
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(0);
const currentSourceRef = useRef(currentSource);
const currentIdRef = useRef(currentId);
const videoTitleRef = useRef(videoTitle);
const videoYearRef = useRef(videoYear);
const detailRef = useRef<SearchResult | null>(detail);
const currentEpisodeIndexRef = useRef(currentEpisodeIndex);
// 同步最新值到 refs
useEffect(() => {
currentSourceRef.current = currentSource;
currentIdRef.current = currentId;
detailRef.current = detail;
currentEpisodeIndexRef.current = currentEpisodeIndex;
videoTitleRef.current = videoTitle;
videoYearRef.current = videoYear;
}, [
currentSource,
currentId,
detail,
currentEpisodeIndex,
videoTitle,
videoYear,
]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
// 总集数
const totalEpisodes = detail?.episodes?.length || 0;
// 用于记录是否需要在播放器 ready 后跳转到指定进度
const resumeTimeRef = useRef<number | null>(null);
// 上次使用的音量,默认 0.7
const lastVolumeRef = useRef<number>(0.7);
// 上次使用的播放速率,默认 1.0
const lastPlaybackRateRef = useRef<number>(1.0);
// 换源相关状态
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
const [sourceSearchLoading, setSourceSearchLoading] = useState(false);
const [sourceSearchError, setSourceSearchError] = useState<string | null>(
null
);
// 优选和测速开关
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;
});
// 保存优选时的测速结果,避免EpisodeSelector重复测速
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
Map<string, { quality: string; loadSpeed: string; pingTime: number }>
>(new Map());
// 折叠状态(仅在 lg 及以上屏幕有效)
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
useState(false);
// 换源加载状态
const [isVideoLoading, setIsVideoLoading] = useState(true);
const [videoLoadingStage, setVideoLoadingStage] = useState<
'initing' | 'sourceChanging'
>('initing');
// 播放进度保存相关
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastSaveTimeRef = useRef<number>(0);
const artPlayerRef = useRef<any>(null);
const artRef = useRef<HTMLDivElement | null>(null);
// Wake Lock 相关
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
// -----------------------------------------------------------------------------
// 工具函数(Utils
// -----------------------------------------------------------------------------
// 播放源优选函数
const preferBestSource = async (
sources: SearchResult[]
): Promise<SearchResult> => {
if (sources.length === 1) return sources[0];
// 将播放源均分为两批,并发测速各批,避免一次性过多请求
const batchSize = Math.ceil(sources.length / 2);
const allResults: Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
} | null> = [];
for (let start = 0; start < sources.length; start += batchSize) {
const batchSources = sources.slice(start, start + batchSize);
const batchResults = await Promise.all(
batchSources.map(async (source) => {
try {
// 检查是否有第一集的播放地址
if (!source.episodes || source.episodes.length === 0) {
console.warn(`播放源 ${source.source_name} 没有可用的播放地址`);
return null;
}
const episodeUrl =
source.episodes.length > 1
? source.episodes[1]
: source.episodes[0];
const testResult = await getVideoResolutionFromM3u8(episodeUrl);
return {
source,
testResult,
};
} catch (error) {
return null;
}
})
);
allResults.push(...batchResults);
}
// 等待所有测速完成,包含成功和失败的结果
// 保存所有测速结果到 precomputedVideoInfo,供 EpisodeSelector 使用(包含错误结果)
const newVideoInfoMap = new Map<
string,
{
quality: string;
loadSpeed: string;
pingTime: number;
hasError?: boolean;
}
>();
allResults.forEach((result, index) => {
const source = sources[index];
const sourceKey = `${source.source}-${source.id}`;
if (result) {
// 成功的结果
newVideoInfoMap.set(sourceKey, result.testResult);
}
});
// 过滤出成功的结果用于优选计算
const successfulResults = allResults.filter(Boolean) as Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
}>;
setPrecomputedVideoInfo(newVideoInfoMap);
if (successfulResults.length === 0) {
console.warn('所有播放源测速都失败,使用第一个播放源');
return sources[0];
}
// 找出所有有效速度的最大值,用于线性映射
const validSpeeds = successfulResults
.map((result) => {
const speedStr = result.testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 0;
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2];
return unit === 'MB/s' ? value * 1024 : value; // 统一转换为 KB/s
})
.filter((speed) => speed > 0);
const maxSpeed = validSpeeds.length > 0 ? Math.max(...validSpeeds) : 1024; // 默认1MB/s作为基准
// 找出所有有效延迟的最小值和最大值,用于线性映射
const validPings = successfulResults
.map((result) => result.testResult.pingTime)
.filter((ping) => ping > 0);
const minPing = validPings.length > 0 ? Math.min(...validPings) : 50;
const maxPing = validPings.length > 0 ? Math.max(...validPings) : 1000;
// 计算每个结果的评分
const resultsWithScore = successfulResults.map((result) => ({
...result,
score: calculateSourceScore(
result.testResult,
maxSpeed,
minPing,
maxPing
),
}));
// 按综合评分排序,选择最佳播放源
resultsWithScore.sort((a, b) => b.score - a.score);
console.log('播放源评分排序结果:');
resultsWithScore.forEach((result, index) => {
console.log(
`${index + 1}. ${
result.source.source_name
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
result.testResult.loadSpeed
}, ${result.testResult.pingTime}ms)`
);
});
return resultsWithScore[0].source;
};
// 计算播放源综合评分
const calculateSourceScore = (
testResult: {
quality: string;
loadSpeed: string;
pingTime: number;
},
maxSpeed: number,
minPing: number,
maxPing: number
): number => {
let score = 0;
// 分辨率评分 (40% 权重)
const qualityScore = (() => {
switch (testResult.quality) {
case '4K':
return 100;
case '2K':
return 85;
case '1080p':
return 75;
case '720p':
return 60;
case '480p':
return 40;
case 'SD':
return 20;
default:
return 0;
}
})();
score += qualityScore * 0.4;
// 下载速度评分 (40% 权重) - 基于最大速度线性映射
const speedScore = (() => {
const speedStr = testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 30;
// 解析速度值
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) return 30;
const value = parseFloat(match[1]);
const unit = match[2];
const speedKBps = unit === 'MB/s' ? value * 1024 : value;
// 基于最大速度线性映射,最高100分
const speedRatio = speedKBps / maxSpeed;
return Math.min(100, Math.max(0, speedRatio * 100));
})();
score += speedScore * 0.4;
// 网络延迟评分 (20% 权重) - 基于延迟范围线性映射
const pingScore = (() => {
const ping = testResult.pingTime;
if (ping <= 0) return 0; // 无效延迟给默认分
// 如果所有延迟都相同,给满分
if (maxPing === minPing) return 100;
// 线性映射:最低延迟=100分,最高延迟=0分
const pingRatio = (maxPing - ping) / (maxPing - minPing);
return Math.min(100, Math.max(0, pingRatio * 100));
})();
score += pingScore * 0.2;
return Math.round(score * 100) / 100; // 保留两位小数
};
// 更新视频地址
const updateVideoUrl = (
detailData: SearchResult | null,
episodeIndex: number
) => {
if (
!detailData ||
!detailData.episodes ||
episodeIndex >= detailData.episodes.length
) {
setVideoUrl('');
return;
}
const newUrl = detailData?.episodes[episodeIndex] || '';
if (newUrl !== videoUrl) {
setVideoUrl(newUrl);
}
};
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
if (!video || !url) return;
const sources = Array.from(video.getElementsByTagName('source'));
const existed = sources.some((s) => s.src === url);
if (!existed) {
// 移除旧的 source,保持唯一
sources.forEach((s) => s.remove());
const sourceEl = document.createElement('source');
sourceEl.src = url;
video.appendChild(sourceEl);
}
// 始终允许远程播放(AirPlay / Cast
video.disableRemotePlayback = false;
// 如果曾经有禁用属性,移除之
if (video.hasAttribute('disableRemotePlayback')) {
video.removeAttribute('disableRemotePlayback');
}
};
// Wake Lock 相关函数
const requestWakeLock = async () => {
try {
if ('wakeLock' in navigator) {
wakeLockRef.current = await (navigator as any).wakeLock.request(
'screen'
);
console.log('Wake Lock 已启用');
}
} catch (err) {
console.warn('Wake Lock 请求失败:', err);
}
};
const releaseWakeLock = async () => {
try {
if (wakeLockRef.current) {
await wakeLockRef.current.release();
wakeLockRef.current = null;
console.log('Wake Lock 已释放');
}
} catch (err) {
console.warn('Wake Lock 释放失败:', err);
}
};
// 清理播放器资源的统一函数
const cleanupPlayer = () => {
if (artPlayerRef.current) {
try {
// 销毁 HLS 实例
if (artPlayerRef.current.video && artPlayerRef.current.video.hls) {
artPlayerRef.current.video.hls.destroy();
}
// 销毁 ArtPlayer 实例
artPlayerRef.current.destroy();
artPlayerRef.current = null;
console.log('播放器资源已清理');
} catch (err) {
console.warn('清理播放器资源时出错:', err);
artPlayerRef.current = null;
}
}
};
// 去广告相关函数
function filterAdsFromM3U8(m3u8Content: string): string {
if (!m3u8Content) return '';
// 按行分割M3U8内容
const lines = m3u8Content.split('\n');
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 只过滤#EXT-X-DISCONTINUITY标识
if (!line.includes('#EXT-X-DISCONTINUITY')) {
filteredLines.push(line);
}
}
return filteredLines.join('\n');
}
// 跳过片头片尾配置相关函数
const handleSkipConfigChange = async (newConfig: {
enable: boolean;
intro_time: number;
outro_time: number;
}) => {
if (!currentSourceRef.current || !currentIdRef.current) return;
try {
setSkipConfig(newConfig);
if (!newConfig.enable && !newConfig.intro_time && !newConfig.outro_time) {
await deleteSkipConfig(currentSourceRef.current, currentIdRef.current);
artPlayerRef.current.setting.update({
name: '跳过片头片尾',
html: '跳过片头片尾',
switch: skipConfigRef.current.enable,
onSwitch: function (item: any) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
});
artPlayerRef.current.setting.update({
name: '设置片头',
html: '设置片头',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfigRef.current.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfigRef.current.intro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
});
artPlayerRef.current.setting.update({
name: '设置片尾',
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfigRef.current.outro_time >= 0
? '设置片尾时间'
: `-${formatTime(-skipConfigRef.current.outro_time)}`,
onClick: function () {
const outroTime =
-(
artPlayerRef.current?.duration -
artPlayerRef.current?.currentTime
) || 0;
if (outroTime < 0) {
const newConfig = {
...skipConfigRef.current,
outro_time: outroTime,
};
handleSkipConfigChange(newConfig);
return `-${formatTime(-outroTime)}`;
}
},
});
} else {
await saveSkipConfig(
currentSourceRef.current,
currentIdRef.current,
newConfig
);
}
console.log('跳过片头片尾配置已保存:', newConfig);
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
}
};
const formatTime = (seconds: number): string => {
if (seconds === 0) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.round(seconds % 60);
if (hours === 0) {
// 不到一小时,格式为 00:00
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
} else {
// 超过一小时,格式为 00:00:00
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
};
class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
constructor(config: any) {
super(config);
const load = this.load.bind(this);
this.load = function (context: any, config: any, callbacks: any) {
// 拦截manifest和level请求
if (
(context as any).type === 'manifest' ||
(context as any).type === 'level'
) {
const onSuccess = callbacks.onSuccess;
callbacks.onSuccess = function (
response: any,
stats: any,
context: any
) {
// 如果是m3u8文件,处理内容以移除广告分段
if (response.data && typeof response.data === 'string') {
// 过滤掉广告段 - 实现更精确的广告过滤逻辑
response.data = filterAdsFromM3U8(response.data);
}
return onSuccess(response, stats, context, null);
};
}
// 执行原始load方法
load(context, config, callbacks);
};
}
}
// 当集数索引变化时自动更新视频地址
useEffect(() => {
updateVideoUrl(detail, currentEpisodeIndex);
}, [detail, currentEpisodeIndex]);
// 进入页面时直接获取全部源信息
useEffect(() => {
const fetchSourceDetail = async (
source: string,
id: string
): Promise<SearchResult[]> => {
try {
const detailResponse = await fetch(
`/api/detail?source=${source}&id=${id}`
);
if (!detailResponse.ok) {
throw new Error('获取视频详情失败');
}
const detailData = (await detailResponse.json()) as SearchResult;
setAvailableSources([detailData]);
return [detailData];
} catch (err) {
console.error('获取视频详情失败:', err);
return [];
} finally {
setSourceSearchLoading(false);
}
};
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
// 根据搜索词获取全部源信息
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}`
);
if (!response.ok) {
throw new Error('搜索失败');
}
const data = await response.json();
// 处理搜索结果,根据规则过滤
const results = data.results.filter(
(result: SearchResult) =>
result.title.replaceAll(' ', '').toLowerCase() ===
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
(videoYearRef.current
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
: true) &&
(searchType
? (searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
: true)
);
setAvailableSources(results);
return results;
} catch (err) {
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
setAvailableSources([]);
return [];
} finally {
setSourceSearchLoading(false);
}
};
const initAll = async () => {
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
setError('缺少必要参数');
setLoading(false);
return;
}
setLoading(true);
setLoadingStage(currentSource && currentId ? 'fetching' : 'searching');
setLoadingMessage(
currentSource && currentId
? '🎬 正在获取视频详情...'
: '🔍 正在搜索播放源...'
);
let sourcesInfo = await fetchSourcesData(searchTitle || videoTitle);
if (
currentSource &&
currentId &&
!sourcesInfo.some(
(source) => source.source === currentSource && source.id === currentId
)
) {
sourcesInfo = await fetchSourceDetail(currentSource, currentId);
}
if (sourcesInfo.length === 0) {
setError('未找到匹配结果');
setLoading(false);
return;
}
let detailData: SearchResult = sourcesInfo[0];
// 指定源和id且无需优选
if (currentSource && currentId && !needPreferRef.current) {
const target = sourcesInfo.find(
(source) => source.source === currentSource && source.id === currentId
);
if (target) {
detailData = target;
} else {
setError('未找到匹配结果');
setLoading(false);
return;
}
}
// 未指定源和 id 或需要优选,且开启优选开关
if (
(!currentSource || !currentId || needPreferRef.current) &&
optimizationEnabled
) {
setLoadingStage('preferring');
setLoadingMessage('⚡ 正在优选最佳播放源...');
detailData = await preferBestSource(sourcesInfo);
}
console.log(detailData.source, detailData.id);
setNeedPrefer(false);
setCurrentSource(detailData.source);
setCurrentId(detailData.id);
setVideoYear(detailData.year);
setVideoTitle(detailData.title || videoTitleRef.current);
setVideoCover(detailData.poster);
setVideoDoubanId(detailData.douban_id || 0);
setDetail(detailData);
if (currentEpisodeIndex >= detailData.episodes.length) {
setCurrentEpisodeIndex(0);
}
// 规范URL参数
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', detailData.source);
newUrl.searchParams.set('id', detailData.id);
newUrl.searchParams.set('year', detailData.year);
newUrl.searchParams.set('title', detailData.title);
newUrl.searchParams.delete('prefer');
window.history.replaceState({}, '', newUrl.toString());
setLoadingStage('ready');
setLoadingMessage('✨ 准备就绪,即将开始播放...');
// 短暂延迟让用户看到完成状态
setTimeout(() => {
setLoading(false);
}, 1000);
};
initAll();
}, []);
// 播放记录处理
useEffect(() => {
// 仅在初次挂载时检查播放记录
const initFromHistory = async () => {
if (!currentSource || !currentId) return;
try {
const allRecords = await getAllPlayRecords();
const key = generateStorageKey(currentSource, currentId);
const record = allRecords[key];
if (record) {
const targetIndex = record.index - 1;
const targetTime = record.play_time;
// 更新当前选集索引
if (targetIndex !== currentEpisodeIndex) {
setCurrentEpisodeIndex(targetIndex);
}
// 保存待恢复的播放进度,待播放器就绪后跳转
resumeTimeRef.current = targetTime;
}
} catch (err) {
console.error('读取播放记录失败:', err);
}
};
initFromHistory();
}, []);
// 跳过片头片尾配置处理
useEffect(() => {
// 仅在初次挂载时检查跳过片头片尾配置
const initSkipConfig = async () => {
if (!currentSource || !currentId) return;
try {
const config = await getSkipConfig(currentSource, currentId);
if (config) {
setSkipConfig(config);
}
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
}
};
initSkipConfig();
}, []);
// 处理换源
const handleSourceChange = async (
newSource: string,
newId: string,
newTitle: string
) => {
try {
// 显示换源加载状态
setVideoLoadingStage('sourceChanging');
setIsVideoLoading(true);
// 记录当前播放进度(仅在同一集数切换时恢复)
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
console.log('换源前当前播放时间:', currentPlayTime);
// 清除前一个历史记录
if (currentSourceRef.current && currentIdRef.current) {
try {
await deletePlayRecord(
currentSourceRef.current,
currentIdRef.current
);
console.log('已清除前一个播放记录');
} catch (err) {
console.error('清除播放记录失败:', err);
}
}
// 清除并设置下一个跳过片头片尾配置
if (currentSourceRef.current && currentIdRef.current) {
try {
await deleteSkipConfig(
currentSourceRef.current,
currentIdRef.current
);
await saveSkipConfig(newSource, newId, skipConfigRef.current);
} catch (err) {
console.error('清除跳过片头片尾配置失败:', err);
}
}
const newDetail = availableSources.find(
(source) => source.source === newSource && source.id === newId
);
if (!newDetail) {
setError('未找到匹配结果');
return;
}
// 尝试跳转到当前正在播放的集数
let targetIndex = currentEpisodeIndex;
// 如果当前集数超出新源的范围,则跳转到第一集
if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) {
targetIndex = 0;
}
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
if (targetIndex !== currentEpisodeIndex) {
resumeTimeRef.current = 0;
} else if (
(!resumeTimeRef.current || resumeTimeRef.current === 0) &&
currentPlayTime > 1
) {
resumeTimeRef.current = currentPlayTime;
}
// 更新URL参数(不刷新页面)
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', newSource);
newUrl.searchParams.set('id', newId);
newUrl.searchParams.set('year', newDetail.year);
window.history.replaceState({}, '', newUrl.toString());
setVideoTitle(newDetail.title || newTitle);
setVideoYear(newDetail.year);
setVideoCover(newDetail.poster);
setVideoDoubanId(newDetail.douban_id || 0);
setCurrentSource(newSource);
setCurrentId(newId);
setDetail(newDetail);
setCurrentEpisodeIndex(targetIndex);
} catch (err) {
// 隐藏换源加载状态
setIsVideoLoading(false);
setError(err instanceof Error ? err.message : '换源失败');
}
};
useEffect(() => {
document.addEventListener('keydown', handleKeyboardShortcuts);
return () => {
document.removeEventListener('keydown', handleKeyboardShortcuts);
};
}, []);
// ---------------------------------------------------------------------------
// 集数切换
// ---------------------------------------------------------------------------
// 处理集数切换
const handleEpisodeChange = (episodeNumber: number) => {
if (episodeNumber >= 0 && episodeNumber < totalEpisodes) {
// 在更换集数前保存当前播放进度
if (artPlayerRef.current && artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
setCurrentEpisodeIndex(episodeNumber);
}
};
const handlePreviousEpisode = () => {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && d.episodes && idx > 0) {
if (artPlayerRef.current && !artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
setCurrentEpisodeIndex(idx - 1);
}
};
const handleNextEpisode = () => {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && d.episodes && idx < d.episodes.length - 1) {
if (artPlayerRef.current && !artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
setCurrentEpisodeIndex(idx + 1);
}
};
// ---------------------------------------------------------------------------
// 键盘快捷键
// ---------------------------------------------------------------------------
// 处理全局快捷键
const handleKeyboardShortcuts = (e: KeyboardEvent) => {
// 忽略输入框中的按键事件
if (
(e.target as HTMLElement).tagName === 'INPUT' ||
(e.target as HTMLElement).tagName === 'TEXTAREA'
)
return;
// Alt + 左箭头 = 上一集
if (e.altKey && e.key === 'ArrowLeft') {
if (detailRef.current && currentEpisodeIndexRef.current > 0) {
handlePreviousEpisode();
e.preventDefault();
}
}
// Alt + 右箭头 = 下一集
if (e.altKey && e.key === 'ArrowRight') {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && idx < d.episodes.length - 1) {
handleNextEpisode();
e.preventDefault();
}
}
// 左箭头 = 快退
if (!e.altKey && e.key === 'ArrowLeft') {
if (artPlayerRef.current && artPlayerRef.current.currentTime > 5) {
artPlayerRef.current.currentTime -= 10;
e.preventDefault();
}
}
// 右箭头 = 快进
if (!e.altKey && e.key === 'ArrowRight') {
if (
artPlayerRef.current &&
artPlayerRef.current.currentTime < artPlayerRef.current.duration - 5
) {
artPlayerRef.current.currentTime += 10;
e.preventDefault();
}
}
// 上箭头 = 音量+
if (e.key === 'ArrowUp') {
if (artPlayerRef.current && artPlayerRef.current.volume < 1) {
artPlayerRef.current.volume =
Math.round((artPlayerRef.current.volume + 0.1) * 10) / 10;
artPlayerRef.current.notice.show = `音量: ${Math.round(
artPlayerRef.current.volume * 100
)}`;
e.preventDefault();
}
}
// 下箭头 = 音量-
if (e.key === 'ArrowDown') {
if (artPlayerRef.current && artPlayerRef.current.volume > 0) {
artPlayerRef.current.volume =
Math.round((artPlayerRef.current.volume - 0.1) * 10) / 10;
artPlayerRef.current.notice.show = `音量: ${Math.round(
artPlayerRef.current.volume * 100
)}`;
e.preventDefault();
}
}
// 空格 = 播放/暂停
if (e.key === ' ') {
if (artPlayerRef.current) {
artPlayerRef.current.toggle();
e.preventDefault();
}
}
// f 键 = 切换全屏
if (e.key === 'f' || e.key === 'F') {
if (artPlayerRef.current) {
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
e.preventDefault();
}
}
};
// ---------------------------------------------------------------------------
// 播放记录相关
// ---------------------------------------------------------------------------
// 保存播放进度
const saveCurrentPlayProgress = async () => {
if (
!artPlayerRef.current ||
!currentSourceRef.current ||
!currentIdRef.current ||
!videoTitleRef.current ||
!detailRef.current?.source_name
) {
return;
}
const player = artPlayerRef.current;
const currentTime = player.currentTime || 0;
const duration = player.duration || 0;
// 如果播放时间太短(少于5秒)或者视频时长无效,不保存
if (currentTime < 1 || !duration) {
return;
}
try {
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year,
cover: detailRef.current?.poster || '',
index: currentEpisodeIndexRef.current + 1, // 转换为1基索引
total_episodes: detailRef.current?.episodes.length || 1,
play_time: Math.floor(currentTime),
total_time: Math.floor(duration),
save_time: Date.now(),
search_title: searchTitle,
});
lastSaveTimeRef.current = Date.now();
console.log('播放进度已保存:', {
title: videoTitleRef.current,
episode: currentEpisodeIndexRef.current + 1,
year: detailRef.current?.year,
progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`,
});
} catch (err) {
console.error('保存播放进度失败:', err);
}
};
useEffect(() => {
// 页面即将卸载时保存播放进度和清理资源
const handleBeforeUnload = () => {
saveCurrentPlayProgress();
releaseWakeLock();
cleanupPlayer();
};
// 页面可见性变化时保存播放进度和释放 Wake Lock
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
saveCurrentPlayProgress();
releaseWakeLock();
} else if (document.visibilityState === 'visible') {
// 页面重新可见时,如果正在播放则重新请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
}
};
// 添加事件监听器
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
// 清理事件监听器
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [currentEpisodeIndex, detail, artPlayerRef.current]);
// 清理定时器
useEffect(() => {
return () => {
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
}
};
}, []);
// ---------------------------------------------------------------------------
// 收藏相关
// ---------------------------------------------------------------------------
// 每当 source 或 id 变化时检查收藏状态
useEffect(() => {
if (!currentSource || !currentId) return;
(async () => {
try {
const fav = await isFavorited(currentSource, currentId);
setFavorited(fav);
} catch (err) {
console.error('检查收藏状态失败:', err);
}
})();
}, [currentSource, currentId]);
// 监听收藏数据更新事件
useEffect(() => {
if (!currentSource || !currentId) return;
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(favorites: Record<string, any>) => {
const key = generateStorageKey(currentSource, currentId);
const isFav = !!favorites[key];
setFavorited(isFav);
}
);
return unsubscribe;
}, [currentSource, currentId]);
// 切换收藏
const handleToggleFavorite = async () => {
if (
!videoTitleRef.current ||
!detailRef.current ||
!currentSourceRef.current ||
!currentIdRef.current
)
return;
try {
if (favorited) {
// 如果已收藏,删除收藏
await deleteFavorite(currentSourceRef.current, currentIdRef.current);
setFavorited(false);
} else {
// 如果未收藏,添加收藏
await saveFavorite(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year,
cover: detailRef.current?.poster || '',
total_episodes: detailRef.current?.episodes.length || 1,
save_time: Date.now(),
search_title: searchTitle,
});
setFavorited(true);
}
} catch (err) {
console.error('切换收藏失败:', err);
}
};
useEffect(() => {
if (
!Artplayer ||
!Hls ||
!videoUrl ||
loading ||
currentEpisodeIndex === null ||
!artRef.current
) {
return;
}
// 确保选集索引有效
if (
!detail ||
!detail.episodes ||
currentEpisodeIndex >= detail.episodes.length ||
currentEpisodeIndex < 0
) {
setError(`选集索引无效,当前共 ${totalEpisodes}`);
return;
}
if (!videoUrl) {
setError('视频地址无效');
return;
}
console.log(videoUrl);
// 检测是否为WebKit浏览器
const isWebkit =
typeof window !== 'undefined' &&
typeof (window as any).webkitConvertPointFromNodeToPage === 'function';
// 非WebKit浏览器且播放器已存在,使用switch方法切换
if (!isWebkit && artPlayerRef.current) {
artPlayerRef.current.switch = videoUrl;
artPlayerRef.current.title = `${videoTitle} - 第${
currentEpisodeIndex + 1
}`;
artPlayerRef.current.poster = videoCover;
if (artPlayerRef.current?.video) {
ensureVideoSource(
artPlayerRef.current.video as HTMLVideoElement,
videoUrl
);
}
return;
}
// WebKit浏览器或首次创建:销毁之前的播放器实例并创建新的
if (artPlayerRef.current) {
cleanupPlayer();
}
try {
// 创建新的播放器实例
Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
Artplayer.USE_RAF = true;
artPlayerRef.current = new Artplayer({
container: artRef.current,
url: videoUrl,
poster: videoCover,
volume: 0.7,
isLive: false,
muted: false,
autoplay: true,
pip: true,
autoSize: false,
autoMini: false,
screenshot: false,
setting: true,
loop: false,
flip: false,
playbackRate: true,
aspectRatio: false,
fullscreen: true,
fullscreenWeb: true,
subtitleOffset: false,
miniProgressBar: false,
mutex: true,
playsInline: true,
autoPlayback: false,
airplay: true,
theme: '#22c55e',
lang: 'zh-cn',
hotkey: false,
fastForward: true,
autoOrientation: true,
lock: true,
moreVideoAttr: {
crossOrigin: 'anonymous',
},
// HLS 支持配置
customType: {
m3u8: function (video: HTMLVideoElement, url: string) {
if (!Hls) {
console.error('HLS.js 未加载');
return;
}
if (video.hls) {
video.hls.destroy();
}
const hls = new Hls({
debug: false, // 关闭日志
enableWorker: true, // WebWorker 解码,降低主线程压力
lowLatencyMode: true, // 开启低延迟 LL-HLS
/* 缓冲/内存相关 */
maxBufferLength: 30, // 前向缓冲最大 30s,过大容易导致高延迟
backBufferLength: 30, // 仅保留 30s 已播放内容,避免内存占用
maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理
/* 自定义loader */
loader: blockAdEnabledRef.current
? CustomHlsJsLoader
: Hls.DefaultConfig.loader,
});
hls.loadSource(url);
hls.attachMedia(video);
video.hls = hls;
ensureVideoSource(video, url);
hls.on(Hls.Events.ERROR, function (event: any, data: any) {
console.error('HLS Error:', event, data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log('网络错误,尝试恢复...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('媒体错误,尝试恢复...');
hls.recoverMediaError();
break;
default:
console.log('无法恢复的错误');
hls.destroy();
break;
}
}
});
},
},
icons: {
loading:
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
},
settings: [
{
html: '去广告',
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
tooltip: blockAdEnabled ? '已开启' : '已关闭',
onClick() {
const newVal = !blockAdEnabled;
try {
localStorage.setItem('enable_blockad', String(newVal));
if (artPlayerRef.current) {
resumeTimeRef.current = artPlayerRef.current.currentTime;
if (
artPlayerRef.current.video &&
artPlayerRef.current.video.hls
) {
artPlayerRef.current.video.hls.destroy();
}
artPlayerRef.current.destroy();
artPlayerRef.current = null;
}
setBlockAdEnabled(newVal);
} catch (_) {
// ignore
}
return newVal ? '当前开启' : '当前关闭';
},
},
{
name: '跳过片头片尾',
html: '跳过片头片尾',
switch: skipConfigRef.current.enable,
onSwitch: function (item) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
},
{
html: '删除跳过配置',
onClick: function () {
handleSkipConfigChange({
enable: false,
intro_time: 0,
outro_time: 0,
});
return '';
},
},
{
name: '设置片头',
html: '设置片头',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfigRef.current.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfigRef.current.intro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
},
{
name: '设置片尾',
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfigRef.current.outro_time >= 0
? '设置片尾时间'
: `-${formatTime(-skipConfigRef.current.outro_time)}`,
onClick: function () {
const outroTime =
-(
artPlayerRef.current?.duration -
artPlayerRef.current?.currentTime
) || 0;
if (outroTime < 0) {
const newConfig = {
...skipConfigRef.current,
outro_time: outroTime,
};
handleSkipConfigChange(newConfig);
return `-${formatTime(-outroTime)}`;
}
},
},
],
// 控制栏配置
controls: [
{
position: 'left',
index: 13,
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
tooltip: '播放下一集',
click: function () {
handleNextEpisode();
},
},
],
});
// 监听播放器事件
artPlayerRef.current.on('ready', () => {
setError(null);
// 播放器就绪后,如果正在播放则请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
});
// 监听播放状态变化,控制 Wake Lock
artPlayerRef.current.on('play', () => {
requestWakeLock();
});
artPlayerRef.current.on('pause', () => {
releaseWakeLock();
saveCurrentPlayProgress();
});
artPlayerRef.current.on('video:ended', () => {
releaseWakeLock();
});
// 如果播放器初始化时已经在播放状态,则请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
artPlayerRef.current.on('video:volumechange', () => {
lastVolumeRef.current = artPlayerRef.current.volume;
});
artPlayerRef.current.on('video:ratechange', () => {
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
});
// 监听视频可播放事件,这时恢复播放进度更可靠
artPlayerRef.current.on('video:canplay', () => {
// 若存在需要恢复的播放进度,则跳转
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
try {
const duration = artPlayerRef.current.duration || 0;
let target = resumeTimeRef.current;
if (duration && target >= duration - 2) {
target = Math.max(0, duration - 5);
}
artPlayerRef.current.currentTime = target;
console.log('成功恢复播放进度到:', resumeTimeRef.current);
} catch (err) {
console.warn('恢复播放进度失败:', err);
}
}
resumeTimeRef.current = null;
setTimeout(() => {
if (
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
) {
artPlayerRef.current.volume = lastVolumeRef.current;
}
if (
Math.abs(
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
) > 0.01 &&
isWebkit
) {
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
}
artPlayerRef.current.notice.show = '';
}, 0);
// 隐藏换源加载状态
setIsVideoLoading(false);
});
// 监听视频时间更新事件,实现跳过片头片尾
artPlayerRef.current.on('video:timeupdate', () => {
if (!skipConfigRef.current.enable) return;
const currentTime = artPlayerRef.current.currentTime || 0;
const duration = artPlayerRef.current.duration || 0;
const now = Date.now();
// 限制跳过检查频率为1.5秒一次
if (now - lastSkipCheckRef.current < 1500) return;
lastSkipCheckRef.current = now;
// 跳过片头
if (
skipConfigRef.current.intro_time > 0 &&
currentTime < skipConfigRef.current.intro_time
) {
artPlayerRef.current.currentTime = skipConfigRef.current.intro_time;
artPlayerRef.current.notice.show = `已跳过片头 (${formatTime(
skipConfigRef.current.intro_time
)})`;
}
// 跳过片尾
if (
skipConfigRef.current.outro_time < 0 &&
duration > 0 &&
currentTime >
artPlayerRef.current.duration + skipConfigRef.current.outro_time
) {
if (
currentEpisodeIndexRef.current <
(detailRef.current?.episodes?.length || 1) - 1
) {
handleNextEpisode();
} else {
artPlayerRef.current.pause();
}
artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime(
skipConfigRef.current.outro_time
)})`;
}
});
artPlayerRef.current.on('error', (err: any) => {
console.error('播放器错误:', err);
if (artPlayerRef.current.currentTime > 0) {
return;
}
});
// 监听视频播放结束事件,自动播放下一集
artPlayerRef.current.on('video:ended', () => {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && d.episodes && idx < d.episodes.length - 1) {
setTimeout(() => {
setCurrentEpisodeIndex(idx + 1);
}, 1000);
}
});
artPlayerRef.current.on('video:timeupdate', () => {
const now = Date.now();
let interval = 5000;
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') {
interval = 20000;
}
if (now - lastSaveTimeRef.current > interval) {
saveCurrentPlayProgress();
lastSaveTimeRef.current = now;
}
});
artPlayerRef.current.on('pause', () => {
saveCurrentPlayProgress();
});
if (artPlayerRef.current?.video) {
ensureVideoSource(
artPlayerRef.current.video as HTMLVideoElement,
videoUrl
);
}
} catch (err) {
console.error('创建播放器失败:', err);
setError('播放器初始化失败');
}
}, [Artplayer, Hls, videoUrl, loading, blockAdEnabled]);
// 当组件卸载时清理定时器、Wake Lock 和播放器资源
useEffect(() => {
return () => {
// 清理定时器
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
}
// 释放 Wake Lock
releaseWakeLock();
// 销毁播放器实例
cleanupPlayer();
};
}, []);
if (loading) {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>
{loadingStage === 'searching' && '🔍'}
{loadingStage === 'preferring' && '⚡'}
{loadingStage === 'fetching' && '🎬'}
{loadingStage === 'ready' && '✨'}
</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 进度指示器 */}
<div className='mb-6 w-80 mx-auto'>
<div className='flex justify-center space-x-2 mb-4'>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'searching' || loadingStage === 'fetching'
? 'bg-green-500 scale-125'
: loadingStage === 'preferring' ||
loadingStage === 'ready'
? 'bg-green-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'preferring'
? 'bg-green-500 scale-125'
: loadingStage === 'ready'
? 'bg-green-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'ready'
? 'bg-green-500 scale-125'
: 'bg-gray-300'
}`}
></div>
</div>
{/* 进度条 */}
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
<div
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
style={{
width:
loadingStage === 'searching' ||
loadingStage === 'fetching'
? '33%'
: loadingStage === 'preferring'
? '66%'
: '100%',
}}
></div>
</div>
</div>
{/* 加载消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
{loadingMessage}
</p>
</div>
</div>
</div>
</PageLayout>
);
}
if (error) {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
</div>
{/* 浮动错误粒子 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-yellow-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 错误信息 */}
<div className='space-y-4 mb-8'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-red-600 dark:text-red-400 font-medium'>
{error}
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() =>
videoTitle
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
: router.back()
}
className='w-full px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl font-medium hover:from-green-600 hover:to-emerald-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
</button>
<button
onClick={() => window.location.reload()}
className='w-full px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200'
>
🔄
</button>
</div>
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout activePath='/play'>
<div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl:px-20'>
{/* 第一行:影片标题 */}
<div className='py-1'>
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
{videoTitle || '影片标题'}
{totalEpisodes > 1 && (
<span className='text-gray-500 dark:text-gray-400'>
{` > 第 ${currentEpisodeIndex + 1}`}
</span>
)}
</h1>
</div>
{/* 第二行:播放器和选集 */}
<div className='space-y-2'>
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
<div className='hidden lg:flex justify-end'>
<button
onClick={() =>
setIsEpisodeSelectorCollapsed(!isEpisodeSelectorCollapsed)
}
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
title={
isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'
}
>
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M9 5l7 7-7 7'
/>
</svg>
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
</span>
{/* 精致的状态指示点 */}
<div
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${
isEpisodeSelectorCollapsed
? 'bg-orange-400 animate-pulse'
: 'bg-green-400'
}`}
></div>
</button>
</div>
<div
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'grid-cols-1'
: 'grid-cols-1 md:grid-cols-4'
}`}
>
{/* 播放器 */}
<div
className={`h-full transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 ${
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
}`}
>
<div className='relative w-full h-[300px] lg:h-full'>
<div
ref={artRef}
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg'
></div>
{/* 换源加载蒙层 */}
{isVideoLoading && (
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>🎬</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 换源消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-white animate-pulse'>
{videoLoadingStage === 'sourceChanging'
? '🔄 切换播放源...'
: '🔄 视频加载中...'}
</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
<div
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
}`}
>
<EpisodeSelector
totalEpisodes={totalEpisodes}
episodes_titles={detail?.episodes_titles || []}
value={currentEpisodeIndex + 1}
onChange={handleEpisodeChange}
onSourceChange={handleSourceChange}
currentSource={currentSource}
currentId={currentId}
videoTitle={searchTitle || videoTitle}
availableSources={availableSources}
sourceSearchLoading={sourceSearchLoading}
sourceSearchError={sourceSearchError}
precomputedVideoInfo={precomputedVideoInfo}
/>
</div>
</div>
</div>
{/* 详情展示 */}
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
{/* 文字区 */}
<div className='md:col-span-3'>
<div className='p-6 flex flex-col min-h-0'>
{/* 标题 */}
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
{videoTitle || '影片标题'}
<button
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite();
}}
className='ml-3 flex-shrink-0 hover:opacity-80 transition-opacity'
>
<FavoriteIcon filled={favorited} />
</button>
</h1>
{/* 关键信息行 */}
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
{detail?.class && (
<span className='text-green-600 font-semibold'>
{detail.class}
</span>
)}
{(detail?.year || videoYear) && (
<span>{detail?.year || videoYear}</span>
)}
{detail?.source_name && (
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
{detail.source_name}
</span>
)}
{detail?.type_name && <span>{detail.type_name}</span>}
</div>
{/* 剧情简介 */}
{detail?.desc && (
<div
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
style={{ whiteSpace: 'pre-line' }}
>
{detail.desc}
</div>
)}
</div>
</div>
{/* 封面展示 */}
<div className='hidden md:block md:col-span-1 md:order-first'>
<div className='pl-0 py-4 pr-6'>
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
{videoCover ? (
<>
<img
src={processImageUrl(videoCover)}
alt={videoTitle}
className='w-full h-full object-cover'
/>
{/* 豆瓣链接按钮 */}
{videoDoubanId !== 0 && (
<a
href={`https://movie.douban.com/subject/${videoDoubanId.toString()}`}
target='_blank'
rel='noopener noreferrer'
className='absolute top-3 left-3'
>
<div className='bg-green-500 text-white text-xs font-bold w-8 h-8 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<svg
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'></path>
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'></path>
</svg>
</div>
</a>
)}
</>
) : (
<span className='text-gray-600 dark:text-gray-400'>
</span>
)}
</div>
</div>
</div>
</div>
</div>
</PageLayout>
);
}
// FavoriteIcon 组件
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
if (filled) {
return (
<svg
className='h-7 w-7'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
fill='#ef4444' /* Tailwind red-500 */
stroke='#ef4444'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
}
return (
<Heart className='h-7 w-7 stroke-[1] text-gray-600 dark:text-gray-300' />
);
};
export default function PlayPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<PlayPageClient />
</Suspense>
);
}
+468
View 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
View 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>
);
}