diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8f824ca..9aca2d1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -49,7 +49,7 @@ import { Trash2, } 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 { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; @@ -11248,6 +11248,7 @@ const OPDSConfigComponent = ({ const [enabled, setEnabled] = useState(false); const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000); const [sources, setSources] = useState([]); + const [editingIndex, setEditingIndex] = useState(null); useEffect(() => { if (config?.OPDSConfig) { @@ -11269,56 +11270,73 @@ const OPDSConfigComponent = ({ language: item.language || '', })) ); + setEditingIndex(null); } }, [config]); + useEffect(() => { + setEditingIndex((prev) => { + if (prev === null) return prev; + return prev >= sources.length ? null : prev; + }); + }, [sources.length]); + const updateSource = (index: number, patch: Partial) => { setSources((prev) => prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))); }; const addSource = () => { - setSources((prev) => [ - ...prev, - { - id: `source_${prev.length + 1}`, - name: `书源 ${prev.length + 1}`, - url: '', - enabled: true, - authMode: 'none', - username: '', - password: '', - headerName: '', - headerValue: '', - searchTemplate: '', - preferFormat: ['epub', 'pdf'], - language: '', - }, - ]); + setSources((prev) => { + const nextIndex = prev.length; + setEditingIndex(nextIndex); + return [ + ...prev, + { + id: `source_${prev.length + 1}`, + name: `书源 ${prev.length + 1}`, + url: '', + enabled: true, + authMode: 'none', + username: '', + password: '', + headerName: '', + headerValue: '', + searchTemplate: '', + preferFormat: ['epub', 'pdf'], + language: '', + }, + ]; + }); }; const removeSource = (index: number) => { 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 = () => ({ Enabled: enabled, CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000), - Sources: sources - .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), + Sources: sources.map(normalizeSource).filter((source) => !!source.url), }); const handleSave = async () => { @@ -11346,26 +11364,31 @@ const OPDSConfigComponent = ({ }); }; - const handleTest = async () => { - await withLoading('testOPDSConfig', async () => { + const handleTest = async (index: number) => { + await withLoading(`testOPDSConfig-${index}`, async () => { try { + const source = normalizeSource(sources[index], index); + if (!source?.url) { + throw new Error('请先填写书源地址'); + } const response = await fetch('/api/admin/opds', { method: 'POST', 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(); if (!response.ok || !data.success) { throw new Error(data.message || data.error || '测试连接失败'); } - const summary = Array.isArray(data.results) - ? data.results - .map((item: { name: string; capability: { catalogSupported: boolean; searchSupported: boolean; lastError?: string } }) => - `${item.name}: 分类${item.capability.catalogSupported ? '√' : '×'} / 搜索${item.capability.searchSupported ? '√' : '×'}${item.capability.lastError ? ` (${item.capability.lastError})` : ''}` - ) - .join('\n') - : ''; - showSuccess(summary || data.message || '测试成功', showAlert); + const result = Array.isArray(data.results) ? data.results[0] : null; + const summary = result + ? `${result.name}: 分类${result.capability.catalogSupported ? '√' : '×'} / 搜索${result.capability.searchSupported ? '√' : '×'}${result.capability.lastError ? ` (${result.capability.lastError})` : ''}` + : data.message || '测试成功'; + showSuccess(summary, showAlert); } catch (error) { showError(error instanceof Error ? error.message : '测试连接失败', showAlert); throw error; @@ -11422,92 +11445,307 @@ const OPDSConfigComponent = ({ )} - {sources.map((source, index) => ( -
-
-
书源 #{index + 1}
-
- - -
+ {sources.length > 0 && ( + <> +
+ {sources.map((source, index) => { + const isEditing = editingIndex === index; + return ( +
+
+
+
+
+ {source.name || `书源 ${index + 1}`} +
+
+ {source.id || '未设置 ID'} +
+
+ +
+ +
+
+ 地址 + {source.url || '-'} +
+
+ 认证 + {source.authMode === 'none' ? '无认证' : source.authMode === 'basic' ? 'Basic Auth' : '自定义 Header'} +
+
+ 搜索 + {source.searchTemplate?.trim() ? '已配置' : '未配置'} +
+
+ 格式 + {source.preferFormat?.join(', ') || '-'} +
+
+ +
+ + + +
+
+ + {isEditing && ( +
+
编辑书源 #{index + 1}
+ +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ +
+ + 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' /> +
+ +
+
+ + +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ + {source.authMode === 'basic' && ( +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ )} + + {source.authMode === 'header' && ( +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ )} +
+ )} +
+ ); + })}
-
-
- - 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' /> -
-
- - 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' /> +
+
+ + + + + + + + + + + + + + + {sources.map((source, index) => { + const isEditing = editingIndex === index; + return ( + + + + + + + + + + + + + {isEditing && ( + + + + )} + + ); + })} + +
启用名称ID地址认证搜索格式偏好操作
+ + +
{source.name || `书源 ${index + 1}`}
+
{source.language || '未设置语言'}
+
{source.id || '-'} +
{source.url || '-'}
+
+ {source.authMode === 'none' ? '无认证' : source.authMode === 'basic' ? 'Basic Auth' : '自定义 Header'} + + {source.searchTemplate?.trim() ? '已配置' : '未配置'} + {source.preferFormat?.join(', ') || '-'} +
+ + + +
+
+
+
+
+
编辑书源 #{index + 1}
+
仅展开当前书源,保存时统一提交。
+
+ +
+ +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ +
+ + 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' /> +
+ +
+
+ + +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ + {source.authMode === 'basic' && ( +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ )} + + {source.authMode === 'header' && ( +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ )} +
+
- -
- - 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' /> -
- -
-
- - -
-
- - 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' /> -
-
- - 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' /> -
-
- - {source.authMode === 'basic' && ( -
-
- - 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' /> -
-
- - 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' /> -
-
- )} - - {source.authMode === 'header' && ( -
-
- - 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' /> -
-
- - 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' /> -
-
- )} -
- ))} + + )}
- diff --git a/src/app/api/books/file/route.ts b/src/app/api/books/file/route.ts index 23850a1..944dacf 100644 --- a/src/app/api/books/file/route.ts +++ b/src/app/api/books/file/route.ts @@ -73,7 +73,9 @@ async function proxyFile(request: NextRequest, sourceId: string, href: string) { const acceptRanges = response.headers.get('accept-ranges'); const contentRange = response.headers.get('content-range'); 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 (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges); if (contentRange) outHeaders.set('Content-Range', contentRange); diff --git a/src/app/books/detail/page.tsx b/src/app/books/detail/page.tsx index 4481f6f..5bf7c2f 100644 --- a/src/app/books/detail/page.tsx +++ b/src/app/books/detail/page.tsx @@ -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', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -48,9 +64,13 @@ async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | const blob = await response.blob(); const url = URL.createObjectURL(blob); 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'); link.href = url; - link.download = ''; + link.download = finalFilename; document.body.appendChild(link); link.click(); link.remove(); @@ -155,7 +175,7 @@ export default function BookDetailPage() {
{readable ? cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读 : null} - {readable ? : null} + {readable ? : null}