From 79fe582f1702b5012eb301d23fabdcdae793c6fc Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 19 May 2026 21:03:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=BC=8Flegado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 1245 +++-------------- .../[id]/refresh/route.ts | 45 + .../admin/legado-subscriptions/[id]/route.ts | 35 + .../legado-subscriptions/import/route.ts | 36 + src/lib/admin.types.ts | 13 +- src/lib/config.ts | 7 + src/lib/legado.client.ts | 29 +- src/lib/legado/subscription-store.ts | 211 +++ 8 files changed, 550 insertions(+), 1071 deletions(-) create mode 100644 src/app/api/admin/legado-subscriptions/[id]/refresh/route.ts create mode 100644 src/app/api/admin/legado-subscriptions/[id]/route.ts create mode 100644 src/app/api/admin/legado-subscriptions/import/route.ts create mode 100644 src/lib/legado/subscription-store.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 0d106cd..1ba211e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12217,217 +12217,102 @@ const OPDSConfigComponent = ({ const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000); const [sources, setSources] = useState([]); const [editingIndex, setEditingIndex] = useState(null); - const [legadoImportText, setLegadoImportText] = useState(''); - const [legadoRuleDrafts, setLegadoRuleDrafts] = useState>({}); + const [legadoSubscriptionName, setLegadoSubscriptionName] = useState(''); + const [legadoSubscriptionUrl, setLegadoSubscriptionUrl] = useState(''); + const [legadoSubscriptions, setLegadoSubscriptions] = useState['LegadoSubscriptions']>([]); useEffect(() => { - if (config?.OPDSConfig) { - setEnabled(config.OPDSConfig.Enabled || false); - setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000); - setSources( - (config.OPDSConfig.Sources || []).map((item, index) => ({ - id: item.id || `source_${index + 1}`, - name: item.name || `书源 ${index + 1}`, - type: item.type || 'opds', - url: item.url || '', - enabled: item.enabled !== false, - authMode: item.authMode || 'none', - username: item.username || '', - password: item.password || '', - headerName: item.headerName || '', - headerValue: item.headerValue || '', - searchTemplate: item.searchTemplate || '', - preferFormat: item.preferFormat || ['epub', 'pdf'], - language: item.language || '', - legado: item.legado, - })) - ); - setEditingIndex(null); - setLegadoRuleDrafts({}); - } + if (!config?.OPDSConfig) return; + setEnabled(config.OPDSConfig.Enabled || false); + setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000); + setSources((config.OPDSConfig.Sources || []).map((item, index) => ({ + id: item.id || `source_${index + 1}`, + name: item.name || `书源 ${index + 1}`, + type: 'opds' as const, + url: item.url || '', + enabled: item.enabled !== false, + authMode: item.authMode || 'none', + username: item.username || '', + password: item.password || '', + headerName: item.headerName || '', + headerValue: item.headerValue || '', + searchTemplate: item.searchTemplate || '', + preferFormat: item.preferFormat || ['epub', 'pdf'], + language: item.language || '', + }))); + setLegadoSubscriptions(config.OPDSConfig.LegadoSubscriptions || []); + 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)) - ); + setSources((prev) => prev.map((item, idx) => idx === index ? { ...item, ...patch } : item)); }; const addSource = () => { setSources((prev) => { const nextIndex = prev.length; setEditingIndex(nextIndex); - return [ - ...prev, - { - id: `source_${prev.length + 1}`, - name: `书源 ${prev.length + 1}`, - type: 'opds', - url: '', - enabled: true, - authMode: 'none', - username: '', - password: '', - headerName: '', - headerValue: '', - searchTemplate: '', - preferFormat: ['epub', 'pdf'], - language: '', - legado: undefined, - }, - ]; + return [...prev, { + id: `source_${nextIndex + 1}`, + name: `书源 ${nextIndex + 1}`, + type: 'opds' as const, + url: '', + enabled: true, + authMode: 'none' as const, + username: '', + password: '', + headerName: '', + headerValue: '', + searchTemplate: '', + preferFormat: ['epub' as const, 'pdf' as const], + language: '', + }]; }); }; - const makeLegadoSourceId = (name: string, url: string, index: number) => { - const raw = `${name}|${url}|${index}`; - let hash = 0; - for (let i = 0; i < raw.length; i += 1) { - hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0; - } - return `legado_${Math.abs(hash).toString(36)}`; - }; - - const importLegadoSources = () => { - try { - const parsed = JSON.parse(legadoImportText); - const list = Array.isArray(parsed) ? parsed : [parsed]; - const imported = list - .filter((item) => item && typeof item === 'object') - .map((rule: any, index) => { - const name = rule.bookSourceName || `Legado 书源 ${index + 1}`; - const url = rule.bookSourceUrl || ''; - return { - id: makeLegadoSourceId(name, url, index), - name, - type: 'legado' as const, - url, - enabled: rule.enabled !== false, - authMode: 'none' as const, - username: '', - password: '', - headerName: '', - headerValue: '', - searchTemplate: '', - preferFormat: ['epub' as const], - language: '', - legado: rule, - } satisfies BookSource; - }) - .filter((source) => !!source.url); - - if (imported.length === 0) { - throw new Error('没有识别到有效 Legado 书源,请确认 JSON 内含 bookSourceUrl'); - } - - setSources((prev) => { - const existed = new Set(prev.map((item) => `${item.type || 'opds'}|${item.url}|${item.name}`)); - const next = imported.filter((item) => !existed.has(`${item.type}|${item.url}|${item.name}`)); - return [...prev, ...next]; - }); - setLegadoImportText(''); - showSuccess(`已导入 ${imported.length} 个 Legado 书源`, showAlert); - } catch (error) { - showError(error instanceof Error ? error.message : 'Legado JSON 解析失败', showAlert); - } - }; - const removeSource = (index: number) => { setSources((prev) => prev.filter((_, idx) => idx !== index)); - setLegadoRuleDrafts((prev) => { - const next: Record = {}; - Object.entries(prev).forEach(([key, value]) => { - const numericKey = Number(key); - if (numericKey < index) next[numericKey] = value; - if (numericKey > index) next[numericKey - 1] = value; - }); - return next; - }); - setEditingIndex((prev) => { - if (prev === null) return prev; - if (prev === index) return null; - return prev > index ? prev - 1 : prev; - }); + setEditingIndex((prev) => prev === index ? null : prev !== null && prev > index ? prev - 1 : prev); }; const normalizeSource = (source: BookSource, index: number) => ({ id: source.id?.trim() || `source_${index + 1}`, name: source.name?.trim() || `书源 ${index + 1}`, - type: source.type || 'opds', + type: 'opds' as const, 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() || '' : '', + headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '', headerValue: source.authMode === 'header' ? source.headerValue || '' : '', - searchTemplate: source.type === 'legado' ? '' : source.searchTemplate?.trim() || '', - preferFormat: source.preferFormat?.length - ? source.preferFormat - : ['epub', 'pdf'], + searchTemplate: source.searchTemplate?.trim() || '', + preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'], language: source.language?.trim() || '', - legado: source.type === 'legado' ? source.legado : undefined, }); - const updateLegadoRuleJson = (index: number, value: string) => { - setLegadoRuleDrafts((prev) => ({ ...prev, [index]: value })); - try { - const rule = JSON.parse(value); - updateSource(index, { - legado: rule, - name: rule.bookSourceName || sources[index]?.name, - url: rule.bookSourceUrl || sources[index]?.url, - }); - } catch { - // 允许用户继续编辑尚未完成的 JSON,保存前需修正为合法 JSON - } - }; - const buildConfig = () => ({ Enabled: enabled, CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000), Sources: sources.map(normalizeSource).filter((source) => !!source.url), + LegadoSubscriptions: legadoSubscriptions || [], }); const handleSave = async () => { await withLoading('saveOPDSConfig', async () => { try { if (!config) throw new Error('配置未加载'); - for (const [index, draft] of Object.entries(legadoRuleDrafts)) { - if (!draft.trim()) continue; - try { - JSON.parse(draft); - } catch { - throw new Error(`第 ${Number(index) + 1} 个 Legado 书源 JSON 格式不正确`); - } - } const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...config, - OPDSConfig: buildConfig(), - }), + body: JSON.stringify({ ...config, OPDSConfig: buildConfig() }), }); - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || '保存失败'); - } - showSuccess('电子书 OPDS 配置已保存', showAlert); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || '保存失败'); + showSuccess('电子书源配置已保存', showAlert); await refreshConfig(); } catch (error) { - showError( - error instanceof Error ? error.message : '保存失败', - showAlert - ); + showError(error instanceof Error ? error.message : '保存失败', showAlert); throw error; } }); @@ -12437,38 +12322,69 @@ const OPDSConfigComponent = ({ await withLoading(`testOPDSConfig-${index}`, async () => { try { const source = normalizeSource(sources[index], index); - if (!source?.url) { - throw new Error('请先填写书源地址'); - } + if (!source.url) throw new Error('请先填写书源地址'); const response = await fetch('/api/admin/opds', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - Enabled: true, - CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000), - Sources: [source], - }), + 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 || '测试连接失败'); - } + if (!response.ok || !data.success) throw new Error(data.message || data.error || '测试连接失败'); 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); + showSuccess(result ? `${result.name}: 分类${result.capability.catalogSupported ? '√' : '×'} / 搜索${result.capability.searchSupported ? '√' : '×'}` : '测试成功', showAlert); } catch (error) { - showError( - error instanceof Error ? error.message : '测试连接失败', - showAlert - ); + showError(error instanceof Error ? error.message : '测试连接失败', showAlert); + throw error; + } + }); + }; + + const importLegadoSubscription = async () => { + await withLoading('importLegadoSubscription', async () => { + try { + const response = await fetch('/api/admin/legado-subscriptions/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: legadoSubscriptionName, url: legadoSubscriptionUrl }), + }); + const data = await response.json(); + if (!response.ok || !data.success) throw new Error(data.error || '导入 Legado 订阅失败'); + setLegadoSubscriptionName(''); + setLegadoSubscriptionUrl(''); + showSuccess(`已导入 ${data.subscription?.sourceCount || 0} 个 Legado 书源`, showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '导入 Legado 订阅失败', showAlert); + throw error; + } + }); + }; + + const refreshLegadoSubscription = async (id: string) => { + await withLoading(`refreshLegadoSubscription-${id}`, async () => { + try { + const response = await fetch(`/api/admin/legado-subscriptions/${encodeURIComponent(id)}/refresh`, { method: 'POST' }); + const data = await response.json(); + if (!response.ok || !data.success) throw new Error(data.error || '刷新 Legado 订阅失败'); + showSuccess(`已同步 ${data.subscription?.sourceCount || 0} 个 Legado 书源`, showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '刷新 Legado 订阅失败', showAlert); + throw error; + } + }); + }; + + const deleteLegadoSubscription = async (id: string) => { + await withLoading(`deleteLegadoSubscription-${id}`, async () => { + try { + const response = await fetch(`/api/admin/legado-subscriptions/${encodeURIComponent(id)}`, { method: 'DELETE' }); + const data = await response.json(); + if (!response.ok || !data.success) throw new Error(data.error || '删除 Legado 订阅失败'); + showSuccess('Legado 订阅已删除', showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '删除 Legado 订阅失败', showAlert); throw error; } }); @@ -12476,893 +12392,110 @@ const OPDSConfigComponent = ({ return (
-
-

- 关于电子书馆 / OPDS / Legado -

-
-

• 支持多书源,每个源可选择 OPDS 或 Legado,并独立配置规则。

-

- • 有些源只支持分类浏览,有些源只支持搜索,测试连接会自动探测能力。 -

-

• 目前前台优先支持 EPUB 在线阅读,PDF 走内嵌预览。

+
+

关于电子书馆 / OPDS / Legado

+
+

• OPDS 源手动配置。

+

• Legado 通过订阅 URL 导入,完整书源内容独立存储,不写入 admin_config。

-
+
-

- 启用电子书馆 -

-

- 关闭后不会展示 OPDS 电子书入口。 -

+

启用电子书馆

+

关闭后不会展示电子书入口。

-
- - - setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000) - } - 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' - /> + + setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)} 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' /> +
+ +
+
+
+

Legado 订阅

+

输入订阅 URL 导入,支持大型书源订阅。

+
+ +
+
+ setLegadoSubscriptionName(e.target.value)} placeholder='订阅名称(可选)' className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' /> + setLegadoSubscriptionUrl(e.target.value)} placeholder='https://example.com/bookSource.json' className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' /> +
+
+ {(legadoSubscriptions || []).length === 0 ?
暂无 Legado 订阅。
: (legadoSubscriptions || []).map((sub) => ( +
+
+
+
{sub.name}
+
{sub.url}
+
源数量:{sub.sourceCount || 0} · 上次同步:{sub.lastSuccessAt ? new Date(sub.lastSuccessAt).toLocaleString() : '-'}
+ {sub.lastError ?
{sub.lastError}
: null} +
+
+ + + +
+
+
+ ))} +
-

- 书源列表 -

- +

OPDS 书源列表

+
- -
-
-
-

- 导入 Legado 书源 -

-

- 粘贴阅读/Legado 导出的单个书源 JSON 或书源数组,导入后可逐个测试。 -

-
- -
-