修复下载格式与优化配置
This commit is contained in:
+362
-124
@@ -49,7 +49,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { GripVertical } from 'lucide-react';
|
import { GripVertical } from 'lucide-react';
|
||||||
import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
||||||
@@ -11248,6 +11248,7 @@ const OPDSConfigComponent = ({
|
|||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000);
|
const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000);
|
||||||
const [sources, setSources] = useState<BookSource[]>([]);
|
const [sources, setSources] = useState<BookSource[]>([]);
|
||||||
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.OPDSConfig) {
|
if (config?.OPDSConfig) {
|
||||||
@@ -11269,56 +11270,73 @@ const OPDSConfigComponent = ({
|
|||||||
language: item.language || '',
|
language: item.language || '',
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
setEditingIndex(null);
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingIndex((prev) => {
|
||||||
|
if (prev === null) return prev;
|
||||||
|
return prev >= sources.length ? null : prev;
|
||||||
|
});
|
||||||
|
}, [sources.length]);
|
||||||
|
|
||||||
const updateSource = (index: number, patch: Partial<BookSource>) => {
|
const updateSource = (index: number, patch: Partial<BookSource>) => {
|
||||||
setSources((prev) => prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item)));
|
setSources((prev) => prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSource = () => {
|
const addSource = () => {
|
||||||
setSources((prev) => [
|
setSources((prev) => {
|
||||||
...prev,
|
const nextIndex = prev.length;
|
||||||
{
|
setEditingIndex(nextIndex);
|
||||||
id: `source_${prev.length + 1}`,
|
return [
|
||||||
name: `书源 ${prev.length + 1}`,
|
...prev,
|
||||||
url: '',
|
{
|
||||||
enabled: true,
|
id: `source_${prev.length + 1}`,
|
||||||
authMode: 'none',
|
name: `书源 ${prev.length + 1}`,
|
||||||
username: '',
|
url: '',
|
||||||
password: '',
|
enabled: true,
|
||||||
headerName: '',
|
authMode: 'none',
|
||||||
headerValue: '',
|
username: '',
|
||||||
searchTemplate: '',
|
password: '',
|
||||||
preferFormat: ['epub', 'pdf'],
|
headerName: '',
|
||||||
language: '',
|
headerValue: '',
|
||||||
},
|
searchTemplate: '',
|
||||||
]);
|
preferFormat: ['epub', 'pdf'],
|
||||||
|
language: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSource = (index: number) => {
|
const removeSource = (index: number) => {
|
||||||
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
||||||
|
setEditingIndex((prev) => {
|
||||||
|
if (prev === null) return prev;
|
||||||
|
if (prev === index) return null;
|
||||||
|
return prev > index ? prev - 1 : prev;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeSource = (source: BookSource, index: number) => ({
|
||||||
|
id: source.id?.trim() || `source_${index + 1}`,
|
||||||
|
name: source.name?.trim() || `书源 ${index + 1}`,
|
||||||
|
url: source.url?.trim() || '',
|
||||||
|
enabled: source.enabled !== false,
|
||||||
|
authMode: source.authMode || 'none',
|
||||||
|
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
|
||||||
|
password: source.authMode === 'none' ? '' : source.password || '',
|
||||||
|
headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
||||||
|
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
||||||
|
searchTemplate: source.searchTemplate?.trim() || '',
|
||||||
|
preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'],
|
||||||
|
language: source.language?.trim() || '',
|
||||||
|
});
|
||||||
|
|
||||||
const buildConfig = () => ({
|
const buildConfig = () => ({
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
|
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
|
||||||
Sources: sources
|
Sources: sources.map(normalizeSource).filter((source) => !!source.url),
|
||||||
.map((source, index) => ({
|
|
||||||
id: source.id?.trim() || `source_${index + 1}`,
|
|
||||||
name: source.name?.trim() || `书源 ${index + 1}`,
|
|
||||||
url: source.url?.trim() || '',
|
|
||||||
enabled: source.enabled !== false,
|
|
||||||
authMode: source.authMode || 'none',
|
|
||||||
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
|
|
||||||
password: source.authMode === 'none' ? '' : source.password || '',
|
|
||||||
headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
|
||||||
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
|
||||||
searchTemplate: source.searchTemplate?.trim() || '',
|
|
||||||
preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'],
|
|
||||||
language: source.language?.trim() || '',
|
|
||||||
}))
|
|
||||||
.filter((source) => !!source.url),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -11346,26 +11364,31 @@ const OPDSConfigComponent = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async (index: number) => {
|
||||||
await withLoading('testOPDSConfig', async () => {
|
await withLoading(`testOPDSConfig-${index}`, async () => {
|
||||||
try {
|
try {
|
||||||
|
const source = normalizeSource(sources[index], index);
|
||||||
|
if (!source?.url) {
|
||||||
|
throw new Error('请先填写书源地址');
|
||||||
|
}
|
||||||
const response = await fetch('/api/admin/opds', {
|
const response = await fetch('/api/admin/opds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(buildConfig()),
|
body: JSON.stringify({
|
||||||
|
Enabled: true,
|
||||||
|
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
|
||||||
|
Sources: [source],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.success) {
|
if (!response.ok || !data.success) {
|
||||||
throw new Error(data.message || data.error || '测试连接失败');
|
throw new Error(data.message || data.error || '测试连接失败');
|
||||||
}
|
}
|
||||||
const summary = Array.isArray(data.results)
|
const result = Array.isArray(data.results) ? data.results[0] : null;
|
||||||
? data.results
|
const summary = result
|
||||||
.map((item: { name: string; capability: { catalogSupported: boolean; searchSupported: boolean; lastError?: string } }) =>
|
? `${result.name}: 分类${result.capability.catalogSupported ? '√' : '×'} / 搜索${result.capability.searchSupported ? '√' : '×'}${result.capability.lastError ? ` (${result.capability.lastError})` : ''}`
|
||||||
`${item.name}: 分类${item.capability.catalogSupported ? '√' : '×'} / 搜索${item.capability.searchSupported ? '√' : '×'}${item.capability.lastError ? ` (${item.capability.lastError})` : ''}`
|
: data.message || '测试成功';
|
||||||
)
|
showSuccess(summary, showAlert);
|
||||||
.join('\n')
|
|
||||||
: '';
|
|
||||||
showSuccess(summary || data.message || '测试成功', showAlert);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
|
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -11422,92 +11445,307 @@ const OPDSConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sources.map((source, index) => (
|
{sources.length > 0 && (
|
||||||
<div key={`${source.id}-${index}`} className='rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4'>
|
<>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='space-y-3 md:hidden'>
|
||||||
<div className='text-sm font-medium text-gray-900 dark:text-white'>书源 #{index + 1}</div>
|
{sources.map((source, index) => {
|
||||||
<div className='flex items-center gap-2'>
|
const isEditing = editingIndex === index;
|
||||||
<button
|
return (
|
||||||
type='button'
|
<div key={`opds-source-${index}`} className='overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'>
|
||||||
onClick={() => updateSource(index, { enabled: source.enabled === false })}
|
<div className='space-y-3 p-4'>
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}
|
<div className='flex items-start justify-between gap-3'>
|
||||||
>
|
<div className='min-w-0 flex-1'>
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} />
|
<div className='text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
</button>
|
{source.name || `书源 ${index + 1}`}
|
||||||
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.danger}>
|
</div>
|
||||||
<Trash2 size={16} />
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
</button>
|
{source.id || '未设置 ID'}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => updateSource(index, { enabled: source.enabled === false })}
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2 text-xs text-gray-600 dark:text-gray-300'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<span className='shrink-0 text-gray-500 dark:text-gray-400'>地址</span>
|
||||||
|
<span className='min-w-0 text-right break-all'>{source.url || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<span className='text-gray-500 dark:text-gray-400'>认证</span>
|
||||||
|
<span>{source.authMode === 'none' ? '无认证' : source.authMode === 'basic' ? 'Basic Auth' : '自定义 Header'}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<span className='text-gray-500 dark:text-gray-400'>搜索</span>
|
||||||
|
<span>{source.searchTemplate?.trim() ? '已配置' : '未配置'}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<span className='text-gray-500 dark:text-gray-400'>格式</span>
|
||||||
|
<span>{source.preferFormat?.join(', ') || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleTest(index)}
|
||||||
|
disabled={isLoading(`testOPDSConfig-${index}`)}
|
||||||
|
className={buttonStyles.primarySmall}
|
||||||
|
>
|
||||||
|
{isLoading(`testOPDSConfig-${index}`) ? '测试中...' : '测试'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setEditingIndex(isEditing ? null : index)}
|
||||||
|
className={buttonStyles.secondarySmall}
|
||||||
|
>
|
||||||
|
{isEditing ? <><ChevronUp size={14} className='inline mr-1' />收起</> : <><Settings size={14} className='inline mr-1' />编辑</>}
|
||||||
|
</button>
|
||||||
|
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}>
|
||||||
|
<Trash2 size={14} className='inline mr-1' />删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div className='space-y-4 border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/40'>
|
||||||
|
<div className='text-sm font-medium text-gray-900 dark:text-white'>编辑书源 #{index + 1}</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>书源 ID</label>
|
||||||
|
<input type='text' value={source.id} onChange={(e) => updateSource(index, { id: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>书源名称</label>
|
||||||
|
<input type='text' value={source.name} onChange={(e) => updateSource(index, { name: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>根地址</label>
|
||||||
|
<input type='text' value={source.url} onChange={(e) => updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>认证方式</label>
|
||||||
|
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'>
|
||||||
|
<option value='none'>无认证</option>
|
||||||
|
<option value='basic'>Basic Auth</option>
|
||||||
|
<option value='header'>自定义 Header</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>语言</label>
|
||||||
|
<input type='text' value={source.language || ''} onChange={(e) => updateSource(index, { language: e.target.value })} placeholder='zh / en' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>搜索模板</label>
|
||||||
|
<input type='text' value={source.searchTemplate || ''} onChange={(e) => updateSource(index, { searchTemplate: e.target.value })} placeholder='https://...{searchTerms}' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source.authMode === 'basic' && (
|
||||||
|
<div className='grid grid-cols-1 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>用户名</label>
|
||||||
|
<input type='text' value={source.username || ''} onChange={(e) => updateSource(index, { username: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>密码</label>
|
||||||
|
<input type='password' value={source.password || ''} onChange={(e) => updateSource(index, { password: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{source.authMode === 'header' && (
|
||||||
|
<div className='grid grid-cols-1 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Header 名称</label>
|
||||||
|
<input type='text' value={source.headerName || ''} onChange={(e) => updateSource(index, { headerName: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Header 值</label>
|
||||||
|
<input type='password' value={source.headerValue || ''} onChange={(e) => updateSource(index, { headerValue: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
<div className='hidden overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 md:block'>
|
||||||
<div>
|
<div className='overflow-x-auto'>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>书源 ID</label>
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
<input type='text' value={source.id} onChange={(e) => updateSource(index, { id: 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' />
|
<thead className='bg-gray-50 dark:bg-gray-800/70'>
|
||||||
</div>
|
<tr>
|
||||||
<div>
|
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>启用</th>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>书源名称</label>
|
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>名称</th>
|
||||||
<input type='text' value={source.name} onChange={(e) => updateSource(index, { name: 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' />
|
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>ID</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>地址</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>认证</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>搜索</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>格式偏好</th>
|
||||||
|
<th className='px-4 py-3 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900'>
|
||||||
|
{sources.map((source, index) => {
|
||||||
|
const isEditing = editingIndex === index;
|
||||||
|
return (
|
||||||
|
<Fragment key={`opds-source-${index}`}>
|
||||||
|
<tr className='align-top'>
|
||||||
|
<td className='px-4 py-3'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => updateSource(index, { enabled: source.enabled === false })}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3 text-sm text-gray-900 dark:text-gray-100'>
|
||||||
|
<div className='font-medium'>{source.name || `书源 ${index + 1}`}</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>{source.language || '未设置语言'}</div>
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>{source.id || '-'}</td>
|
||||||
|
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||||
|
<div className='max-w-[320px] truncate' title={source.url || ''}>{source.url || '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||||
|
{source.authMode === 'none' ? '无认证' : source.authMode === 'basic' ? 'Basic Auth' : '自定义 Header'}
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||||
|
{source.searchTemplate?.trim() ? '已配置' : '未配置'}
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>{source.preferFormat?.join(', ') || '-'}</td>
|
||||||
|
<td className='px-4 py-3'>
|
||||||
|
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleTest(index)}
|
||||||
|
disabled={isLoading(`testOPDSConfig-${index}`)}
|
||||||
|
className={buttonStyles.primarySmall}
|
||||||
|
>
|
||||||
|
{isLoading(`testOPDSConfig-${index}`) ? '测试中...' : '测试'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setEditingIndex(isEditing ? null : index)}
|
||||||
|
className={buttonStyles.secondarySmall}
|
||||||
|
>
|
||||||
|
{isEditing ? <><ChevronUp size={14} className='inline mr-1' />收起</> : <><Settings size={14} className='inline mr-1' />编辑</>}
|
||||||
|
</button>
|
||||||
|
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}>
|
||||||
|
<Trash2 size={14} className='inline mr-1' />删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className='bg-gray-50 px-4 py-4 dark:bg-gray-800/40'>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<div className='text-sm font-medium text-gray-900 dark:text-white'>编辑书源 #{index + 1}</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>仅展开当前书源,保存时统一提交。</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setEditingIndex(null)}
|
||||||
|
className={buttonStyles.secondarySmall}
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} className='inline mr-1' />收起
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>书源 ID</label>
|
||||||
|
<input type='text' value={source.id} onChange={(e) => updateSource(index, { id: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>书源名称</label>
|
||||||
|
<input type='text' value={source.name} onChange={(e) => updateSource(index, { name: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>根地址</label>
|
||||||
|
<input type='text' value={source.url} onChange={(e) => updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>认证方式</label>
|
||||||
|
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'>
|
||||||
|
<option value='none'>无认证</option>
|
||||||
|
<option value='basic'>Basic Auth</option>
|
||||||
|
<option value='header'>自定义 Header</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>语言</label>
|
||||||
|
<input type='text' value={source.language || ''} onChange={(e) => updateSource(index, { language: e.target.value })} placeholder='zh / en' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>搜索模板</label>
|
||||||
|
<input type='text' value={source.searchTemplate || ''} onChange={(e) => updateSource(index, { searchTemplate: e.target.value })} placeholder='https://...{searchTerms}' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source.authMode === 'basic' && (
|
||||||
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>用户名</label>
|
||||||
|
<input type='text' value={source.username || ''} onChange={(e) => updateSource(index, { username: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>密码</label>
|
||||||
|
<input type='password' value={source.password || ''} onChange={(e) => updateSource(index, { password: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{source.authMode === 'header' && (
|
||||||
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Header 名称</label>
|
||||||
|
<input type='text' value={source.headerName || ''} onChange={(e) => updateSource(index, { headerName: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Header 值</label>
|
||||||
|
<input type='password' value={source.headerValue || ''} onChange={(e) => updateSource(index, { headerValue: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<div>
|
)}
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>根地址</label>
|
|
||||||
<input type='text' value={source.url} onChange={(e) => updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' 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 className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
|
||||||
<div>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>认证方式</label>
|
|
||||||
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} 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'>
|
|
||||||
<option value='none'>无认证</option>
|
|
||||||
<option value='basic'>Basic Auth</option>
|
|
||||||
<option value='header'>自定义 Header</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>语言</label>
|
|
||||||
<input type='text' value={source.language || ''} onChange={(e) => updateSource(index, { language: e.target.value })} placeholder='zh / en' 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-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>搜索模板</label>
|
|
||||||
<input type='text' value={source.searchTemplate || ''} onChange={(e) => updateSource(index, { searchTemplate: e.target.value })} placeholder='https://...{searchTerms}' 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>
|
|
||||||
|
|
||||||
{source.authMode === 'basic' && (
|
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
|
||||||
<div>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>用户名</label>
|
|
||||||
<input type='text' value={source.username || ''} onChange={(e) => updateSource(index, { username: 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' />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>密码</label>
|
|
||||||
<input type='password' value={source.password || ''} onChange={(e) => updateSource(index, { password: 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' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source.authMode === 'header' && (
|
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
|
||||||
<div>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Header 名称</label>
|
|
||||||
<input type='text' value={source.headerName || ''} onChange={(e) => updateSource(index, { headerName: 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' />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Header 值</label>
|
|
||||||
<input type='password' value={source.headerValue || ''} onChange={(e) => updateSource(index, { headerValue: 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' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex gap-3'>
|
<div className='flex gap-3'>
|
||||||
<button onClick={handleTest} disabled={isLoading('testOPDSConfig')} className={buttonStyles.primary}>
|
|
||||||
{isLoading('testOPDSConfig') ? '测试中...' : '测试书源'}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleSave} disabled={isLoading('saveOPDSConfig')} className={buttonStyles.success}>
|
<button onClick={handleSave} disabled={isLoading('saveOPDSConfig')} className={buttonStyles.success}>
|
||||||
{isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'}
|
{isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ async function proxyFile(request: NextRequest, sourceId: string, href: string) {
|
|||||||
const acceptRanges = response.headers.get('accept-ranges');
|
const acceptRanges = response.headers.get('accept-ranges');
|
||||||
const contentRange = response.headers.get('content-range');
|
const contentRange = response.headers.get('content-range');
|
||||||
const disposition = response.headers.get('content-disposition');
|
const disposition = response.headers.get('content-disposition');
|
||||||
if (contentType) outHeaders.set('Content-Type', contentType);
|
const normalizedHref = href.toLowerCase();
|
||||||
|
const resolvedContentType = contentType || (normalizedHref.endsWith('.pdf') ? 'application/pdf' : normalizedHref.endsWith('.epub') ? 'application/epub+zip' : '');
|
||||||
|
if (resolvedContentType) outHeaders.set('Content-Type', resolvedContentType);
|
||||||
if (contentLength) outHeaders.set('Content-Length', contentLength);
|
if (contentLength) outHeaders.set('Content-Length', contentLength);
|
||||||
if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges);
|
if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges);
|
||||||
if (contentRange) outHeaders.set('Content-Range', contentRange);
|
if (contentRange) outHeaders.set('Content-Range', contentRange);
|
||||||
|
|||||||
@@ -31,7 +31,23 @@ function DetailSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf', download = false, href?: string) {
|
function parseDownloadFilename(disposition: string | null) {
|
||||||
|
if (!disposition) return '';
|
||||||
|
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(utf8Match[1]);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
|
||||||
|
return plainMatch?.[1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string) {
|
||||||
|
return name.replace(/[\/:*?"<>|]/g, '_').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf', download = false, href?: string, title?: string) {
|
||||||
const response = await fetch('/api/books/file', {
|
const response = await fetch('/api/books/file', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -48,9 +64,13 @@ async function openBookFile(sourceId: string, bookId: string, format?: 'epub' |
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (download) {
|
if (download) {
|
||||||
|
const headerFilename = parseDownloadFilename(response.headers.get('content-disposition'));
|
||||||
|
const fallbackBaseName = sanitizeFilename(title || bookId || 'book') || 'book';
|
||||||
|
const extension = format === 'pdf' ? 'pdf' : 'epub';
|
||||||
|
const finalFilename = headerFilename || `${fallbackBaseName}.${extension}`;
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = '';
|
link.download = finalFilename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
@@ -155,7 +175,7 @@ export default function BookDetailPage() {
|
|||||||
<div className='flex flex-wrap gap-3'>
|
<div className='flex flex-wrap gap-3'>
|
||||||
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读</Link> : null}
|
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读</Link> : null}
|
||||||
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button>
|
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button>
|
||||||
{readable ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
{readable ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user