Update
This commit is contained in:
@@ -0,0 +1,1417 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
'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 { ChevronDown, ChevronUp, 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;
|
||||
ImageProxy: string;
|
||||
DoubanProxy: string;
|
||||
}
|
||||
|
||||
// 视频源数据类型
|
||||
interface DataSource {
|
||||
name: string;
|
||||
key: string;
|
||||
api: string;
|
||||
detail?: 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;
|
||||
|
||||
// 检测存储类型是否为 d1
|
||||
const isD1Storage =
|
||||
typeof window !== 'undefined' &&
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
|
||||
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 ${
|
||||
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
允许新用户注册
|
||||
{isD1Storage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(D1 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
{isUpstashStorage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(Upstash 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
onClick={() =>
|
||||
!isD1Storage &&
|
||||
!isUpstashStorage &&
|
||||
toggleAllowRegister(!userSettings.enableRegistration)
|
||||
}
|
||||
disabled={isD1Storage || 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'
|
||||
} ${
|
||||
isD1Storage || 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 SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
|
||||
SiteName: '',
|
||||
Announcement: '',
|
||||
SearchDownstreamMaxPage: 1,
|
||||
SiteInterfaceCacheTime: 7200,
|
||||
ImageProxy: '',
|
||||
DoubanProxy: '',
|
||||
});
|
||||
// 保存状态
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 检测存储类型是否为 d1 或 upstash
|
||||
const isD1Storage =
|
||||
typeof window !== 'undefined' &&
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
|
||||
const isUpstashStorage =
|
||||
typeof window !== 'undefined' &&
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.SiteConfig) {
|
||||
setSiteSettings({
|
||||
...config.SiteConfig,
|
||||
ImageProxy: config.SiteConfig.ImageProxy || '',
|
||||
DoubanProxy: config.SiteConfig.DoubanProxy || '',
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// 保存站点配置
|
||||
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 ${
|
||||
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
站点名称
|
||||
{isD1Storage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(D1 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
{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) =>
|
||||
!isD1Storage &&
|
||||
!isUpstashStorage &&
|
||||
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
|
||||
}
|
||||
disabled={isD1Storage || 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 ${
|
||||
isD1Storage || isUpstashStorage
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 站点公告 */}
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
站点公告
|
||||
{isD1Storage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(D1 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
{isUpstashStorage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(Upstash 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<textarea
|
||||
value={siteSettings.Announcement}
|
||||
onChange={(e) =>
|
||||
!isD1Storage &&
|
||||
!isUpstashStorage &&
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
Announcement: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isD1Storage || 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 ${
|
||||
isD1Storage || isUpstashStorage
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
</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>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
图片代理前缀
|
||||
{isD1Storage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(D1 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
{isUpstashStorage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(Upstash 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='例如: https://imageproxy.example.com/?url='
|
||||
value={siteSettings.ImageProxy}
|
||||
onChange={(e) =>
|
||||
!isD1Storage &&
|
||||
!isUpstashStorage &&
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
ImageProxy: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isD1Storage || 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 ${
|
||||
isD1Storage || isUpstashStorage
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
用于代理图片访问,解决跨域或访问限制问题。留空则不使用代理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 豆瓣代理设置 */}
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
豆瓣代理地址
|
||||
{isD1Storage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(D1 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
{isUpstashStorage && (
|
||||
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
(Upstash 环境下请通过环境变量修改)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||
value={siteSettings.DoubanProxy}
|
||||
onChange={(e) =>
|
||||
!isD1Storage &&
|
||||
!isUpstashStorage &&
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
DoubanProxy: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isD1Storage || 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 ${
|
||||
isD1Storage || isUpstashStorage
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
用于代理豆瓣数据访问,解决跨域或访问限制问题。留空则使用服务端API。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || isD1Storage || isUpstashStorage}
|
||||
className={`px-4 py-2 ${
|
||||
saving || isD1Storage || isUpstashStorage
|
||||
? '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,
|
||||
});
|
||||
|
||||
// 获取管理员配置
|
||||
// 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AdminPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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') {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* 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,
|
||||
ImageProxy,
|
||||
DoubanProxy,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
SearchDownstreamMaxPage: number;
|
||||
SiteInterfaceCacheTime: number;
|
||||
ImageProxy: string;
|
||||
DoubanProxy: string;
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
if (
|
||||
typeof SiteName !== 'string' ||
|
||||
typeof Announcement !== 'string' ||
|
||||
typeof SearchDownstreamMaxPage !== 'number' ||
|
||||
typeof SiteInterfaceCacheTime !== 'number' ||
|
||||
typeof ImageProxy !== 'string' ||
|
||||
typeof DoubanProxy !== 'string'
|
||||
) {
|
||||
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') {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存中的站点设置
|
||||
adminConfig.SiteConfig = {
|
||||
SiteName,
|
||||
Announcement,
|
||||
SearchDownstreamMaxPage,
|
||||
SiteInterfaceCacheTime,
|
||||
ImageProxy,
|
||||
DoubanProxy,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime } from '@/lib/config';
|
||||
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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
async function fetchDoubanData(
|
||||
url: string
|
||||
): Promise<DoubanCategoryApiResponse> {
|
||||
// 添加超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
// 设置请求选项,包括信号和头部
|
||||
const fetchOptions = {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
Origin: 'https://movie.douban.com',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// 尝试直接访问豆瓣API
|
||||
const response = await fetch(url, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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(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}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime } from '@/lib/config';
|
||||
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||
|
||||
interface DoubanApiResponse {
|
||||
subjects: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function fetchDoubanData(url: string): Promise<DoubanApiResponse> {
|
||||
// 添加超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
// 设置请求选项,包括信号和头部
|
||||
const fetchOptions = {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// 尝试直接访问豆瓣API
|
||||
const response = await fetch(url, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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(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}`,
|
||||
},
|
||||
});
|
||||
} 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}`,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取豆瓣 Top250 数据失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
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 { 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 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 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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');
|
||||
|
||||
// 直接返回图片流
|
||||
return new Response(imageResponse.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching image' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/* 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'
|
||||
| 'd1'
|
||||
| '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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
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 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 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 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* 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'
|
||||
| 'd1'
|
||||
| '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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
|
||||
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}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const apiSites = await getAvailableApiSites();
|
||||
|
||||
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);
|
||||
const result = results.filter((r) => r.title === query);
|
||||
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}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '搜索失败',
|
||||
result: null,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
|
||||
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}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const apiSites = await getAvailableApiSites();
|
||||
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
|
||||
|
||||
try {
|
||||
const results = await Promise.all(searchPromises);
|
||||
const flattenedResults = results.flat();
|
||||
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}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
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 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 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 { 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/* eslint-disable no-console,react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getDoubanCategories } from '@/lib/douban.client';
|
||||
import { DoubanItem } from '@/lib/types';
|
||||
|
||||
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
|
||||
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);
|
||||
|
||||
const type = searchParams.get('type') || 'movie';
|
||||
|
||||
// 选择器状态 - 完全独立,不依赖URL参数
|
||||
const [primarySelection, setPrimarySelection] = useState<string>(() => {
|
||||
return type === 'movie' ? '热门' : '';
|
||||
});
|
||||
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
|
||||
if (type === 'movie') return '全部';
|
||||
if (type === 'tv') return 'tv';
|
||||
if (type === 'show') return 'show';
|
||||
return '全部';
|
||||
});
|
||||
|
||||
// 初始化时标记选择器为准备好状态
|
||||
useEffect(() => {
|
||||
// 短暂延迟确保初始状态设置完成
|
||||
const timer = setTimeout(() => {
|
||||
setSelectorsReady(true);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 只在组件挂载时执行一次
|
||||
|
||||
// type变化时立即重置selectorsReady(最高优先级)
|
||||
useEffect(() => {
|
||||
setSelectorsReady(false);
|
||||
setLoading(true); // 立即显示loading状态
|
||||
}, [type]);
|
||||
|
||||
// 当type变化时重置选择器状态
|
||||
useEffect(() => {
|
||||
// 批量更新选择器状态
|
||||
if (type === 'movie') {
|
||||
setPrimarySelection('热门');
|
||||
setSecondarySelection('全部');
|
||||
} else if (type === 'tv') {
|
||||
setPrimarySelection('');
|
||||
setSecondarySelection('tv');
|
||||
} else if (type === 'show') {
|
||||
setPrimarySelection('');
|
||||
setSecondarySelection('show');
|
||||
} else {
|
||||
setPrimarySelection('');
|
||||
setSecondarySelection('全部');
|
||||
}
|
||||
|
||||
// 使用短暂延迟确保状态更新完成后标记选择器准备好
|
||||
const timer = setTimeout(() => {
|
||||
setSelectorsReady(true);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [type]);
|
||||
|
||||
// 生成骨架屏数据
|
||||
const skeletonData = Array.from({ length: 25 }, (_, index) => index);
|
||||
|
||||
// 生成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 () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getDoubanCategories(getRequestParams(0));
|
||||
|
||||
if (data.code === 200) {
|
||||
setDoubanData(data.list);
|
||||
setHasMore(data.list.length === 25);
|
||||
setLoading(false);
|
||||
} else {
|
||||
throw new Error(data.message || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, [type, primarySelection, secondarySelection, getRequestParams]);
|
||||
|
||||
// 只在选择器准备好后才加载数据
|
||||
useEffect(() => {
|
||||
// 只有在选择器准备好时才开始加载
|
||||
if (!selectorsReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置页面状态
|
||||
setDoubanData([]);
|
||||
setCurrentPage(0);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
|
||||
// 清除之前的防抖定时器
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 使用防抖机制加载数据,避免连续状态更新触发多次请求
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
loadInitialData();
|
||||
}, 100); // 100ms 防抖延迟
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
selectorsReady,
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
loadInitialData,
|
||||
]);
|
||||
|
||||
// 单独处理 currentPage 变化(加载更多)
|
||||
useEffect(() => {
|
||||
if (currentPage > 0) {
|
||||
const fetchMoreData = async () => {
|
||||
try {
|
||||
setIsLoadingMore(true);
|
||||
|
||||
const data = await getDoubanCategories(
|
||||
getRequestParams(currentPage * 25)
|
||||
);
|
||||
|
||||
if (data.code === 200) {
|
||||
setDoubanData((prev) => [...prev, ...data.list]);
|
||||
setHasMore(data.list.length === 25);
|
||||
} else {
|
||||
throw new Error(data.message || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMoreData();
|
||||
}
|
||||
}, [currentPage, type, primarySelection, secondarySelection]);
|
||||
|
||||
// 设置滚动监听
|
||||
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);
|
||||
setPrimarySelection(value);
|
||||
}
|
||||
},
|
||||
[primarySelection]
|
||||
);
|
||||
|
||||
const handleSecondaryChange = useCallback(
|
||||
(value: string) => {
|
||||
// 只有当值真正改变时才设置loading状态
|
||||
if (value !== secondarySelection) {
|
||||
setLoading(true);
|
||||
setSecondarySelection(value);
|
||||
}
|
||||
},
|
||||
[secondarySelection]
|
||||
);
|
||||
|
||||
const getPageTitle = () => {
|
||||
// 根据 type 生成标题
|
||||
return type === 'movie' ? '电影' : type === 'tv' ? '电视剧' : '综艺';
|
||||
};
|
||||
|
||||
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'>
|
||||
来自豆瓣的精选内容
|
||||
</p>
|
||||
</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'>
|
||||
<DoubanSelector
|
||||
type={type as 'movie' | 'tv' | 'show'}
|
||||
primarySelection={primarySelection}
|
||||
secondarySelection={secondarySelection}
|
||||
onPrimaryChange={handlePrimaryChange}
|
||||
onSecondaryChange={handleSecondaryChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容展示区域 */}
|
||||
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
|
||||
{/* 内容网格 */}
|
||||
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,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={item.id}
|
||||
rate={item.rate}
|
||||
year={item.year}
|
||||
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制,tv 不控
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
/* 加载骨架屏淡紫色主题 */
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面进入动画 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* KatelyaTV Logo 彩虹渐变动画 */
|
||||
.katelya-logo {
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ff6b6b,
|
||||
#4ecdc4,
|
||||
#45b7d1,
|
||||
#96ceb4,
|
||||
#ffc645,
|
||||
#fd79a8,
|
||||
#6c5ce7,
|
||||
#a29bfe
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: rainbow-flow 4s ease-in-out infinite, logo-glow 2s ease-in-out infinite alternate;
|
||||
position: relative;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@keyframes rainbow-flow {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo-glow {
|
||||
0% {
|
||||
filter: drop-shadow(0 0 5px rgba(108, 92, 231, 0.4));
|
||||
}
|
||||
100% {
|
||||
filter: drop-shadow(0 0 20px rgba(108, 92, 231, 0.8)) drop-shadow(0 0 30px rgba(255, 107, 107, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
/* 主内容区大型 KatelyaTV Logo 容器 */
|
||||
.main-logo-container {
|
||||
padding: 3rem 0 4rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* 背景光效 */
|
||||
.logo-background-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 600px;
|
||||
height: 400px;
|
||||
background: radial-gradient(
|
||||
ellipse,
|
||||
rgba(147, 112, 219, 0.15) 0%,
|
||||
rgba(255, 107, 107, 0.1) 30%,
|
||||
rgba(76, 205, 196, 0.08) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: glow-pulse 4s ease-in-out infinite, glow-rotate 20s linear infinite;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-rotate {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 主内容区大型 Logo */
|
||||
.main-katelya-logo {
|
||||
font-size: 4rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ff6b6b,
|
||||
#4ecdc4,
|
||||
#45b7d1,
|
||||
#96ceb4,
|
||||
#ffc645,
|
||||
#fd79a8,
|
||||
#6c5ce7,
|
||||
#a29bfe,
|
||||
#ff6b6b
|
||||
);
|
||||
background-size: 600% 600%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: rainbow-flow-main 6s ease-in-out infinite, logo-float 3s ease-in-out infinite, logo-glow-main 4s ease-in-out infinite;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: 0 10px 30px rgba(147, 112, 219, 0.3);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@keyframes rainbow-flow-main {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
25% {
|
||||
background-position: 100% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
75% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo-float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo-glow-main {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 20px rgba(147, 112, 219, 0.4)) drop-shadow(0 0 40px rgba(255, 107, 107, 0.2));
|
||||
}
|
||||
33% {
|
||||
filter: drop-shadow(0 0 30px rgba(76, 205, 196, 0.4)) drop-shadow(0 0 50px rgba(69, 183, 209, 0.3));
|
||||
}
|
||||
66% {
|
||||
filter: drop-shadow(0 0 25px rgba(255, 198, 69, 0.4)) drop-shadow(0 0 45px rgba(253, 121, 168, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
/* 主 Logo 副标题 */
|
||||
.main-logo-subtitle {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: rgba(147, 112, 219, 0.8);
|
||||
animation: subtitle-shimmer 5s ease-in-out infinite;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.dark .main-logo-subtitle {
|
||||
color: rgba(186, 85, 211, 0.9);
|
||||
}
|
||||
|
||||
@keyframes subtitle-shimmer {
|
||||
0%, 100% {
|
||||
opacity: 0.7;
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo 装饰性粒子效果 */
|
||||
.logo-particles {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
animation: particle-float 8s linear infinite;
|
||||
}
|
||||
|
||||
.particle-1 {
|
||||
top: 20%;
|
||||
left: 15%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: linear-gradient(45deg, #ff6b6b, #fd79a8);
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.particle-2 {
|
||||
top: 70%;
|
||||
right: 20%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: linear-gradient(45deg, #4ecdc4, #45b7d1);
|
||||
animation-delay: -2s;
|
||||
}
|
||||
|
||||
.particle-3 {
|
||||
bottom: 30%;
|
||||
left: 25%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: linear-gradient(45deg, #96ceb4, #ffc645);
|
||||
animation-delay: -4s;
|
||||
}
|
||||
|
||||
.particle-4 {
|
||||
top: 40%;
|
||||
right: 30%;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
|
||||
animation-delay: -1s;
|
||||
}
|
||||
|
||||
.particle-5 {
|
||||
top: 60%;
|
||||
left: 10%;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: linear-gradient(45deg, #fd79a8, #ffc645);
|
||||
animation-delay: -3s;
|
||||
}
|
||||
|
||||
.particle-6 {
|
||||
bottom: 15%;
|
||||
right: 15%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: linear-gradient(45deg, #a29bfe, #ff6b6b);
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
@keyframes particle-float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) translateX(0px) rotate(0deg);
|
||||
opacity: 0.3;
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-20px) translateX(10px) rotate(90deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) translateX(-15px) rotate(180deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
75% {
|
||||
transform: translateY(-25px) translateX(5px) rotate(270deg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端主内容区 Logo 适配 */
|
||||
@media (max-width: 768px) {
|
||||
.main-logo-container {
|
||||
padding: 2rem 0 3rem 0;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.main-katelya-logo {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.main-logo-subtitle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logo-background-glow {
|
||||
width: 400px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.particle {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-katelya-logo {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.main-logo-subtitle {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logo-background-glow {
|
||||
width: 300px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部 KatelyaTV Logo 容器 */
|
||||
.bottom-logo-container {
|
||||
padding: 2rem 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bottom-logo-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(147, 112, 219, 0.1),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer-sweep 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer-sweep {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-logo {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ff6b6b,
|
||||
#4ecdc4,
|
||||
#45b7d1,
|
||||
#96ceb4,
|
||||
#ffc645,
|
||||
#fd79a8,
|
||||
#6c5ce7,
|
||||
#a29bfe,
|
||||
#ff6b6b
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: rainbow-flow 3s ease-in-out infinite, pulse-scale 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 0 0 30px rgba(147, 112, 219, 0.5);
|
||||
}
|
||||
|
||||
@keyframes pulse-scale {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端底部 Logo 调整 */
|
||||
@media (max-width: 768px) {
|
||||
.bottom-logo {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.bottom-logo-container {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 按钮悬停效果增强 */
|
||||
.btn-purple {
|
||||
background: linear-gradient(135deg, #9370db, #ba55d3);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-purple::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.btn-purple:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-purple:hover {
|
||||
background: linear-gradient(135deg, #8a2be2, #9370db);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(147, 112, 219, 0.4);
|
||||
}
|
||||
|
||||
/* 导航项悬停动画 */
|
||||
.nav-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #9370db, transparent);
|
||||
transition: left 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* 卡片悬停增强效果 */
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(45deg, transparent, rgba(147, 112, 219, 0.1), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-hover:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 20px 40px rgba(147, 112, 219, 0.2);
|
||||
}
|
||||
|
||||
/* 浮动几何图形 */
|
||||
.floating-shapes {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
opacity: 0.1;
|
||||
animation: float-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
.shape:nth-child(1) {
|
||||
top: 10%;
|
||||
left: 10%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
|
||||
border-radius: 50%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape:nth-child(2) {
|
||||
top: 70%;
|
||||
right: 15%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
|
||||
transform: rotate(45deg);
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.shape:nth-child(3) {
|
||||
bottom: 20%;
|
||||
left: 20%;
|
||||
width: 80px;
|
||||
height: 20px;
|
||||
background: linear-gradient(45deg, #ffc645, #fd79a8);
|
||||
border-radius: 10px;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.shape:nth-child(4) {
|
||||
top: 30%;
|
||||
right: 30%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: linear-gradient(45deg, #96ceb4, #45b7d1);
|
||||
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
|
||||
animation-delay: -15s;
|
||||
}
|
||||
|
||||
@keyframes float-rotate {
|
||||
0% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-20px) rotate(360deg);
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@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));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 动态背景特效 - 增强版 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(45deg, #e6e6fa, #dda0dd, #c8a2c8, #f0e6ff, #e6e6fa, #d8bfd8, #e6e6fa);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientFlow 12s ease infinite;
|
||||
}
|
||||
|
||||
/* 流光背景动画 */
|
||||
@keyframes gradientFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
33% {
|
||||
background-position: 100% 0%;
|
||||
}
|
||||
66% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式背景 */
|
||||
html.dark body::before {
|
||||
background: linear-gradient(45deg, #2a0845, #4a0e4e, #1a0a2e, #16213e, #2a0845, #3d1f69, #2a0845);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientFlow 12s ease infinite;
|
||||
}
|
||||
|
||||
/* 增强的浮动装饰元素 */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(3px 3px at 20px 30px, rgba(147, 112, 219, 0.5), transparent),
|
||||
radial-gradient(2px 2px at 40px 70px, rgba(186, 85, 211, 0.4), transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, rgba(221, 160, 221, 0.5), transparent),
|
||||
radial-gradient(2px 2px at 130px 80px, rgba(147, 112, 219, 0.4), transparent),
|
||||
radial-gradient(3px 3px at 160px 30px, rgba(138, 43, 226, 0.5), transparent),
|
||||
radial-gradient(1px 1px at 200px 90px, rgba(219, 112, 147, 0.4), transparent),
|
||||
radial-gradient(2px 2px at 250px 50px, rgba(147, 112, 219, 0.5), transparent),
|
||||
radial-gradient(4px 4px at 300px 120px, rgba(255, 107, 107, 0.3), transparent),
|
||||
radial-gradient(2px 2px at 350px 40px, rgba(76, 205, 196, 0.4), transparent);
|
||||
background-repeat: repeat;
|
||||
background-size: 350px 250px;
|
||||
animation: sparkle 25s linear infinite, float 18s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) translateX(0px) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-15px) translateX(10px) rotate(90deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px) translateX(-8px) rotate(180deg);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(-20px) translateX(5px) rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 - 增强版 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(147, 112, 219, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(147, 112, 219, 0.3), rgba(186, 85, 211, 0.4));
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(147, 112, 219, 0.6), rgba(186, 85, 211, 0.7));
|
||||
}
|
||||
|
||||
/* 视频卡片悬停效果 */
|
||||
.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%
|
||||
);
|
||||
}
|
||||
|
||||
/* 优化的圆角容器样式 - 主内容区 - 修改透明度为50% */
|
||||
.rounded-container {
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
/* 将透明度从0.85调整为0.5 (50%) */
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(147, 112, 219, 0.2);
|
||||
box-shadow:
|
||||
0 8px 40px rgba(147, 112, 219, 0.12),
|
||||
0 1px 0 rgba(255, 255, 255, 0.9) inset,
|
||||
0 0 0 1px rgba(147, 112, 219, 0.05) inset;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
/* 确保容器占据完整的分配空间 */
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rounded-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(147, 112, 219, 0.5), rgba(255, 107, 107, 0.3), rgba(147, 112, 219, 0.5), transparent);
|
||||
animation: shimmerTop 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmerTop {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.dark .rounded-container {
|
||||
/* 暗色模式下也将透明度调整为0.5 (50%) */
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(147, 112, 219, 0.3);
|
||||
box-shadow:
|
||||
0 8px 40px rgba(147, 112, 219, 0.25),
|
||||
0 1px 0 rgba(147, 112, 219, 0.15) inset,
|
||||
0 0 0 1px rgba(147, 112, 219, 0.1) inset;
|
||||
}
|
||||
|
||||
.dark .rounded-container::before {
|
||||
background: linear-gradient(90deg, transparent, rgba(147, 112, 219, 0.7), rgba(255, 107, 107, 0.4), rgba(147, 112, 219, 0.7), transparent);
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.rounded-container:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow:
|
||||
0 15px 50px rgba(147, 112, 219, 0.2),
|
||||
0 1px 0 rgba(255, 255, 255, 0.95) inset,
|
||||
0 0 0 1px rgba(147, 112, 219, 0.1) inset;
|
||||
}
|
||||
|
||||
.dark .rounded-container:hover {
|
||||
box-shadow:
|
||||
0 15px 50px rgba(147, 112, 219, 0.35),
|
||||
0 1px 0 rgba(147, 112, 219, 0.25) inset,
|
||||
0 0 0 1px rgba(147, 112, 219, 0.2) inset;
|
||||
}
|
||||
|
||||
/* 响应式容器边距 */
|
||||
@media (max-width: 768px) {
|
||||
.rounded-container {
|
||||
border-radius: 16px;
|
||||
/* 移动端时恢复全宽 */
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 隐藏移动端(<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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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 { 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.SITE_NAME || 'KatelyaTV';
|
||||
if (
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
|
||||
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 = {
|
||||
themeColor: '#000000',
|
||||
};
|
||||
|
||||
// 浮动几何形状组件
|
||||
const FloatingShapes = () => {
|
||||
return (
|
||||
<div className="floating-shapes">
|
||||
<div className="shape"></div>
|
||||
<div className="shape"></div>
|
||||
<div className="shape"></div>
|
||||
<div className="shape"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
let siteName = process.env.SITE_NAME || 'KatelyaTV';
|
||||
let announcement =
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。Link Me TG:@katelya77';
|
||||
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
||||
let imageProxy = process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
|
||||
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||
if (
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
|
||||
) {
|
||||
const config = await getConfig();
|
||||
siteName = config.SiteConfig.SiteName;
|
||||
announcement = config.SiteConfig.Announcement;
|
||||
enableRegister = config.UserConfig.AllowRegister;
|
||||
imageProxy = config.SiteConfig.ImageProxy;
|
||||
doubanProxy = config.SiteConfig.DoubanProxy;
|
||||
}
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
const runtimeConfig = {
|
||||
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
|
||||
ENABLE_REGISTER: enableRegister,
|
||||
IMAGE_PROXY: imageProxy,
|
||||
DOUBAN_PROXY: doubanProxy,
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang='zh-CN' suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 将配置序列化后直接写入脚本,浏览器端可通过 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`}
|
||||
>
|
||||
{/* 浮动几何形状装饰 */}
|
||||
<FloatingShapes />
|
||||
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SiteProvider siteName={siteName} announcement={announcement}>
|
||||
{children}
|
||||
</SiteProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/* 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/senshinya/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-purple-600 dark:text-purple-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'>
|
||||
{/* 渐变酷炫Logo */}
|
||||
<h1 className='relative tracking-tight text-center text-3xl font-extrabold mb-8 drop-shadow-lg'>
|
||||
<span className='bg-gradient-to-r from-purple-400 via-pink-500 to-purple-600 dark:from-purple-300 dark:via-pink-400 dark:to-purple-500 bg-clip-text text-transparent animate-pulse'>
|
||||
{siteName}
|
||||
</span>
|
||||
{/* 添加发光效果 */}
|
||||
<span className='absolute inset-0 bg-gradient-to-r from-purple-400 via-pink-500 to-purple-600 dark:from-purple-300 dark:via-pink-400 dark:to-purple-500 bg-clip-text text-transparent blur-sm opacity-50 animate-pulse'>
|
||||
{siteName}
|
||||
</span>
|
||||
</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-purple-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-purple-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-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 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-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-700 hover:to-purple-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 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-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-700 hover:to-purple-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/* 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';
|
||||
|
||||
// 客户端收藏 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';
|
||||
|
||||
// 主内容区大型 KatelyaTV Logo 组件
|
||||
const MainKatelyaLogo = () => {
|
||||
return (
|
||||
<div className="main-logo-container">
|
||||
{/* 背景光效 */}
|
||||
<div className="logo-background-glow"></div>
|
||||
|
||||
{/* 主 Logo */}
|
||||
<div className="main-katelya-logo">
|
||||
KatelyaTV
|
||||
</div>
|
||||
|
||||
{/* 副标题 */}
|
||||
<div className="mt-3 text-center">
|
||||
<div className="main-logo-subtitle">
|
||||
极致影视体验,尽在指尖
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 装饰性粒子效果 */}
|
||||
<div className="logo-particles">
|
||||
<div className="particle particle-1"></div>
|
||||
<div className="particle particle-2"></div>
|
||||
<div className="particle particle-3"></div>
|
||||
<div className="particle particle-4"></div>
|
||||
<div className="particle particle-5"></div>
|
||||
<div className="particle particle-6"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// KatelyaTV 底部 Logo 组件
|
||||
const BottomKatelyaLogo = () => {
|
||||
return (
|
||||
<div className="bottom-logo-container">
|
||||
{/* 浮动几何形状装饰 */}
|
||||
<div className="floating-shapes">
|
||||
<div className="shape"></div>
|
||||
<div className="shape"></div>
|
||||
<div className="shape"></div>
|
||||
<div className="shape"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="bottom-logo">
|
||||
KatelyaTV
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500 dark:text-gray-400 opacity-75">
|
||||
Powered by MoonTV Core
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 [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 fetchDoubanData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 并行获取热门电影、热门剧集和热门综艺
|
||||
const [moviesData, tvShowsData, varietyShowsData] = await Promise.all([
|
||||
getDoubanCategories({
|
||||
kind: 'movie',
|
||||
category: '热门',
|
||||
type: '全部',
|
||||
}),
|
||||
getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }),
|
||||
getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }),
|
||||
]);
|
||||
|
||||
if (moviesData.code === 200) {
|
||||
setHotMovies(moviesData.list);
|
||||
}
|
||||
|
||||
if (tvShowsData.code === 200) {
|
||||
setHotTvShows(tvShowsData.list);
|
||||
}
|
||||
|
||||
if (varietyShowsData.code === 200) {
|
||||
setHotVarietyShows(varietyShowsData.list);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取豆瓣数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDoubanData();
|
||||
}, []);
|
||||
|
||||
// 处理收藏数据更新的函数
|
||||
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-4 sm:px-8 lg:px-12 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 主内容区大型 KatelyaTV Logo - 仅在首页显示 */}
|
||||
{activeTab === 'home' && <MainKatelyaLogo />}
|
||||
|
||||
{/* 顶部 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='w-full max-w-none 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-purple-700 dark:text-gray-400 dark:hover:text-purple-300 transition-colors'
|
||||
onClick={async () => {
|
||||
await clearAllFavorites();
|
||||
setFavoriteItems([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 优化收藏夹网格布局,确保在新的居中布局下完美对齐 */}
|
||||
<div className='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-6 lg:gap-x-8 justify-items-center'>
|
||||
{favoriteItems.map((item) => (
|
||||
<div key={item.id + item.source} className='w-full max-w-44'>
|
||||
<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>
|
||||
|
||||
{/* 收藏夹页面底部 Logo */}
|
||||
<BottomKatelyaLogo />
|
||||
</>
|
||||
) : (
|
||||
// 首页视图
|
||||
<>
|
||||
{/* 继续观看 */}
|
||||
<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-purple-700 dark:text-gray-400 dark:hover:text-purple-300 transition-colors'
|
||||
>
|
||||
查看更多
|
||||
<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-purple-200 animate-pulse dark:bg-purple-800'>
|
||||
<div className='absolute inset-0 bg-purple-300 dark:bg-purple-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-purple-200 rounded animate-pulse dark:bg-purple-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={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-purple-700 dark:text-gray-400 dark:hover:text-purple-300 transition-colors'
|
||||
>
|
||||
查看更多
|
||||
<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-purple-200 animate-pulse dark:bg-purple-800'>
|
||||
<div className='absolute inset-0 bg-purple-300 dark:bg-purple-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-purple-200 rounded animate-pulse dark:bg-purple-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={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=show'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-purple-700 dark:text-gray-400 dark:hover:text-purple-300 transition-colors'
|
||||
>
|
||||
查看更多
|
||||
<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-purple-200 animate-pulse dark:bg-purple-800'>
|
||||
<div className='absolute inset-0 bg-purple-300 dark:bg-purple-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-purple-200 rounded animate-pulse dark:bg-purple-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={show.id}
|
||||
rate={show.rate}
|
||||
year={show.year}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 首页底部 Logo */}
|
||||
<BottomKatelyaLogo />
|
||||
</>
|
||||
)}
|
||||
</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-purple-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-purple-50 dark:bg-purple-900/20'>
|
||||
<div className='absolute inset-y-0 left-0 w-1.5 bg-purple-500 dark:bg-purple-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-purple-600 to-purple-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-purple-700 hover:to-purple-800 dark:from-purple-600 dark:to-purple-700 dark:hover:from-purple-700 dark:hover:to-purple-800 transition-all duration-300 transform hover:-translate-y-0.5'
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense>
|
||||
<HomeClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,1702 @@
|
||||
/* 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,
|
||||
generateStorageKey,
|
||||
getAllPlayRecords,
|
||||
isFavorited,
|
||||
saveFavorite,
|
||||
savePlayRecord,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 去广告开关(从 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('');
|
||||
// 当前源和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);
|
||||
|
||||
// 换源相关状态
|
||||
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);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 工具函数(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');
|
||||
}
|
||||
};
|
||||
|
||||
// 去广告相关函数
|
||||
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');
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
}, []);
|
||||
|
||||
// 处理换源
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
};
|
||||
|
||||
// 页面可见性变化时保存播放进度
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
saveCurrentPlayProgress();
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听器
|
||||
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) {
|
||||
if (artPlayerRef.current.video && artPlayerRef.current.video.hls) {
|
||||
artPlayerRef.current.video.hls.destroy();
|
||||
}
|
||||
// 销毁播放器实例
|
||||
artPlayerRef.current.destroy();
|
||||
artPlayerRef.current = null;
|
||||
}
|
||||
|
||||
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 ? '当前开启' : '当前关闭';
|
||||
},
|
||||
},
|
||||
],
|
||||
// 控制栏配置
|
||||
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);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('video:volumechange', () => {
|
||||
lastVolumeRef.current = artPlayerRef.current.volume;
|
||||
});
|
||||
|
||||
// 监听视频可播放事件,这时恢复播放进度更可靠
|
||||
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;
|
||||
}
|
||||
artPlayerRef.current.notice.show = '';
|
||||
}, 0);
|
||||
|
||||
// 隐藏换源加载状态
|
||||
setIsVideoLoading(false);
|
||||
});
|
||||
|
||||
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 === 'd1') {
|
||||
interval = 10000;
|
||||
}
|
||||
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]);
|
||||
|
||||
// 当组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveIntervalRef.current) {
|
||||
clearInterval(saveIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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}
|
||||
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='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'
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
/* 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 PageLayout from '@/components/PageLayout';
|
||||
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[]>([]);
|
||||
|
||||
// 获取默认聚合设置:只读取用户本地设置,默认为 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);
|
||||
|
||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||
addSearchHistory(query);
|
||||
} else {
|
||||
setShowResults(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();
|
||||
setSearchResults(
|
||||
data.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 handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) return;
|
||||
|
||||
// 回显搜索框
|
||||
setSearchQuery(trimmed);
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
// 直接发请求
|
||||
fetchSearchResults(trimmed);
|
||||
|
||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||
addSearchHistory(trimmed);
|
||||
};
|
||||
|
||||
// 返回顶部功能
|
||||
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={(e) => setSearchQuery(e.target.value)}
|
||||
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'
|
||||
/>
|
||||
</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?.toString()}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '安全警告 - MoonTV',
|
||||
description: '站点安全配置警告',
|
||||
};
|
||||
|
||||
export default function WarningPage() {
|
||||
return (
|
||||
<div className='min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4'>
|
||||
<div className='max-w-2xl w-full bg-white rounded-2xl shadow-2xl p-4 sm:p-8 border border-red-200'>
|
||||
{/* 警告图标 */}
|
||||
<div className='flex justify-center mb-4 sm:mb-6'>
|
||||
<div className='w-16 h-16 sm:w-20 sm:h-20 bg-red-100 rounded-full flex items-center justify-center'>
|
||||
<svg
|
||||
className='w-10 h-10 sm:w-12 sm:h-12 text-red-600'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className='text-center mb-6 sm:mb-8'>
|
||||
<h1 className='text-2xl sm:text-3xl font-bold text-gray-900 mb-2'>
|
||||
安全合规配置警告
|
||||
</h1>
|
||||
<div className='w-12 sm:w-16 h-1 bg-red-500 mx-auto rounded-full'></div>
|
||||
</div>
|
||||
|
||||
{/* 警告内容 */}
|
||||
<div className='space-y-4 sm:space-y-6 text-gray-700'>
|
||||
<div className='bg-red-50 border-l-4 border-red-500 p-3 sm:p-4 rounded-r-lg'>
|
||||
<p className='text-base sm:text-lg font-semibold text-red-800 mb-2'>
|
||||
⚠️ 安全风险提示
|
||||
</p>
|
||||
<p className='text-sm sm:text-base text-red-700'>
|
||||
检测到您的站点未配置访问控制,存在潜在的安全风险和法律合规问题。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<h2 className='text-lg sm:text-xl font-semibold text-gray-900'>
|
||||
主要风险
|
||||
</h2>
|
||||
<ul className='space-y-2 sm:space-y-3 text-sm sm:text-base text-gray-600'>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>未经授权的访问可能导致内容被恶意传播</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>服务器资源可能被滥用,影响正常服务</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>可能收到相关权利方的法律通知</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>服务提供商可能因合规问题终止服务</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3 sm:p-4'>
|
||||
<h3 className='text-base sm:text-lg font-semibold text-yellow-800 mb-2'>
|
||||
🔒 安全配置建议
|
||||
</h3>
|
||||
<p className='text-sm sm:text-base text-yellow-700'>
|
||||
请立即配置{' '}
|
||||
<code className='bg-yellow-100 px-1.5 py-0.5 rounded text-xs sm:text-sm font-mono'>
|
||||
PASSWORD
|
||||
</code>{' '}
|
||||
环境变量以启用访问控制。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部装饰 */}
|
||||
<div className='mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-gray-200'>
|
||||
<div className='text-center text-xs sm:text-sm text-gray-500'>
|
||||
<p>为确保系统安全性和合规性,请及时完成安全配置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user