mirror of https://github.com/djteang/OrangeTV.git
add:机器识别码设定开关;配置文件去重添加;视频源编辑;单个视频源进行有效性检测
update:聊天页面适配移动端 fixed:弹幕发送问题;播放页测速问题
This commit is contained in:
parent
156f2de526
commit
091ca9d2ff
15
Dockerfile
15
Dockerfile
|
|
@ -16,6 +16,21 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 先复制所有文件
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 然后检查文件
|
||||||
|
RUN echo "文件列表:" && ls -la && \
|
||||||
|
echo "检查 tsconfig.json:" && \
|
||||||
|
if [ -f "tsconfig.json" ]; then \
|
||||||
|
echo "tsconfig.json 存在"; \
|
||||||
|
else \
|
||||||
|
echo "tsconfig.json 不存在"; \
|
||||||
|
echo "查找所有文件:"; \
|
||||||
|
find . -type f -name "*tsconfig*"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# 安装所有依赖
|
# 安装所有依赖
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,7 @@ interface SiteConfig {
|
||||||
DoubanImageProxy: string;
|
DoubanImageProxy: string;
|
||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
|
RequireDeviceCode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频源数据类型
|
// 视频源数据类型
|
||||||
|
|
@ -2295,6 +2296,7 @@ const VideoSourceConfig = ({
|
||||||
const { isLoading, withLoading } = useLoadingState();
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
const [sources, setSources] = useState<DataSource[]>([]);
|
const [sources, setSources] = useState<DataSource[]>([]);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [editingSource, setEditingSource] = useState<DataSource | null>(null);
|
||||||
const [orderChanged, setOrderChanged] = useState(false);
|
const [orderChanged, setOrderChanged] = useState(false);
|
||||||
const [newSource, setNewSource] = useState<DataSource>({
|
const [newSource, setNewSource] = useState<DataSource>({
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -2340,6 +2342,32 @@ const VideoSourceConfig = ({
|
||||||
resultCount: number;
|
resultCount: number;
|
||||||
}>>([]);
|
}>>([]);
|
||||||
|
|
||||||
|
// 单个视频源验证状态
|
||||||
|
const [singleValidationResult, setSingleValidationResult] = useState<{
|
||||||
|
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
|
||||||
|
message: string;
|
||||||
|
details?: {
|
||||||
|
responseTime?: number;
|
||||||
|
resultCount?: number;
|
||||||
|
error?: string;
|
||||||
|
searchKeyword?: string;
|
||||||
|
};
|
||||||
|
}>({ status: null, message: '' });
|
||||||
|
const [isSingleValidating, setIsSingleValidating] = useState(false);
|
||||||
|
|
||||||
|
// 新增视频源验证状态
|
||||||
|
const [newSourceValidationResult, setNewSourceValidationResult] = useState<{
|
||||||
|
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
|
||||||
|
message: string;
|
||||||
|
details?: {
|
||||||
|
responseTime?: number;
|
||||||
|
resultCount?: number;
|
||||||
|
error?: string;
|
||||||
|
searchKeyword?: string;
|
||||||
|
};
|
||||||
|
}>({ status: null, message: '' });
|
||||||
|
const [isNewSourceValidating, setIsNewSourceValidating] = useState(false);
|
||||||
|
|
||||||
// dnd-kit 传感器
|
// dnd-kit 传感器
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
|
|
@ -2422,11 +2450,42 @@ const VideoSourceConfig = ({
|
||||||
from: 'custom',
|
from: 'custom',
|
||||||
});
|
});
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
|
// 清除检测结果
|
||||||
|
clearNewSourceValidation();
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
console.error('操作失败', 'add', newSource);
|
console.error('操作失败', 'add', newSource);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditSource = () => {
|
||||||
|
if (!editingSource || !editingSource.name || !editingSource.api) return;
|
||||||
|
withLoading('editSource', async () => {
|
||||||
|
await callSourceApi({
|
||||||
|
action: 'edit',
|
||||||
|
key: editingSource.key,
|
||||||
|
name: editingSource.name,
|
||||||
|
api: editingSource.api,
|
||||||
|
detail: editingSource.detail,
|
||||||
|
});
|
||||||
|
setEditingSource(null);
|
||||||
|
}).catch(() => {
|
||||||
|
console.error('操作失败', 'edit', editingSource);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingSource(null);
|
||||||
|
// 清除单个源的检测结果
|
||||||
|
setSingleValidationResult({ status: null, message: '' });
|
||||||
|
setIsSingleValidating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除新增视频源检测结果
|
||||||
|
const clearNewSourceValidation = () => {
|
||||||
|
setNewSourceValidationResult({ status: null, message: '' });
|
||||||
|
setIsNewSourceValidating(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: any) => {
|
const handleDragEnd = (event: any) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
@ -2544,6 +2603,149 @@ const VideoSourceConfig = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 通用视频源有效性检测函数
|
||||||
|
const handleValidateSource = async (
|
||||||
|
api: string,
|
||||||
|
name: string,
|
||||||
|
isNewSource: boolean = false
|
||||||
|
) => {
|
||||||
|
if (!api.trim()) {
|
||||||
|
showAlert({ type: 'warning', title: 'API地址不能为空', message: '请输入有效的API地址' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationKey = isNewSource ? 'validateNewSource' : 'validateSingleSource';
|
||||||
|
const setValidating = isNewSource ? setIsNewSourceValidating : setIsSingleValidating;
|
||||||
|
const setResult = isNewSource ? setNewSourceValidationResult : setSingleValidationResult;
|
||||||
|
|
||||||
|
await withLoading(validationKey, async () => {
|
||||||
|
setValidating(true);
|
||||||
|
setResult({ status: 'validating', message: '检测中...' });
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const testKeyword = '灵笼';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建检测 URL,使用临时 API 地址
|
||||||
|
const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(testKeyword)}&tempApi=${encodeURIComponent(api.trim())}&tempName=${encodeURIComponent(name)}`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'start':
|
||||||
|
console.log(`开始检测视频源: ${name}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'source_result':
|
||||||
|
case 'source_error':
|
||||||
|
if (data.source === 'temp') {
|
||||||
|
let message = '';
|
||||||
|
let details: any = {
|
||||||
|
responseTime,
|
||||||
|
searchKeyword: testKeyword
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.status === 'valid') {
|
||||||
|
message = '搜索正常';
|
||||||
|
details.resultCount = data.resultCount || 0;
|
||||||
|
} else if (data.status === 'no_results') {
|
||||||
|
message = '无法搜索到结果';
|
||||||
|
details.resultCount = 0;
|
||||||
|
} else {
|
||||||
|
message = '连接失败';
|
||||||
|
details.error = data.error || '未知错误';
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
status: data.status,
|
||||||
|
message,
|
||||||
|
details
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
console.log(`检测完成: ${name}`);
|
||||||
|
eventSource.close();
|
||||||
|
setValidating(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析EventSource数据失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('EventSource错误:', error);
|
||||||
|
eventSource.close();
|
||||||
|
setValidating(false);
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
setResult({
|
||||||
|
status: 'invalid',
|
||||||
|
message: '连接错误,请重试',
|
||||||
|
details: {
|
||||||
|
responseTime,
|
||||||
|
error: '网络连接失败',
|
||||||
|
searchKeyword: testKeyword
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置超时,防止长时间等待
|
||||||
|
setTimeout(() => {
|
||||||
|
if (eventSource.readyState === EventSource.OPEN) {
|
||||||
|
eventSource.close();
|
||||||
|
setValidating(false);
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
setResult({
|
||||||
|
status: 'invalid',
|
||||||
|
message: '检测超时,请重试',
|
||||||
|
details: {
|
||||||
|
responseTime,
|
||||||
|
error: '请求超时(30秒)',
|
||||||
|
searchKeyword: testKeyword
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 30000); // 30秒超时
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setValidating(false);
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
setResult({
|
||||||
|
status: 'invalid',
|
||||||
|
message: error instanceof Error ? error.message : '未知错误',
|
||||||
|
details: {
|
||||||
|
responseTime,
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
searchKeyword: testKeyword
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单个视频源有效性检测函数
|
||||||
|
const handleValidateSingleSource = async () => {
|
||||||
|
if (!editingSource) {
|
||||||
|
showAlert({ type: 'warning', title: '没有可检测的视频源', message: '请确保正在编辑视频源' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleValidateSource(editingSource.api, editingSource.name, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新增视频源有效性检测函数
|
||||||
|
const handleValidateNewSource = async () => {
|
||||||
|
if (!newSource.name.trim()) {
|
||||||
|
showAlert({ type: 'warning', title: '视频源名称不能为空', message: '请输入视频源名称' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleValidateSource(newSource.api, newSource.name, true);
|
||||||
|
};
|
||||||
|
|
||||||
// 获取有效性状态显示
|
// 获取有效性状态显示
|
||||||
const getValidationStatus = (sourceKey: string) => {
|
const getValidationStatus = (sourceKey: string) => {
|
||||||
const result = validationResults.find(r => r.key === sourceKey);
|
const result = validationResults.find(r => r.key === sourceKey);
|
||||||
|
|
@ -2671,15 +2873,27 @@ const VideoSourceConfig = ({
|
||||||
>
|
>
|
||||||
{!source.disabled ? '禁用' : '启用'}
|
{!source.disabled ? '禁用' : '启用'}
|
||||||
</button>
|
</button>
|
||||||
{source.from !== 'config' && (
|
<button
|
||||||
<button
|
onClick={() => {
|
||||||
onClick={() => handleDelete(source.key)}
|
setEditingSource(source);
|
||||||
disabled={isLoading(`deleteSource_${source.key}`)}
|
// 清除之前的检测结果
|
||||||
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
setSingleValidationResult({ status: null, message: '' });
|
||||||
>
|
setIsSingleValidating(false);
|
||||||
删除
|
}}
|
||||||
</button>
|
disabled={isLoading(`editSource_${source.key}`)}
|
||||||
)}
|
className={`${buttonStyles.roundedPrimary} ${isLoading(`editSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
title='编辑此视频源'
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(source.key)}
|
||||||
|
disabled={isLoading(`deleteSource_${source.key}`)}
|
||||||
|
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
title='删除此视频源'
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -2824,7 +3038,13 @@ const VideoSourceConfig = ({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => {
|
||||||
|
setShowAddForm(!showAddForm);
|
||||||
|
// 切换表单时清除检测结果
|
||||||
|
if (!showAddForm) {
|
||||||
|
clearNewSourceValidation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
|
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
|
||||||
>
|
>
|
||||||
{showAddForm ? '取消' : '添加视频源'}
|
{showAddForm ? '取消' : '添加视频源'}
|
||||||
|
|
@ -2873,11 +3093,62 @@ const VideoSourceConfig = ({
|
||||||
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'
|
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>
|
||||||
<div className='flex justify-end'>
|
|
||||||
|
{/* 新增视频源有效性检测结果显示 */}
|
||||||
|
{newSourceValidationResult.status && (
|
||||||
|
<div className='p-3 rounded-lg border'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>检测结果:</span>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${newSourceValidationResult.status === 'valid'
|
||||||
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
|
: newSourceValidationResult.status === 'validating'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
|
||||||
|
: newSourceValidationResult.status === 'no_results'
|
||||||
|
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
||||||
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{newSourceValidationResult.status === 'valid' && '✓ '}
|
||||||
|
{newSourceValidationResult.status === 'validating' && '⏳ '}
|
||||||
|
{newSourceValidationResult.status === 'no_results' && '⚠️ '}
|
||||||
|
{newSourceValidationResult.status === 'invalid' && '✗ '}
|
||||||
|
{newSourceValidationResult.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{newSourceValidationResult.details && (
|
||||||
|
<div className='text-xs text-gray-600 dark:text-gray-400 space-y-1'>
|
||||||
|
{newSourceValidationResult.details.searchKeyword && (
|
||||||
|
<div>测试关键词: {newSourceValidationResult.details.searchKeyword}</div>
|
||||||
|
)}
|
||||||
|
{newSourceValidationResult.details.responseTime && (
|
||||||
|
<div>响应时间: {newSourceValidationResult.details.responseTime}ms</div>
|
||||||
|
)}
|
||||||
|
{newSourceValidationResult.details.resultCount !== undefined && (
|
||||||
|
<div>搜索结果数: {newSourceValidationResult.details.resultCount}</div>
|
||||||
|
)}
|
||||||
|
{newSourceValidationResult.details.error && (
|
||||||
|
<div className='text-red-600 dark:text-red-400'>错误信息: {newSourceValidationResult.details.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex justify-end space-x-2'>
|
||||||
|
<button
|
||||||
|
onClick={handleValidateNewSource}
|
||||||
|
disabled={!newSource.api || isNewSourceValidating || isLoading('validateNewSource')}
|
||||||
|
className={`px-4 py-2 ${!newSource.api || isNewSourceValidating || isLoading('validateNewSource') ? buttonStyles.disabled : buttonStyles.primary}`}
|
||||||
|
>
|
||||||
|
{isNewSourceValidating || isLoading('validateNewSource') ? '检测中...' : '有效性检测'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddSource}
|
onClick={handleAddSource}
|
||||||
disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
|
disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
|
||||||
className={`w-full sm:w-auto px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
className={`px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
||||||
>
|
>
|
||||||
{isLoading('addSource') ? '添加中...' : '添加'}
|
{isLoading('addSource') ? '添加中...' : '添加'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2885,6 +3156,140 @@ const VideoSourceConfig = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 编辑视频源表单 */}
|
||||||
|
{editingSource && (
|
||||||
|
<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='flex items-center justify-between'>
|
||||||
|
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
编辑视频源: {editingSource.name}
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||||
|
名称
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={editingSource.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||||
|
Key (不可编辑)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={editingSource.key}
|
||||||
|
disabled
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||||
|
API 地址
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={editingSource.api}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSource((prev) => prev ? ({ ...prev, api: e.target.value }) : null)
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||||
|
Detail 地址(选填)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={editingSource.detail || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSource((prev) => prev ? ({ ...prev, detail: e.target.value }) : null)
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 有效性检测结果显示 */}
|
||||||
|
{singleValidationResult.status && (
|
||||||
|
<div className='col-span-full mt-4 p-3 rounded-lg border'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>检测结果:</span>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${singleValidationResult.status === 'valid'
|
||||||
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
|
: singleValidationResult.status === 'validating'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
|
||||||
|
: singleValidationResult.status === 'no_results'
|
||||||
|
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
||||||
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{singleValidationResult.status === 'valid' && '✓ '}
|
||||||
|
{singleValidationResult.status === 'validating' && '⏳ '}
|
||||||
|
{singleValidationResult.status === 'no_results' && '⚠️ '}
|
||||||
|
{singleValidationResult.status === 'invalid' && '✗ '}
|
||||||
|
{singleValidationResult.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{singleValidationResult.details && (
|
||||||
|
<div className='text-xs text-gray-600 dark:text-gray-400 space-y-1'>
|
||||||
|
{singleValidationResult.details.searchKeyword && (
|
||||||
|
<div>测试关键词: {singleValidationResult.details.searchKeyword}</div>
|
||||||
|
)}
|
||||||
|
{singleValidationResult.details.responseTime && (
|
||||||
|
<div>响应时间: {singleValidationResult.details.responseTime}ms</div>
|
||||||
|
)}
|
||||||
|
{singleValidationResult.details.resultCount !== undefined && (
|
||||||
|
<div>搜索结果数: {singleValidationResult.details.resultCount}</div>
|
||||||
|
)}
|
||||||
|
{singleValidationResult.details.error && (
|
||||||
|
<div className='text-red-600 dark:text-red-400'>错误信息: {singleValidationResult.details.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end space-x-2'>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className={buttonStyles.secondary}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleValidateSingleSource}
|
||||||
|
disabled={!editingSource.api || isSingleValidating || isLoading('validateSingleSource')}
|
||||||
|
className={`${!editingSource.api || isSingleValidating || isLoading('validateSingleSource') ? buttonStyles.disabled : buttonStyles.primary}`}
|
||||||
|
>
|
||||||
|
{isSingleValidating || isLoading('validateSingleSource') ? '检测中...' : '有效性检测'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEditSource}
|
||||||
|
disabled={!editingSource.name || !editingSource.api || isLoading('editSource')}
|
||||||
|
className={`${!editingSource.name || !editingSource.api || isLoading('editSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
||||||
|
>
|
||||||
|
{isLoading('editSource') ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 视频源表格 */}
|
{/* 视频源表格 */}
|
||||||
|
|
@ -3657,6 +4062,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||||
DoubanImageProxy: '',
|
DoubanImageProxy: '',
|
||||||
DisableYellowFilter: false,
|
DisableYellowFilter: false,
|
||||||
FluidSearch: true,
|
FluidSearch: true,
|
||||||
|
RequireDeviceCode: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 豆瓣数据源相关状态
|
// 豆瓣数据源相关状态
|
||||||
|
|
@ -3719,6 +4125,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||||
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
||||||
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
||||||
FluidSearch: config.SiteConfig.FluidSearch || true,
|
FluidSearch: config.SiteConfig.FluidSearch || true,
|
||||||
|
RequireDeviceCode: config.SiteConfig.RequireDeviceCode !== undefined ? config.SiteConfig.RequireDeviceCode : true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
@ -4103,6 +4510,40 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 启用设备码验证 */}
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
|
>
|
||||||
|
启用设备码验证
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
RequireDeviceCode: !prev.RequireDeviceCode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${siteSettings.RequireDeviceCode
|
||||||
|
? buttonStyles.toggleOn
|
||||||
|
: buttonStyles.toggleOff
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.RequireDeviceCode
|
||||||
|
? buttonStyles.toggleThumbOn
|
||||||
|
: buttonStyles.toggleThumbOff
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
启用后用户登录时需要绑定设备码,提升账户安全性。禁用后用户可以直接登录而无需绑定设备码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 禁用黄色过滤器 */}
|
{/* 禁用黄色过滤器 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export async function POST(request: NextRequest) {
|
||||||
DoubanImageProxy,
|
DoubanImageProxy,
|
||||||
DisableYellowFilter,
|
DisableYellowFilter,
|
||||||
FluidSearch,
|
FluidSearch,
|
||||||
|
RequireDeviceCode,
|
||||||
} = body as {
|
} = body as {
|
||||||
SiteName: string;
|
SiteName: string;
|
||||||
Announcement: string;
|
Announcement: string;
|
||||||
|
|
@ -50,6 +51,7 @@ export async function POST(request: NextRequest) {
|
||||||
DoubanImageProxy: string;
|
DoubanImageProxy: string;
|
||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
|
RequireDeviceCode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 参数校验
|
// 参数校验
|
||||||
|
|
@ -63,7 +65,8 @@ export async function POST(request: NextRequest) {
|
||||||
typeof DoubanImageProxyType !== 'string' ||
|
typeof DoubanImageProxyType !== 'string' ||
|
||||||
typeof DoubanImageProxy !== 'string' ||
|
typeof DoubanImageProxy !== 'string' ||
|
||||||
typeof DisableYellowFilter !== 'boolean' ||
|
typeof DisableYellowFilter !== 'boolean' ||
|
||||||
typeof FluidSearch !== 'boolean'
|
typeof FluidSearch !== 'boolean' ||
|
||||||
|
typeof RequireDeviceCode !== 'boolean'
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +96,7 @@ export async function POST(request: NextRequest) {
|
||||||
DoubanImageProxy,
|
DoubanImageProxy,
|
||||||
DisableYellowFilter,
|
DisableYellowFilter,
|
||||||
FluidSearch,
|
FluidSearch,
|
||||||
|
RequireDeviceCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { db } from '@/lib/db';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
// 支持的操作类型
|
// 支持的操作类型
|
||||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'edit' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
||||||
|
|
||||||
interface BaseBody {
|
interface BaseBody {
|
||||||
action?: Action;
|
action?: Action;
|
||||||
|
|
@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
// 基础校验
|
// 基础校验
|
||||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'edit', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
||||||
if (!username || !action || !ACTIONS.includes(action)) {
|
if (!username || !action || !ACTIONS.includes(action)) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +99,26 @@ export async function POST(request: NextRequest) {
|
||||||
entry.disabled = false;
|
entry.disabled = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'edit': {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
|
if (!entry) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
// 更新字段(除了 key 和 from)
|
||||||
|
entry.name = name;
|
||||||
|
entry.api = api;
|
||||||
|
entry.detail = detail || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
const { key } = body as { key?: string };
|
const { key } = body as { key?: string };
|
||||||
if (!key)
|
if (!key)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const searchKeyword = searchParams.get('q');
|
const searchKeyword = searchParams.get('q');
|
||||||
|
const sourceKey = searchParams.get('source'); // 支持单个源验证
|
||||||
|
const tempApi = searchParams.get('tempApi'); // 临时 API 地址
|
||||||
|
const tempName = searchParams.get('tempName'); // 临时源名称
|
||||||
|
|
||||||
if (!searchKeyword) {
|
if (!searchKeyword) {
|
||||||
return new Response(
|
return new Response(
|
||||||
|
|
@ -30,7 +33,34 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const apiSites = config.SourceConfig;
|
let apiSites = config.SourceConfig;
|
||||||
|
|
||||||
|
// 如果提供了临时 API 地址,创建临时源进行验证
|
||||||
|
if (tempApi && tempName) {
|
||||||
|
apiSites = [{
|
||||||
|
key: 'temp',
|
||||||
|
name: tempName,
|
||||||
|
api: tempApi,
|
||||||
|
detail: '',
|
||||||
|
disabled: false,
|
||||||
|
from: 'custom' as const
|
||||||
|
}];
|
||||||
|
} else if (sourceKey) {
|
||||||
|
// 如果指定了特定源,只验证该源
|
||||||
|
const targetSite = apiSites.find(site => site.key === sourceKey);
|
||||||
|
if (!targetSite) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: '指定的视频源不存在' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
apiSites = [targetSite];
|
||||||
|
}
|
||||||
|
|
||||||
// 共享状态
|
// 共享状态
|
||||||
let streamClosed = false;
|
let streamClosed = false;
|
||||||
|
|
@ -94,6 +124,7 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
// 检查结果是否有效
|
// 检查结果是否有效
|
||||||
let status: 'valid' | 'no_results' | 'invalid';
|
let status: 'valid' | 'no_results' | 'invalid';
|
||||||
|
let resultCount = 0;
|
||||||
if (
|
if (
|
||||||
data &&
|
data &&
|
||||||
data.list &&
|
data.list &&
|
||||||
|
|
@ -108,11 +139,14 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
if (validResults.length > 0) {
|
if (validResults.length > 0) {
|
||||||
status = 'valid';
|
status = 'valid';
|
||||||
|
resultCount = validResults.length;
|
||||||
} else {
|
} else {
|
||||||
status = 'no_results';
|
status = 'no_results';
|
||||||
|
resultCount = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
status = 'no_results';
|
status = 'no_results';
|
||||||
|
resultCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送该源的验证结果
|
// 发送该源的验证结果
|
||||||
|
|
@ -122,7 +156,8 @@ export async function GET(request: NextRequest) {
|
||||||
const sourceEvent = `data: ${JSON.stringify({
|
const sourceEvent = `data: ${JSON.stringify({
|
||||||
type: 'source_result',
|
type: 'source_result',
|
||||||
source: site.key,
|
source: site.key,
|
||||||
status
|
status,
|
||||||
|
resultCount
|
||||||
})}\n\n`;
|
})}\n\n`;
|
||||||
|
|
||||||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||||
|
|
@ -145,7 +180,9 @@ export async function GET(request: NextRequest) {
|
||||||
const errorEvent = `data: ${JSON.stringify({
|
const errorEvent = `data: ${JSON.stringify({
|
||||||
type: 'source_error',
|
type: 'source_error',
|
||||||
source: site.key,
|
source: site.key,
|
||||||
status: 'invalid'
|
status: 'invalid',
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
resultCount: 0
|
||||||
})}\n\n`;
|
})}\n\n`;
|
||||||
|
|
||||||
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||||||
|
|
|
||||||
|
|
@ -1,248 +1,98 @@
|
||||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
function buildCorsHeaders(contentType?: string, extra?: Record<string, string>) {
|
||||||
const { searchParams } = new URL(request.url);
|
const headers = new Headers();
|
||||||
const url = searchParams.get('url');
|
if (contentType) headers.set('Content-Type', contentType);
|
||||||
|
headers.set('Access-Control-Allow-Origin', '*');
|
||||||
if (!url) {
|
headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||||
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
|
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||||
}
|
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges, Content-Type');
|
||||||
|
headers.set('Cache-Control', 'no-cache');
|
||||||
console.log('Proxy video request for URL:', url);
|
if (extra) {
|
||||||
|
Object.entries(extra).forEach(([k, v]) => headers.set(k, v));
|
||||||
let response: Response | null = null;
|
|
||||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decodedUrl = decodeURIComponent(url);
|
|
||||||
console.log('Decoded URL:', decodedUrl);
|
|
||||||
|
|
||||||
// 为短剧视频文件设置合适的请求头,避免403错误
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
|
||||||
'Accept': '*/*',
|
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
||||||
'Accept-Encoding': 'identity',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Pragma': 'no-cache',
|
|
||||||
'Sec-Fetch-Dest': 'video',
|
|
||||||
'Sec-Fetch-Mode': 'no-cors',
|
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 对于夸克网盘等,设置更精确的请求头
|
|
||||||
if (decodedUrl.includes('quark.cn') || decodedUrl.includes('drive.quark.cn')) {
|
|
||||||
headers['Referer'] = 'https://pan.quark.cn/';
|
|
||||||
headers['Origin'] = 'https://pan.quark.cn';
|
|
||||||
// 移除可能导致问题的header
|
|
||||||
delete headers['Sec-Fetch-Dest'];
|
|
||||||
delete headers['Sec-Fetch-Mode'];
|
|
||||||
delete headers['Sec-Fetch-Site'];
|
|
||||||
} else if (decodedUrl.includes('dl-c-')) {
|
|
||||||
// 对于CDN链接,使用更简单的请求头
|
|
||||||
headers['Referer'] = '';
|
|
||||||
delete headers['Origin'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理Range请求,支持视频拖拽播放
|
|
||||||
const rangeHeader = request.headers.get('Range');
|
|
||||||
if (rangeHeader) {
|
|
||||||
headers['Range'] = rangeHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await fetch(decodedUrl, {
|
|
||||||
headers,
|
|
||||||
// 添加超时设置
|
|
||||||
signal: AbortSignal.timeout(30000), // 30秒超时
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(`Failed to fetch video: ${response.status} ${response.statusText}`);
|
|
||||||
console.error('Request headers were:', JSON.stringify(headers, null, 2));
|
|
||||||
|
|
||||||
// 返回具有正确CORS头的错误响应
|
|
||||||
const errorHeaders = new Headers();
|
|
||||||
errorHeaders.set('Access-Control-Allow-Origin', '*');
|
|
||||||
errorHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
||||||
errorHeaders.set('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Origin, Authorization');
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
error: `Failed to fetch video: ${response.status}`,
|
|
||||||
details: response.statusText,
|
|
||||||
url: decodedUrl
|
|
||||||
}, {
|
|
||||||
status: response.status >= 400 ? response.status : 500,
|
|
||||||
headers: errorHeaders
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Successfully fetched video: ${response.status} ${response.statusText}`);
|
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
|
|
||||||
// 设置内容类型
|
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
if (contentType) {
|
|
||||||
responseHeaders.set('Content-Type', contentType);
|
|
||||||
} else {
|
|
||||||
// 默认为MP4
|
|
||||||
responseHeaders.set('Content-Type', 'video/mp4');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 完整的CORS头设置
|
|
||||||
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Origin, Authorization, X-Requested-With');
|
|
||||||
responseHeaders.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges, Content-Type');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Credentials', 'false');
|
|
||||||
|
|
||||||
// 支持Range请求
|
|
||||||
responseHeaders.set('Accept-Ranges', 'bytes');
|
|
||||||
|
|
||||||
// 传递内容长度和Range响应头
|
|
||||||
const contentLength = response.headers.get('content-length');
|
|
||||||
if (contentLength) {
|
|
||||||
responseHeaders.set('Content-Length', contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentRange = response.headers.get('content-range');
|
|
||||||
if (contentRange) {
|
|
||||||
responseHeaders.set('Content-Range', contentRange);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缓存控制
|
|
||||||
responseHeaders.set('Cache-Control', 'public, max-age=3600, must-revalidate');
|
|
||||||
|
|
||||||
// 使用流式传输,支持大文件播放
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
if (!response?.body) {
|
|
||||||
controller.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reader = response.body.getReader();
|
|
||||||
let isCancelled = false;
|
|
||||||
|
|
||||||
function pump() {
|
|
||||||
if (isCancelled || !reader) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.read().then(({ done, value }) => {
|
|
||||||
if (isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (done) {
|
|
||||||
controller.close();
|
|
||||||
cleanup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
controller.enqueue(value);
|
|
||||||
pump();
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
console.error('Stream error:', error);
|
|
||||||
controller.error(error);
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
console.error('Reader error:', error);
|
|
||||||
controller.error(error);
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
isCancelled = true;
|
|
||||||
if (reader && reader.releaseLock) {
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch (e) {
|
|
||||||
// reader 可能已经被释放,忽略错误
|
|
||||||
}
|
|
||||||
reader = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pump();
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
// 当流被取消时,确保释放所有资源
|
|
||||||
if (reader && reader.releaseLock) {
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch (e) {
|
|
||||||
// reader 可能已经被释放,忽略错误
|
|
||||||
}
|
|
||||||
reader = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response?.body) {
|
|
||||||
try {
|
|
||||||
response.body.cancel();
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略取消时的错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
status: response.status,
|
|
||||||
headers: responseHeaders
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Proxy video error:', error);
|
|
||||||
|
|
||||||
// 确保在错误情况下也释放资源
|
|
||||||
if (reader && typeof (reader as any)?.releaseLock === 'function') {
|
|
||||||
try {
|
|
||||||
(reader as ReadableStreamDefaultReader<Uint8Array>).releaseLock();
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response?.body) {
|
|
||||||
try {
|
|
||||||
response.body.cancel();
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
error: 'Failed to proxy video',
|
|
||||||
details: error instanceof Error ? error.message : String(error)
|
|
||||||
}, { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持OPTIONS请求用于CORS预检
|
async function forwardRequest(url: string, method: 'GET' | 'HEAD', reqHeaders: Headers) {
|
||||||
export async function OPTIONS(_request: Request) {
|
const decodedUrl = decodeURIComponent(url);
|
||||||
console.log('CORS preflight request received');
|
|
||||||
|
|
||||||
return new Response(null, {
|
// 透传范围请求和必要请求头
|
||||||
status: 200,
|
const fetchHeaders: Record<string, string> = {};
|
||||||
headers: {
|
const range = reqHeaders.get('Range');
|
||||||
'Access-Control-Allow-Origin': '*',
|
if (range) fetchHeaders['Range'] = range;
|
||||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS, POST',
|
const accept = reqHeaders.get('Accept');
|
||||||
'Access-Control-Allow-Headers': 'Range, Content-Type, Accept, Origin, Authorization, X-Requested-With',
|
if (accept) fetchHeaders['Accept'] = accept;
|
||||||
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges, Content-Type',
|
|
||||||
'Access-Control-Allow-Credentials': 'false',
|
// 统一 UA,部分源(如 quark drive)需要浏览器 UA 才能返回
|
||||||
'Access-Control-Max-Age': '86400',
|
fetchHeaders['User-Agent'] =
|
||||||
},
|
reqHeaders.get('User-Agent') ||
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
|
||||||
|
|
||||||
|
const upstream = await fetch(decodedUrl, {
|
||||||
|
method,
|
||||||
|
headers: fetchHeaders,
|
||||||
|
redirect: 'follow',
|
||||||
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return upstream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function HEAD(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||||
|
|
||||||
|
const upstream = await forwardRequest(url, 'HEAD', request.headers);
|
||||||
|
const headers = buildCorsHeaders(upstream.headers.get('Content-Type') || undefined, {
|
||||||
|
'Accept-Ranges': upstream.headers.get('Accept-Ranges') || 'bytes',
|
||||||
|
'Content-Length': upstream.headers.get('Content-Length') || '',
|
||||||
|
'Content-Range': upstream.headers.get('Content-Range') || '',
|
||||||
|
});
|
||||||
|
const status = upstream.status === 206 ? 206 : upstream.status;
|
||||||
|
return new Response(null, { status, headers });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: 'Proxy HEAD failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||||
|
|
||||||
|
const upstream = await forwardRequest(url, 'GET', request.headers);
|
||||||
|
if (!upstream.ok && upstream.status !== 206) {
|
||||||
|
return NextResponse.json({ error: `Upstream error ${upstream.status}` }, { status: upstream.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream';
|
||||||
|
const extra: Record<string, string> = {
|
||||||
|
'Accept-Ranges': upstream.headers.get('Accept-Ranges') || 'bytes',
|
||||||
|
};
|
||||||
|
const contentLength = upstream.headers.get('Content-Length');
|
||||||
|
if (contentLength) extra['Content-Length'] = contentLength;
|
||||||
|
const contentRange = upstream.headers.get('Content-Range');
|
||||||
|
if (contentRange) extra['Content-Range'] = contentRange;
|
||||||
|
|
||||||
|
const headers = buildCorsHeaders(contentType, extra);
|
||||||
|
const status = upstream.status === 206 ? 206 : 200;
|
||||||
|
return new Response(upstream.body, { status, headers });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Proxy video failed:', e);
|
||||||
|
return NextResponse.json({ error: 'Proxy failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers: buildCorsHeaders() });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export default async function RootLayout({
|
||||||
let disableYellowFilter =
|
let disableYellowFilter =
|
||||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||||
|
let requireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false';
|
||||||
let customCategories = [] as {
|
let customCategories = [] as {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv';
|
||||||
|
|
@ -78,6 +79,7 @@ export default async function RootLayout({
|
||||||
query: category.query,
|
query: category.query,
|
||||||
}));
|
}));
|
||||||
fluidSearch = config.SiteConfig.FluidSearch;
|
fluidSearch = config.SiteConfig.FluidSearch;
|
||||||
|
requireDeviceCode = config.SiteConfig.RequireDeviceCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||||
|
|
@ -90,6 +92,7 @@ export default async function RootLayout({
|
||||||
DISABLE_YELLOW_FILTER: disableYellowFilter,
|
DISABLE_YELLOW_FILTER: disableYellowFilter,
|
||||||
CUSTOM_CATEGORIES: customCategories,
|
CUSTOM_CATEGORIES: customCategories,
|
||||||
FLUID_SEARCH: fluidSearch,
|
FLUID_SEARCH: fluidSearch,
|
||||||
|
REQUIRE_DEVICE_CODE: requireDeviceCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -85,18 +85,23 @@ function LoginPageClient() {
|
||||||
const [machineCodeGenerated, setMachineCodeGenerated] = useState(false);
|
const [machineCodeGenerated, setMachineCodeGenerated] = useState(false);
|
||||||
const [, setShowBindOption] = useState(false);
|
const [, setShowBindOption] = useState(false);
|
||||||
const [bindMachineCode, setBindMachineCode] = useState(false);
|
const [bindMachineCode, setBindMachineCode] = useState(false);
|
||||||
|
const [deviceCodeEnabled, setDeviceCodeEnabled] = useState(true); // 站点是否启用设备码功能
|
||||||
|
|
||||||
const { siteName } = useSite();
|
const { siteName } = useSite();
|
||||||
|
|
||||||
// 在客户端挂载后设置配置并生成机器码
|
// 在客户端挂载后设置配置并生成机器码
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
|
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||||
setShouldAskUsername(storageType && storageType !== 'localstorage');
|
const storageType = runtimeConfig?.STORAGE_TYPE;
|
||||||
|
const requireDeviceCode = runtimeConfig?.REQUIRE_DEVICE_CODE;
|
||||||
|
|
||||||
// 生成机器码和设备信息
|
setShouldAskUsername(storageType && storageType !== 'localstorage');
|
||||||
|
setDeviceCodeEnabled(requireDeviceCode !== false); // 默认启用,除非明确设置为 false
|
||||||
|
|
||||||
|
// 只有在启用设备码功能时才生成机器码和设备信息
|
||||||
const generateMachineInfo = async () => {
|
const generateMachineInfo = async () => {
|
||||||
if (MachineCode.isSupported()) {
|
if (requireDeviceCode !== false && MachineCode.isSupported()) {
|
||||||
try {
|
try {
|
||||||
const code = await MachineCode.generateMachineCode();
|
const code = await MachineCode.generateMachineCode();
|
||||||
const info = await MachineCode.getDeviceInfo();
|
const info = await MachineCode.getDeviceInfo();
|
||||||
|
|
@ -128,8 +133,8 @@ function LoginPageClient() {
|
||||||
...(shouldAskUsername ? { username } : {}),
|
...(shouldAskUsername ? { username } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果需要机器码或用户选择绑定,则发送机器码
|
// 只有在启用设备码功能时才处理机器码逻辑
|
||||||
if ((requireMachineCode || bindMachineCode) && machineCode) {
|
if (deviceCodeEnabled && (requireMachineCode || bindMachineCode) && machineCode) {
|
||||||
requestData.machineCode = machineCode;
|
requestData.machineCode = machineCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,8 +147,8 @@ function LoginPageClient() {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// 登录成功,如果用户选择绑定机器码,则绑定
|
// 登录成功,如果启用设备码功能且用户选择绑定机器码,则绑定
|
||||||
if (bindMachineCode && machineCode && shouldAskUsername) {
|
if (deviceCodeEnabled && bindMachineCode && machineCode && shouldAskUsername) {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/machine-code', {
|
await fetch('/api/machine-code', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -242,8 +247,8 @@ function LoginPageClient() {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 机器码信息显示 */}
|
{/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
|
||||||
{machineCodeGenerated && shouldAskUsername && (
|
{deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
|
|
@ -294,7 +299,7 @@ function LoginPageClient() {
|
||||||
!password ||
|
!password ||
|
||||||
loading ||
|
loading ||
|
||||||
(shouldAskUsername && !username) ||
|
(shouldAskUsername && !username) ||
|
||||||
(machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode)
|
(deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode)
|
||||||
}
|
}
|
||||||
className='inline-flex w-full justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-blue-600 hover:to-blue-700 disabled:cursor-not-allowed disabled:opacity-50'
|
className='inline-flex w-full justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-blue-600 hover:to-blue-700 disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1969,40 +1969,99 @@ function PlayPageClient() {
|
||||||
plugins: danmuEnabled ? [
|
plugins: danmuEnabled ? [
|
||||||
artplayerPluginDanmuku({
|
artplayerPluginDanmuku({
|
||||||
danmuku: async () => {
|
danmuku: async () => {
|
||||||
return await loadDanmuData(currentVideoId);
|
try {
|
||||||
|
const danmuData = await loadDanmuData(currentVideoId);
|
||||||
|
return danmuData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载弹幕失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
speed: 5, // 弹幕速度
|
speed: 5,
|
||||||
opacity: 1, // 透明度
|
opacity: 1,
|
||||||
fontSize: 25, // 字体大小
|
fontSize: 25,
|
||||||
color: '#FFFFFF', // 默认颜色
|
color: '#FFFFFF',
|
||||||
mode: 0, // 弹幕模式
|
mode: 0,
|
||||||
margin: [10, '25%'], // 边距
|
margin: [10, '25%'],
|
||||||
antiOverlap: true, // 防重叠
|
antiOverlap: true,
|
||||||
useWorker: true, // 使用 WebWorker
|
useWorker: true,
|
||||||
synchronousPlayback: false, // 非同步播放
|
synchronousPlayback: false,
|
||||||
filter: (danmu: any) => danmu.text.length < 50, // 过滤长弹幕
|
filter: (danmu: any) => danmu.text.length < 50,
|
||||||
lockTime: 5, // 锁定时间
|
lockTime: 5,
|
||||||
maxLength: 100, // 最大长度
|
maxLength: 100,
|
||||||
minWidth: 200, // 最小宽度
|
minWidth: 200,
|
||||||
maxWidth: 500, // 最大宽度
|
maxWidth: 500,
|
||||||
theme: 'dark', // 主题
|
theme: 'dark',
|
||||||
|
// 核心配置:启用弹幕发送功能
|
||||||
|
panel: true, // 启用弹幕输入面板
|
||||||
|
emit: true, // 启用弹幕发送
|
||||||
|
placeholder: '发个弹幕呗~',
|
||||||
|
maxlength: 50,
|
||||||
beforeVisible: (danmu: any) => {
|
beforeVisible: (danmu: any) => {
|
||||||
// 可在此处添加额外的过滤逻辑
|
|
||||||
return !danmu.text.includes('广告');
|
return !danmu.text.includes('广告');
|
||||||
},
|
},
|
||||||
beforeEmit: async (danmu: any) => {
|
beforeEmit: async (danmu: any) => {
|
||||||
// 发送弹幕前的处理
|
|
||||||
try {
|
try {
|
||||||
await sendDanmu(currentVideoId, {
|
const result = await sendDanmu(currentVideoId, {
|
||||||
text: danmu.text,
|
text: danmu.text,
|
||||||
color: danmu.color || '#FFFFFF',
|
color: danmu.color || '#FFFFFF',
|
||||||
mode: danmu.mode || 0,
|
mode: danmu.mode || 0,
|
||||||
time: artPlayerRef.current?.currentTime || 0
|
time: artPlayerRef.current?.currentTime || 0
|
||||||
});
|
});
|
||||||
return danmu;
|
|
||||||
|
// 显示成功提示
|
||||||
|
if (artPlayerRef.current?.notice) {
|
||||||
|
artPlayerRef.current.notice.show = '✅ 弹幕发送成功!';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建符合插件要求的弹幕对象
|
||||||
|
const danmuObject = {
|
||||||
|
text: danmu.text,
|
||||||
|
color: danmu.color || '#FFFFFF',
|
||||||
|
mode: danmu.mode || 0,
|
||||||
|
time: (artPlayerRef.current?.currentTime || 0) + 0.5,
|
||||||
|
border: false,
|
||||||
|
size: 25
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动触发弹幕显示(如果beforeEmit的返回值不能正常显示)
|
||||||
|
// 这是一个备用方案
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const danmakuPlugin = artPlayerRef.current?.plugins?.artplayerPluginDanmuku;
|
||||||
|
if (danmakuPlugin) {
|
||||||
|
// 确保弹幕未被隐藏
|
||||||
|
try {
|
||||||
|
if (danmakuPlugin.isHide && danmakuPlugin.show) {
|
||||||
|
danmakuPlugin.show();
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
// 尝试不同的方法来添加弹幕
|
||||||
|
if (danmakuPlugin.emit) {
|
||||||
|
danmakuPlugin.emit(danmuObject);
|
||||||
|
} else if (danmakuPlugin.add) {
|
||||||
|
danmakuPlugin.add(danmuObject);
|
||||||
|
} else if (danmakuPlugin.send) {
|
||||||
|
danmakuPlugin.send(danmuObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ 手动添加弹幕失败:', err);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// 返回弹幕对象让插件自动处理
|
||||||
|
return danmuObject;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送弹幕失败:', error);
|
console.error('发送弹幕失败:', error);
|
||||||
artPlayerRef.current?.notice?.show?.('发送弹幕失败:' + (error as any).message);
|
|
||||||
|
// 显示错误提示
|
||||||
|
if (artPlayerRef.current?.notice) {
|
||||||
|
artPlayerRef.current.notice.show = '❌ 发送弹幕失败:' + (error as any).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阻止弹幕显示
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2061,6 +2120,7 @@ function PlayPageClient() {
|
||||||
return newVal ? '弹幕已开启' : '弹幕已关闭';
|
return newVal ? '弹幕已开启' : '弹幕已关闭';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: '跳过片头片尾',
|
name: '跳过片头片尾',
|
||||||
html: '跳过片头片尾',
|
html: '跳过片头片尾',
|
||||||
|
|
@ -2159,6 +2219,11 @@ function PlayPageClient() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查弹幕插件是否正确加载
|
||||||
|
if (danmuEnabled) {
|
||||||
|
// 弹幕启用,无需调试日志
|
||||||
|
}
|
||||||
|
|
||||||
// 播放器就绪后,如果正在播放则请求 Wake Lock
|
// 播放器就绪后,如果正在播放则请求 Wake Lock
|
||||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||||
requestWakeLock();
|
requestWakeLock();
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export function ChatModal({
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||||
const [dragStartPosition, setDragStartPosition] = useState({ x: 0, y: 0 });
|
const [dragStartPosition, setDragStartPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -58,19 +59,55 @@ export function ChatModal({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStartPosition({
|
||||||
|
x: touch.clientX - dragPosition.x,
|
||||||
|
y: touch.clientY - dragPosition.y
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
const newX = e.clientX - dragStartPosition.x;
|
const newX = e.clientX - dragStartPosition.x;
|
||||||
const newY = e.clientY - dragStartPosition.y;
|
const newY = e.clientY - dragStartPosition.y;
|
||||||
|
|
||||||
// 限制拖动范围,确保模态框不会完全移出视口
|
// 允许在全屏范围内拖动,保留边距避免完全移出
|
||||||
const maxX = window.innerWidth - 400; // 模态框最小宽度
|
const edgePadding = 40;
|
||||||
const maxY = window.innerHeight - 200; // 模态框最小高度
|
const maxX = window.innerWidth - edgePadding;
|
||||||
|
const minX = - (window.innerWidth - edgePadding);
|
||||||
|
const maxY = window.innerHeight - edgePadding;
|
||||||
|
const minY = - (window.innerHeight - edgePadding);
|
||||||
|
|
||||||
setDragPosition({
|
setDragPosition({
|
||||||
x: Math.max(-200, Math.min(maxX, newX)),
|
x: Math.max(minX, Math.min(maxX, newX)),
|
||||||
y: Math.max(0, Math.min(maxY, newY))
|
y: Math.max(minY, Math.min(maxY, newY))
|
||||||
|
});
|
||||||
|
}, [isDragging, dragStartPosition]);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e: TouchEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const newX = touch.clientX - dragStartPosition.x;
|
||||||
|
const newY = touch.clientY - dragStartPosition.y;
|
||||||
|
|
||||||
|
const edgePadding = 40;
|
||||||
|
const maxX = window.innerWidth - edgePadding;
|
||||||
|
const minX = - (window.innerWidth - edgePadding);
|
||||||
|
const maxY = window.innerHeight - edgePadding;
|
||||||
|
const minY = - (window.innerHeight - edgePadding);
|
||||||
|
|
||||||
|
// 阻止页面滚动
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setDragPosition({
|
||||||
|
x: Math.max(minX, Math.min(maxX, newX)),
|
||||||
|
y: Math.max(minY, Math.min(maxY, newY))
|
||||||
});
|
});
|
||||||
}, [isDragging, dragStartPosition]);
|
}, [isDragging, dragStartPosition]);
|
||||||
|
|
||||||
|
|
@ -78,11 +115,31 @@ export function ChatModal({
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 添加全局鼠标事件监听
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 检测屏幕大小
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkMobile);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 添加全局鼠标/触摸事件监听
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd);
|
||||||
document.body.style.cursor = 'grabbing';
|
document.body.style.cursor = 'grabbing';
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -90,10 +147,12 @@ export function ChatModal({
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove as any);
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd as any);
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
};
|
};
|
||||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
||||||
|
|
||||||
// 实时搜索功能
|
// 实时搜索功能
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -772,18 +831,38 @@ export function ChatModal({
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center bg-black bg-opacity-50" style={{ zIndex: '99999' }}>
|
<div
|
||||||
|
className={`z-[2147483647] ${isMobile
|
||||||
|
? 'fixed top-0 left-0 right-0 bottom-0 bg-white dark:bg-gray-900'
|
||||||
|
: 'fixed inset-0 flex items-center justify-center bg-black bg-opacity-50'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
zIndex: '2147483647',
|
||||||
|
...(isMobile && {
|
||||||
|
paddingTop: '56px', // 减少顶部padding
|
||||||
|
paddingBottom: '72px' // 减少底部padding
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-6xl h-[80vh] bg-white dark:bg-gray-900 rounded-lg shadow-xl flex relative"
|
className={`${isMobile
|
||||||
|
? 'w-full bg-white dark:bg-gray-900 flex flex-col'
|
||||||
|
: 'w-full max-w-6xl h-[80vh] bg-white dark:bg-gray-900 rounded-lg shadow-xl flex flex-row relative'
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${dragPosition.x}px, ${dragPosition.y}px)`,
|
transform: !isMobile ? `translate(${dragPosition.x}px, ${dragPosition.y}px)` : 'none',
|
||||||
transition: isDragging ? 'none' : 'transform 0.2s ease-out'
|
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
|
||||||
|
...(isMobile && {
|
||||||
|
height: 'calc(100vh - 128px)', // 调整为新的padding总和
|
||||||
|
minHeight: 'calc(100vh - 128px)'
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 拖动头部 */}
|
{/* 拖动头部 - 仅桌面端显示 */}
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-0 right-0 h-8 bg-gray-100 dark:bg-gray-800 rounded-t-lg cursor-grab active:cursor-grabbing flex items-center justify-center"
|
className="absolute top-0 left-0 right-0 h-8 bg-gray-100 dark:bg-gray-800 rounded-t-lg cursor-grab active:cursor-grabbing hidden md:flex items-center justify-center"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||||
>
|
>
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1">
|
||||||
|
|
@ -793,7 +872,16 @@ export function ChatModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 左侧面板 */}
|
{/* 左侧面板 */}
|
||||||
<div className="w-1/3 border-r border-gray-200 dark:border-gray-700 flex flex-col mt-8">
|
<div className={`${isMobile
|
||||||
|
? `w-full flex flex-col ${selectedConversation ? 'hidden' : 'flex'}`
|
||||||
|
: `w-1/3 border-r border-gray-200 dark:border-gray-700 flex flex-col mt-8 h-auto ${selectedConversation ? 'block' : 'block'}`
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
...(isMobile && {
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '100%'
|
||||||
|
})
|
||||||
|
}}>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|
@ -1139,12 +1227,32 @@ export function ChatModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧聊天区域 */}
|
{/* 右侧聊天区域 */}
|
||||||
<div className="flex-1 flex flex-col mt-8">
|
<div className={`${isMobile
|
||||||
|
? `w-full flex flex-col ${selectedConversation ? 'flex' : 'hidden'}`
|
||||||
|
: `flex-1 flex flex-col mt-8 ${selectedConversation ? 'block' : 'block'}`
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
...(isMobile && {
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '100%'
|
||||||
|
})
|
||||||
|
}}>
|
||||||
{selectedConversation ? (
|
{selectedConversation ? (
|
||||||
<>
|
<>
|
||||||
{/* 聊天头部 */}
|
{/* 聊天头部 */}
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* 移动端返回按钮 */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedConversation(null)}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* 对话头像(显示对方用户的头像,如果是群聊则显示群组图标) */}
|
{/* 对话头像(显示对方用户的头像,如果是群聊则显示群组图标) */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{selectedConversation.participants.length === 2 ? (
|
{selectedConversation.participants.length === 2 ? (
|
||||||
|
|
@ -1207,7 +1315,8 @@ export function ChatModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 消息列表 */}
|
{/* 消息列表 */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gradient-to-b from-gray-50/30 to-white/50 dark:from-gray-800/30 dark:to-gray-900/50">
|
<div className={`flex-1 overflow-y-auto space-y-4 bg-gradient-to-b from-gray-50/30 to-white/50 dark:from-gray-800/30 dark:to-gray-900/50 ${isMobile ? 'p-3' : 'p-6'
|
||||||
|
}`}>
|
||||||
{messages.map((message, index) => {
|
{messages.map((message, index) => {
|
||||||
const isOwnMessage = message.sender_id === currentUser?.username;
|
const isOwnMessage = message.sender_id === currentUser?.username;
|
||||||
const prevMessage = index > 0 ? messages[index - 1] : null;
|
const prevMessage = index > 0 ? messages[index - 1] : null;
|
||||||
|
|
@ -1357,7 +1466,7 @@ export function ChatModal({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 主输入区域 */}
|
{/* 主输入区域 */}
|
||||||
<div className="p-4">
|
<div className={`${isMobile ? 'p-3' : 'p-4'} pb-safe`}>
|
||||||
<div className="bg-white dark:bg-gray-700 rounded-2xl shadow-sm border border-gray-200/80 dark:border-gray-600/80 backdrop-blur-sm">
|
<div className="bg-white dark:bg-gray-700 rounded-2xl shadow-sm border border-gray-200/80 dark:border-gray-600/80 backdrop-blur-sm">
|
||||||
{/* 顶部工具栏 */}
|
{/* 顶部工具栏 */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-600">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-600">
|
||||||
|
|
@ -1498,9 +1607,11 @@ export function ChatModal({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
!isMobile && (
|
||||||
选择一个对话开始聊天
|
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
选择一个对话开始聊天
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||||
<header className='md:hidden fixed top-0 left-0 right-0 z-[999] w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
|
<header className='md:hidden fixed top-0 left-0 right-0 z-[999] w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
|
||||||
<div className='h-12 flex items-center justify-between px-4'>
|
<div className='h-12 flex items-center justify-between px-4'>
|
||||||
{/* 左侧:搜索按钮、返回按钮和设置按钮 */}
|
{/* 左侧:搜索按钮、返回按钮和设置按钮 */}
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-1'>
|
||||||
<Link
|
<Link
|
||||||
href='/search'
|
href='/search'
|
||||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
className='w-8 h-8 p-1.5 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className='w-full h-full'
|
className='w-full h-full'
|
||||||
|
|
@ -41,7 +41,7 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧按钮 */}
|
{/* 右侧按钮 */}
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-1'>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export function ThemeToggle() {
|
||||||
const [messageCount, setMessageCount] = useState(0);
|
const [messageCount, setMessageCount] = useState(0);
|
||||||
const [chatCount, setChatCount] = useState(0);
|
const [chatCount, setChatCount] = useState(0);
|
||||||
const [friendRequestCount, setFriendRequestCount] = useState(0);
|
const [friendRequestCount, setFriendRequestCount] = useState(0);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const { setTheme, resolvedTheme } = useTheme();
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
|
@ -54,6 +55,17 @@ export function ThemeToggle() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkMobile);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 监听主题变化和路由变化,确保主题色始终同步
|
// 监听主题变化和路由变化,确保主题色始终同步
|
||||||
|
|
@ -84,16 +96,16 @@ export function ThemeToggle() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center space-x-2">
|
<div className={`flex items-center ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
|
||||||
{/* 聊天按钮 */}
|
{/* 聊天按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsChatModalOpen(true)}
|
onClick={() => setIsChatModalOpen(true)}
|
||||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors relative'
|
className={`${isMobile ? 'w-8 h-8 p-1.5' : 'w-10 h-10 p-2'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors relative`}
|
||||||
aria-label='Open chat'
|
aria-label='Open chat'
|
||||||
>
|
>
|
||||||
<MessageCircle className='w-full h-full' />
|
<MessageCircle className='w-full h-full' />
|
||||||
{messageCount > 0 && (
|
{messageCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
|
<span className={`absolute ${isMobile ? '-top-0.5 -right-0.5 w-4 h-4 text-xs' : '-top-1 -right-1 w-5 h-5 text-xs'} bg-red-500 text-white rounded-full flex items-center justify-center`}>
|
||||||
{messageCount > 99 ? '99+' : messageCount}
|
{messageCount > 99 ? '99+' : messageCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -102,7 +114,7 @@ export function ThemeToggle() {
|
||||||
{/* 主题切换按钮 */}
|
{/* 主题切换按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
className={`${isMobile ? 'w-8 h-8 p-1.5' : 'w-10 h-10 p-2'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors`}
|
||||||
aria-label='Toggle theme'
|
aria-label='Toggle theme'
|
||||||
>
|
>
|
||||||
{resolvedTheme === 'dark' ? (
|
{resolvedTheme === 'dark' ? (
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,21 @@ interface ToastProviderProps {
|
||||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkMobile);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeToast = useCallback((id: string) => {
|
const removeToast = useCallback((id: string) => {
|
||||||
|
|
@ -86,16 +98,16 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getToastIcon = (type: ToastType) => {
|
const getToastIcon = (type: ToastType) => {
|
||||||
const iconProps = { className: 'w-5 h-5 flex-shrink-0' };
|
const iconSize = isMobile ? 'w-4 h-4' : 'w-5 h-5';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return <CheckCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-green-500" />;
|
return <CheckCircle className={`${iconSize} flex-shrink-0 text-green-500`} />;
|
||||||
case 'error':
|
case 'error':
|
||||||
return <XCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-red-500" />;
|
return <XCircle className={`${iconSize} flex-shrink-0 text-red-500`} />;
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return <AlertCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-yellow-500" />;
|
return <AlertCircle className={`${iconSize} flex-shrink-0 text-yellow-500`} />;
|
||||||
case 'info':
|
case 'info':
|
||||||
return <Info {...iconProps} className="w-5 h-5 flex-shrink-0 text-blue-500" />;
|
return <Info className={`${iconSize} flex-shrink-0 text-blue-500`} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -113,29 +125,36 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const toastContainer = mounted && toasts.length > 0 && (
|
const toastContainer = mounted && toasts.length > 0 && (
|
||||||
<div className="fixed top-4 right-4 z-[9999] space-y-2 max-w-sm w-full">
|
<div
|
||||||
|
className={`fixed ${isMobile ? 'space-y-1' : 'space-y-2'} ${isMobile
|
||||||
|
? 'top-14 left-3 right-3 max-w-none z-[2147483648]'
|
||||||
|
: 'top-4 right-4 max-w-sm w-full z-[9999]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
className={`
|
className={`
|
||||||
flex items-start gap-3 p-4 rounded-lg border shadow-lg
|
flex items-start gap-3 rounded-lg border shadow-lg
|
||||||
transform transition-all duration-300 ease-out
|
transform transition-all duration-300 ease-out
|
||||||
animate-in slide-in-from-right-2
|
${isMobile ? 'p-3 text-sm' : 'p-4'}
|
||||||
|
${isMobile ? 'animate-in slide-in-from-top-2' : 'animate-in slide-in-from-right-2'}
|
||||||
${getToastStyles(toast.type)}
|
${getToastStyles(toast.type)}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{getToastIcon(toast.type)}
|
{getToastIcon(toast.type)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-sm font-medium">{toast.title}</h4>
|
<h4 className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{toast.title}</h4>
|
||||||
{toast.message && (
|
{toast.message && (
|
||||||
<p className="text-sm opacity-90 mt-1">{toast.message}</p>
|
<p className={`opacity-90 mt-1 ${isMobile ? 'text-xs' : 'text-sm'}`}>{toast.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeToast(toast.id)}
|
onClick={() => removeToast(toast.id)}
|
||||||
className="flex-shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity"
|
className={`flex-shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity ${isMobile ? 'p-1' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className={isMobile ? 'w-3 h-3' : 'w-4 h-4'} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export const UserMenu: React.FC = () => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string>('');
|
const [avatarUrl, setAvatarUrl] = useState<string>('');
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 裁剪相关状态
|
// 裁剪相关状态
|
||||||
|
|
@ -137,6 +138,17 @@ export const UserMenu: React.FC = () => {
|
||||||
// 确保组件已挂载
|
// 确保组件已挂载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkMobile);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 获取认证信息、存储类型和头像
|
// 获取认证信息、存储类型和头像
|
||||||
|
|
@ -1319,7 +1331,7 @@ export const UserMenu: React.FC = () => {
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<button
|
<button
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
className='w-10 h-10 p-0.5 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors overflow-hidden'
|
className={`${isMobile ? 'w-8 h-8 p-0.5' : 'w-10 h-10 p-0.5'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors overflow-hidden`}
|
||||||
aria-label='User Menu'
|
aria-label='User Menu'
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface AdminConfig {
|
||||||
DoubanImageProxy: string;
|
DoubanImageProxy: string;
|
||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
|
RequireDeviceCode: boolean;
|
||||||
};
|
};
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: {
|
Users: {
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,40 @@ export interface ChangelogEntry {
|
||||||
|
|
||||||
export const changelog: ChangelogEntry[] = [
|
export const changelog: ChangelogEntry[] = [
|
||||||
{
|
{
|
||||||
version: "8.8.8",
|
version: "8.9.0",
|
||||||
date: "2025-09-15",
|
date: "2025-09-15",
|
||||||
|
added: [
|
||||||
|
"机器识别码设定开关",
|
||||||
|
"配置文件去重添加",
|
||||||
|
"视频源编辑",
|
||||||
|
"单个视频源进行有效性检测"
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
"聊天页面适配移动端"
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
"弹幕发送问题",
|
||||||
|
"播放页测速问题",
|
||||||
|
"测速问题"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "8.8.9",
|
||||||
|
date: "2025-09-14",
|
||||||
|
added: [
|
||||||
|
"聊天,好友等功能",
|
||||||
|
"支持arm架构镜像"
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
"播放页面500问题"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "8.8.8",
|
||||||
|
date: "2025-09-12",
|
||||||
added: [
|
added: [
|
||||||
"短剧类目聚合",
|
"短剧类目聚合",
|
||||||
"支持短剧类目搜索",
|
"支持短剧类目搜索",
|
||||||
|
|
|
||||||
|
|
@ -80,15 +80,30 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||||
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
|
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 用于跟踪已存在的API地址,避免重复
|
||||||
|
const existingApiUrls = new Set(
|
||||||
|
Array.from(currentApiSites.values()).map(s => s.api.toLowerCase().trim())
|
||||||
|
);
|
||||||
|
|
||||||
apiSitesFromFile.forEach(([key, site]) => {
|
apiSitesFromFile.forEach(([key, site]) => {
|
||||||
const existingSource = currentApiSites.get(key);
|
const existingSource = currentApiSites.get(key);
|
||||||
|
const normalizedApiUrl = site.api.toLowerCase().trim();
|
||||||
|
|
||||||
if (existingSource) {
|
if (existingSource) {
|
||||||
// 如果已存在,只覆盖 name、api、detail 和 from
|
// 如果已存在,只覆盖 name、api、detail 和 from
|
||||||
existingSource.name = site.name;
|
existingSource.name = site.name;
|
||||||
existingSource.api = site.api;
|
existingSource.api = site.api;
|
||||||
existingSource.detail = site.detail;
|
existingSource.detail = site.detail;
|
||||||
existingSource.from = 'config';
|
existingSource.from = 'config';
|
||||||
|
// 更新API地址记录
|
||||||
|
existingApiUrls.add(normalizedApiUrl);
|
||||||
} else {
|
} else {
|
||||||
|
// 检查API地址是否已存在
|
||||||
|
if (existingApiUrls.has(normalizedApiUrl)) {
|
||||||
|
console.warn(`跳过重复的API地址: ${site.api} (key: ${key})`);
|
||||||
|
return; // 跳过重复的API地址
|
||||||
|
}
|
||||||
|
|
||||||
// 如果不存在,创建新条目
|
// 如果不存在,创建新条目
|
||||||
currentApiSites.set(key, {
|
currentApiSites.set(key, {
|
||||||
key,
|
key,
|
||||||
|
|
@ -98,6 +113,7 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||||
from: 'config',
|
from: 'config',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
existingApiUrls.add(normalizedApiUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -226,6 +242,8 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||||
FluidSearch:
|
FluidSearch:
|
||||||
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
||||||
|
RequireDeviceCode:
|
||||||
|
process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false',
|
||||||
},
|
},
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: [],
|
Users: [],
|
||||||
|
|
@ -339,6 +357,28 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||||
adminConfig.LiveConfig = [];
|
adminConfig.LiveConfig = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确保 SiteConfig 及其属性存在
|
||||||
|
if (!adminConfig.SiteConfig) {
|
||||||
|
adminConfig.SiteConfig = {
|
||||||
|
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV',
|
||||||
|
Announcement: process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||||
|
SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||||
|
SiteInterfaceCacheTime: 7200,
|
||||||
|
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||||
|
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||||
|
DoubanImageProxyType: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||||
|
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||||
|
DisableYellowFilter: process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||||
|
FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
||||||
|
RequireDeviceCode: process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 RequireDeviceCode 属性存在
|
||||||
|
if (adminConfig.SiteConfig.RequireDeviceCode === undefined) {
|
||||||
|
adminConfig.SiteConfig.RequireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
// 站长变更自检
|
// 站长变更自检
|
||||||
const ownerUser = process.env.USERNAME;
|
const ownerUser = process.env.USERNAME;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,38 +72,18 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||||
pingTime: number; // 网络延迟(毫秒)
|
pingTime: number; // 网络延迟(毫秒)
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// 检查是否需要使用代理
|
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
||||||
const needsProxy = m3u8Url.includes('quark.cn') ||
|
|
||||||
m3u8Url.includes('drive.quark.cn') ||
|
|
||||||
m3u8Url.includes('dl-c-zb-') ||
|
|
||||||
m3u8Url.includes('dl-c-') ||
|
|
||||||
m3u8Url.match(/https?:\/\/[^/]*\.drive\./) ||
|
|
||||||
// 添加更多可能需要代理的域名
|
|
||||||
m3u8Url.includes('ffzy-online') ||
|
|
||||||
m3u8Url.includes('bfikuncdn.com') ||
|
|
||||||
m3u8Url.includes('vip.') ||
|
|
||||||
!m3u8Url.includes('localhost');
|
|
||||||
|
|
||||||
const finalM3u8Url = needsProxy
|
|
||||||
? `/api/proxy/video?url=${encodeURIComponent(m3u8Url)}`
|
|
||||||
: m3u8Url;
|
|
||||||
|
|
||||||
if (needsProxy) {
|
|
||||||
console.log('Using proxy for M3U8 resolution detection:', m3u8Url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.preload = 'metadata';
|
video.preload = 'metadata';
|
||||||
|
|
||||||
// 测量网络延迟(ping时间)
|
// 测量网络延迟(ping时间) - 使用m3u8 URL而不是ts文件
|
||||||
const pingStart = performance.now();
|
const pingStart = performance.now();
|
||||||
let pingTime = 0;
|
let pingTime = 0;
|
||||||
|
|
||||||
// 测量ping时间(如果使用代理,则测试代理URL的响应时间)
|
// 测量ping时间(使用m3u8 URL)
|
||||||
const pingUrl = needsProxy ? `/api/proxy/video/test?url=${encodeURIComponent(m3u8Url)}` : m3u8Url;
|
fetch(m3u8Url, { method: 'HEAD', mode: 'no-cors' })
|
||||||
fetch(pingUrl, { method: 'HEAD', mode: needsProxy ? 'cors' : 'no-cors' })
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
pingTime = performance.now() - pingStart;
|
pingTime = performance.now() - pingStart;
|
||||||
})
|
})
|
||||||
|
|
@ -209,31 +189,17 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.loadSource(finalM3u8Url);
|
hls.loadSource(m3u8Url);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|
||||||
// 监听hls.js错误
|
// 监听hls.js错误
|
||||||
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||||
// 只在开发环境下打印详细错误,生产环境下简化错误信息
|
console.error('HLS错误:', data);
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Video resolution detection failed:', {
|
|
||||||
url: needsProxy ? 'via proxy' : m3u8Url,
|
|
||||||
error: data.details,
|
|
||||||
type: data.type
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
video.remove();
|
video.remove();
|
||||||
|
reject(new Error(`HLS播放失败: ${data.type}`));
|
||||||
// 对于CORS相关错误,提供更友好的错误信息
|
|
||||||
if (data.details === 'manifestLoadError' || data.type === 'networkError') {
|
|
||||||
reject(new Error('Network access restricted'));
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Video analysis failed: ${data.type}`));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -263,4 +229,4 @@ export function cleanHtmlTags(text: string): string {
|
||||||
|
|
||||||
// 使用 he 库解码 HTML 实体
|
// 使用 he 库解码 HTML 实体
|
||||||
return he.decode(cleanedText);
|
return he.decode(cleanedText);
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
const CURRENT_VERSION = '8.8.8';
|
const CURRENT_VERSION = '8.9.0';
|
||||||
|
|
||||||
// 导出当前版本号供其他地方使用
|
// 导出当前版本号供其他地方使用
|
||||||
export { CURRENT_VERSION };
|
export { CURRENT_VERSION };
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ async function fetchVersionFromUrl(url: string): Promise<string | null> {
|
||||||
*/
|
*/
|
||||||
export function compareVersions(remoteVersion: string): UpdateStatus {
|
export function compareVersions(remoteVersion: string): UpdateStatus {
|
||||||
// 如果版本号相同,无需更新
|
// 如果版本号相同,无需更新
|
||||||
if ('8.8.8' === CURRENT_VERSION) {
|
if ('8.9.0' === CURRENT_VERSION) {
|
||||||
return UpdateStatus.NO_UPDATE;
|
return UpdateStatus.NO_UPDATE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue