订阅式legado
This commit is contained in:
+153
-1020
@@ -12217,18 +12217,18 @@ const OPDSConfigComponent = ({
|
|||||||
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);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [legadoImportText, setLegadoImportText] = useState('');
|
const [legadoSubscriptionName, setLegadoSubscriptionName] = useState('');
|
||||||
const [legadoRuleDrafts, setLegadoRuleDrafts] = useState<Record<number, string>>({});
|
const [legadoSubscriptionUrl, setLegadoSubscriptionUrl] = useState('');
|
||||||
|
const [legadoSubscriptions, setLegadoSubscriptions] = useState<NonNullable<AdminConfig['OPDSConfig']>['LegadoSubscriptions']>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.OPDSConfig) {
|
if (!config?.OPDSConfig) return;
|
||||||
setEnabled(config.OPDSConfig.Enabled || false);
|
setEnabled(config.OPDSConfig.Enabled || false);
|
||||||
setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000);
|
setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000);
|
||||||
setSources(
|
setSources((config.OPDSConfig.Sources || []).map((item, index) => ({
|
||||||
(config.OPDSConfig.Sources || []).map((item, index) => ({
|
|
||||||
id: item.id || `source_${index + 1}`,
|
id: item.id || `source_${index + 1}`,
|
||||||
name: item.name || `书源 ${index + 1}`,
|
name: item.name || `书源 ${index + 1}`,
|
||||||
type: item.type || 'opds',
|
type: 'opds' as const,
|
||||||
url: item.url || '',
|
url: item.url || '',
|
||||||
enabled: item.enabled !== false,
|
enabled: item.enabled !== false,
|
||||||
authMode: item.authMode || 'none',
|
authMode: item.authMode || 'none',
|
||||||
@@ -12239,195 +12239,80 @@ const OPDSConfigComponent = ({
|
|||||||
searchTemplate: item.searchTemplate || '',
|
searchTemplate: item.searchTemplate || '',
|
||||||
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
||||||
language: item.language || '',
|
language: item.language || '',
|
||||||
legado: item.legado,
|
})));
|
||||||
}))
|
setLegadoSubscriptions(config.OPDSConfig.LegadoSubscriptions || []);
|
||||||
);
|
|
||||||
setEditingIndex(null);
|
setEditingIndex(null);
|
||||||
setLegadoRuleDrafts({});
|
|
||||||
}
|
|
||||||
}, [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) =>
|
setSources((prev) => prev.map((item, idx) => idx === index ? { ...item, ...patch } : item));
|
||||||
prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSource = () => {
|
const addSource = () => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const nextIndex = prev.length;
|
const nextIndex = prev.length;
|
||||||
setEditingIndex(nextIndex);
|
setEditingIndex(nextIndex);
|
||||||
return [
|
return [...prev, {
|
||||||
...prev,
|
id: `source_${nextIndex + 1}`,
|
||||||
{
|
name: `书源 ${nextIndex + 1}`,
|
||||||
id: `source_${prev.length + 1}`,
|
type: 'opds' as const,
|
||||||
name: `书源 ${prev.length + 1}`,
|
|
||||||
type: 'opds',
|
|
||||||
url: '',
|
url: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
authMode: 'none',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
headerName: '',
|
|
||||||
headerValue: '',
|
|
||||||
searchTemplate: '',
|
|
||||||
preferFormat: ['epub', 'pdf'],
|
|
||||||
language: '',
|
|
||||||
legado: undefined,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
authMode: 'none' as const,
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
headerName: '',
|
headerName: '',
|
||||||
headerValue: '',
|
headerValue: '',
|
||||||
searchTemplate: '',
|
searchTemplate: '',
|
||||||
preferFormat: ['epub' as const],
|
preferFormat: ['epub' as const, 'pdf' as const],
|
||||||
language: '',
|
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) => {
|
const removeSource = (index: number) => {
|
||||||
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
||||||
setLegadoRuleDrafts((prev) => {
|
setEditingIndex((prev) => prev === index ? null : prev !== null && prev > index ? prev - 1 : prev);
|
||||||
const next: Record<number, string> = {};
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeSource = (source: BookSource, index: number) => ({
|
const normalizeSource = (source: BookSource, index: number) => ({
|
||||||
id: source.id?.trim() || `source_${index + 1}`,
|
id: source.id?.trim() || `source_${index + 1}`,
|
||||||
name: source.name?.trim() || `书源 ${index + 1}`,
|
name: source.name?.trim() || `书源 ${index + 1}`,
|
||||||
type: source.type || 'opds',
|
type: 'opds' as const,
|
||||||
url: source.url?.trim() || '',
|
url: source.url?.trim() || '',
|
||||||
enabled: source.enabled !== false,
|
enabled: source.enabled !== false,
|
||||||
authMode: source.authMode || 'none',
|
authMode: source.authMode || 'none',
|
||||||
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
|
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
|
||||||
password: source.authMode === 'none' ? '' : source.password || '',
|
password: source.authMode === 'none' ? '' : source.password || '',
|
||||||
headerName:
|
headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
||||||
source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
|
||||||
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
||||||
searchTemplate: source.type === 'legado' ? '' : source.searchTemplate?.trim() || '',
|
searchTemplate: source.searchTemplate?.trim() || '',
|
||||||
preferFormat: source.preferFormat?.length
|
preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'],
|
||||||
? source.preferFormat
|
|
||||||
: ['epub', 'pdf'],
|
|
||||||
language: source.language?.trim() || '',
|
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 = () => ({
|
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.map(normalizeSource).filter((source) => !!source.url),
|
Sources: sources.map(normalizeSource).filter((source) => !!source.url),
|
||||||
|
LegadoSubscriptions: legadoSubscriptions || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await withLoading('saveOPDSConfig', async () => {
|
await withLoading('saveOPDSConfig', async () => {
|
||||||
try {
|
try {
|
||||||
if (!config) throw new Error('配置未加载');
|
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', {
|
const response = await fetch('/api/admin/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ ...config, OPDSConfig: buildConfig() }),
|
||||||
...config,
|
|
||||||
OPDSConfig: buildConfig(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
const data = await response.json().catch(() => ({}));
|
||||||
const data = await response.json();
|
if (!response.ok) throw new Error(data.error || '保存失败');
|
||||||
throw new Error(data.error || '保存失败');
|
showSuccess('电子书源配置已保存', showAlert);
|
||||||
}
|
|
||||||
showSuccess('电子书 OPDS 配置已保存', showAlert);
|
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(
|
showError(error instanceof Error ? error.message : '保存失败', showAlert);
|
||||||
error instanceof Error ? error.message : '保存失败',
|
|
||||||
showAlert
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -12437,38 +12322,69 @@ const OPDSConfigComponent = ({
|
|||||||
await withLoading(`testOPDSConfig-${index}`, async () => {
|
await withLoading(`testOPDSConfig-${index}`, async () => {
|
||||||
try {
|
try {
|
||||||
const source = normalizeSource(sources[index], index);
|
const source = normalizeSource(sources[index], index);
|
||||||
if (!source?.url) {
|
if (!source.url) throw new Error('请先填写书源地址');
|
||||||
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({
|
body: JSON.stringify({ Enabled: true, CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000), Sources: [source] }),
|
||||||
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 result = Array.isArray(data.results) ? data.results[0] : null;
|
const result = Array.isArray(data.results) ? data.results[0] : null;
|
||||||
const summary = result
|
showSuccess(result ? `${result.name}: 分类${result.capability.catalogSupported ? '√' : '×'} / 搜索${result.capability.searchSupported ? '√' : '×'}` : '测试成功', showAlert);
|
||||||
? `${result.name}: 分类${
|
|
||||||
result.capability.catalogSupported ? '√' : '×'
|
|
||||||
} / 搜索${result.capability.searchSupported ? '√' : '×'}${
|
|
||||||
result.capability.lastError
|
|
||||||
? ` (${result.capability.lastError})`
|
|
||||||
: ''
|
|
||||||
}`
|
|
||||||
: data.message || '测试成功';
|
|
||||||
showSuccess(summary, showAlert);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(
|
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
|
||||||
error instanceof Error ? error.message : '测试连接失败',
|
throw error;
|
||||||
showAlert
|
}
|
||||||
);
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -12476,893 +12392,110 @@ const OPDSConfigComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4'>
|
<div className='rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
||||||
<h3 className='text-sm font-medium text-amber-900 dark:text-amber-100 mb-2'>
|
<h3 className='mb-2 text-sm font-medium text-amber-900 dark:text-amber-100'>关于电子书馆 / OPDS / Legado</h3>
|
||||||
关于电子书馆 / OPDS / Legado
|
<div className='space-y-1 text-sm text-amber-800 dark:text-amber-200'>
|
||||||
</h3>
|
<p>• OPDS 源手动配置。</p>
|
||||||
<div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'>
|
<p>• Legado 通过订阅 URL 导入,完整书源内容独立存储,不写入 admin_config。</p>
|
||||||
<p>• 支持多书源,每个源可选择 OPDS 或 Legado,并独立配置规则。</p>
|
|
||||||
<p>
|
|
||||||
• 有些源只支持分类浏览,有些源只支持搜索,测试连接会自动探测能力。
|
|
||||||
</p>
|
|
||||||
<p>• 目前前台优先支持 EPUB 在线阅读,PDF 走内嵌预览。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
|
<div className='flex items-center justify-between border-b border-gray-200 py-3 dark:border-gray-700'>
|
||||||
<div>
|
<div>
|
||||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
|
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>启用电子书馆</h3>
|
||||||
启用电子书馆
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>关闭后不会展示电子书入口。</p>
|
||||||
</h3>
|
|
||||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
|
||||||
关闭后不会展示 OPDS 电子书入口。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => setEnabled(!enabled)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'}`}>
|
||||||
onClick={() => setEnabled(!enabled)}
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Feed 缓存时长(毫秒)</label>
|
||||||
Feed 缓存时长(毫秒)
|
<input type='number' min='60000' value={cacheTTL} onChange={(e) => 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' />
|
||||||
</label>
|
</div>
|
||||||
<input
|
|
||||||
type='number'
|
<div className='rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
||||||
min='60000'
|
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||||
value={cacheTTL}
|
<div>
|
||||||
onChange={(e) =>
|
<h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>Legado 订阅</h4>
|
||||||
setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)
|
<p className='mt-1 text-xs text-amber-800 dark:text-amber-200'>输入订阅 URL 导入,支持大型书源订阅。</p>
|
||||||
}
|
</div>
|
||||||
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'
|
<button type='button' onClick={importLegadoSubscription} disabled={!legadoSubscriptionUrl.trim() || isLoading('importLegadoSubscription')} className={buttonStyles.primarySmall}>{isLoading('importLegadoSubscription') ? '导入中...' : '导入订阅'}</button>
|
||||||
/>
|
</div>
|
||||||
|
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||||
|
<input type='text' value={legadoSubscriptionName} onChange={(e) => 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' />
|
||||||
|
<input type='text' value={legadoSubscriptionUrl} onChange={(e) => 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' />
|
||||||
|
</div>
|
||||||
|
<div className='mt-4 space-y-2'>
|
||||||
|
{(legadoSubscriptions || []).length === 0 ? <div className='text-xs text-amber-800 dark:text-amber-200'>暂无 Legado 订阅。</div> : (legadoSubscriptions || []).map((sub) => (
|
||||||
|
<div key={sub.id} className='rounded-lg border border-amber-200 bg-white p-3 text-sm dark:border-amber-800 dark:bg-gray-900'>
|
||||||
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='font-medium text-gray-900 dark:text-gray-100'>{sub.name}</div>
|
||||||
|
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>{sub.url}</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>源数量:{sub.sourceCount || 0} · 上次同步:{sub.lastSuccessAt ? new Date(sub.lastSuccessAt).toLocaleString() : '-'}</div>
|
||||||
|
{sub.lastError ? <div className='mt-1 text-xs text-red-500'>{sub.lastError}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<button type='button' onClick={() => setLegadoSubscriptions((prev) => (prev || []).map((item) => item.id === sub.id ? { ...item, enabled: item.enabled === false } : item))} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${sub.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 ${sub.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} /></button>
|
||||||
|
<button type='button' onClick={() => refreshLegadoSubscription(sub.id)} disabled={isLoading(`refreshLegadoSubscription-${sub.id}`)} className={buttonStyles.secondarySmall}>{isLoading(`refreshLegadoSubscription-${sub.id}`) ? '同步中...' : '同步'}</button>
|
||||||
|
<button type='button' onClick={() => deleteLegadoSubscription(sub.id)} disabled={isLoading(`deleteLegadoSubscription-${sub.id}`)} className={buttonStyles.dangerSmall}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
|
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>OPDS 书源列表</h3>
|
||||||
书源列表
|
<button type='button' onClick={addSource} className={buttonStyles.primary}><Plus size={16} className='mr-1 inline' />添加 OPDS</button>
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={addSource}
|
|
||||||
className={buttonStyles.primary}
|
|
||||||
>
|
|
||||||
<Plus size={16} className='inline mr-1' />
|
|
||||||
添加书源
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{sources.length === 0 ? <div className='rounded-lg border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-gray-600 dark:text-gray-400'>暂无 OPDS 书源。</div> : null}
|
||||||
<div className='rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
<div className='space-y-3'>
|
||||||
<div className='mb-2 flex items-center justify-between gap-3'>
|
|
||||||
<div>
|
|
||||||
<h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>
|
|
||||||
导入 Legado 书源
|
|
||||||
</h4>
|
|
||||||
<p className='mt-1 text-xs text-amber-800 dark:text-amber-200'>
|
|
||||||
粘贴阅读/Legado 导出的单个书源 JSON 或书源数组,导入后可逐个测试。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={importLegadoSources}
|
|
||||||
disabled={!legadoImportText.trim()}
|
|
||||||
className={buttonStyles.primarySmall}
|
|
||||||
>
|
|
||||||
导入 Legado
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={legadoImportText}
|
|
||||||
onChange={(e) => setLegadoImportText(e.target.value)}
|
|
||||||
placeholder='[{ "bookSourceName": "...", "bookSourceUrl": "...", "searchUrl": "...", "ruleSearch": { ... } }]'
|
|
||||||
rows={5}
|
|
||||||
className='w-full rounded-lg border border-amber-200 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sources.length === 0 && (
|
|
||||||
<div className='rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-500 dark:text-gray-400'>
|
|
||||||
暂无 OPDS 书源,点击“添加书源”开始配置。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sources.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className='space-y-3 md:hidden'>
|
|
||||||
{sources.map((source, index) => {
|
{sources.map((source, index) => {
|
||||||
const isEditing = editingIndex === index;
|
const isEditing = editingIndex === index;
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={`opds-source-${index}`} className='rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900'>
|
||||||
key={`opds-source-${index}`}
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||||
className='overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'
|
|
||||||
>
|
|
||||||
<div className='space-y-3 p-4'>
|
|
||||||
<div className='flex items-start justify-between gap-3'>
|
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100'>
|
<div className='font-medium text-gray-900 dark:text-gray-100'>{source.name || `书源 ${index + 1}`}</div>
|
||||||
{source.name || `书源 ${index + 1}`}
|
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>{source.url || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<div className='flex items-center gap-2'>
|
||||||
{source.id || '未设置 ID'}
|
<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>
|
||||||
|
<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 ? '收起' : '编辑'}</button>
|
||||||
|
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}>删除</button>
|
||||||
</div>
|
</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'>
|
|
||||||
{source.type === 'legado' ? 'Legado' : 'OPDS'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<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.type === 'legado'
|
|
||||||
? source.legado?.searchUrl
|
|
||||||
? '已配置'
|
|
||||||
: '未配置'
|
|
||||||
: 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 ? (
|
{isEditing ? (
|
||||||
<>
|
<div className='mt-4 grid grid-cols-1 gap-4 border-t border-gray-200 pt-4 dark:border-gray-700 md:grid-cols-2'>
|
||||||
<ChevronUp size={14} className='inline mr-1' />
|
<input type='text' value={source.id} onChange={(e) => updateSource(index, { id: e.target.value })} placeholder='书源 ID' className='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' />
|
||||||
收起
|
<input type='text' value={source.name} onChange={(e) => updateSource(index, { name: e.target.value })} placeholder='书源名称' className='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' />
|
||||||
</>
|
<input type='text' value={source.url} onChange={(e) => updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='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 md:col-span-2' />
|
||||||
) : (
|
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} className='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>
|
||||||
<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'>
|
|
||||||
书源类型
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={source.type || 'opds'}
|
|
||||||
onChange={(e) => {
|
|
||||||
const nextType = e.target.value as BookSource['type'];
|
|
||||||
updateSource(index, {
|
|
||||||
type: nextType,
|
|
||||||
legado: nextType === 'legado'
|
|
||||||
? source.legado || { bookSourceName: source.name, bookSourceUrl: source.url }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
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='opds'>OPDS</option>
|
|
||||||
<option value='legado'>Legado</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
<input type='text' value={source.language || ''} onChange={(e) => updateSource(index, { language: e.target.value })} placeholder='语言 zh / en' className='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' />
|
||||||
|
<input type='text' value={source.searchTemplate || ''} onChange={(e) => updateSource(index, { searchTemplate: e.target.value })} placeholder='搜索模板 https://...{searchTerms}' className='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 md:col-span-2' />
|
||||||
|
{source.authMode === 'basic' ? <><input type='text' value={source.username || ''} onChange={(e) => updateSource(index, { username: e.target.value })} placeholder='用户名' className='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' /><input type='password' value={source.password || ''} onChange={(e) => updateSource(index, { password: e.target.value })} placeholder='密码' className='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' /></> : null}
|
||||||
|
{source.authMode === 'header' ? <><input type='text' value={source.headerName || ''} onChange={(e) => updateSource(index, { headerName: e.target.value })} placeholder='Header 名称' className='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' /><input type='password' value={source.headerValue || ''} onChange={(e) => updateSource(index, { headerValue: e.target.value })} placeholder='Header 值' className='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' /></> : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : null}
|
||||||
<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) => {
|
|
||||||
const url = e.target.value;
|
|
||||||
updateSource(index, {
|
|
||||||
url,
|
|
||||||
legado: source.type === 'legado' ? { ...(source.legado || {}), bookSourceUrl: url } : source.legado,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder={source.type === 'legado' ? 'https://example.com' : '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>
|
|
||||||
|
|
||||||
{source.type === 'legado' ? (
|
|
||||||
<div>
|
|
||||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
|
||||||
Legado 原始 JSON
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={legadoRuleDrafts[index] ?? JSON.stringify(source.legado || {}, null, 2)}
|
|
||||||
onChange={(e) => updateLegadoRuleJson(index, e.target.value)}
|
|
||||||
rows={10}
|
|
||||||
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
|
||||||
/>
|
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
修改后请保持合法 JSON;测试会检查搜索/章节规则是否存在。
|
|
||||||
</p>
|
|
||||||
</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>
|
||||||
|
|
||||||
<div className='hidden overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 md:block'>
|
|
||||||
<div className='overflow-x-auto'>
|
|
||||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
|
||||||
<thead className='bg-gray-50 dark:bg-gray-800/70'>
|
|
||||||
<tr>
|
|
||||||
<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'>
|
|
||||||
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.type === 'legado' ? 'Legado' : 'OPDS'} · {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.type === 'legado'
|
|
||||||
? source.legado?.searchUrl
|
|
||||||
? '已配置'
|
|
||||||
: '未配置'
|
|
||||||
: 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'>
|
|
||||||
书源类型
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={source.type || 'opds'}
|
|
||||||
onChange={(e) => {
|
|
||||||
const nextType = e.target.value as BookSource['type'];
|
|
||||||
updateSource(index, {
|
|
||||||
type: nextType,
|
|
||||||
legado: nextType === 'legado'
|
|
||||||
? source.legado || { bookSourceName: source.name, bookSourceUrl: source.url }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
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='opds'>OPDS</option>
|
|
||||||
<option value='legado'>Legado</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<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) => {
|
|
||||||
const url = e.target.value;
|
|
||||||
updateSource(index, {
|
|
||||||
url,
|
|
||||||
legado: source.type === 'legado' ? { ...(source.legado || {}), bookSourceUrl: url } : source.legado,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder={source.type === 'legado' ? 'https://example.com' : '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>
|
|
||||||
|
|
||||||
{source.type === 'legado' ? (
|
|
||||||
<div>
|
|
||||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
|
||||||
Legado 原始 JSON
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={legadoRuleDrafts[index] ?? JSON.stringify(source.legado || {}, null, 2)}
|
|
||||||
onChange={(e) => updateLegadoRuleJson(index, e.target.value)}
|
|
||||||
rows={12}
|
|
||||||
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-xs 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 className='flex gap-3'>
|
<div className='flex gap-3'>
|
||||||
<button
|
<button onClick={handleSave} disabled={isLoading('saveOPDSConfig')} className={buttonStyles.success}>{isLoading('saveOPDSConfig') ? '保存中...' : '保存电子书源配置'}</button>
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isLoading('saveOPDSConfig')}
|
|
||||||
className={buttonStyles.success}
|
|
||||||
>
|
|
||||||
{isLoading('saveOPDSConfig') ? '保存中...' : '保存电子书源配置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertModal
|
<AlertModal isOpen={alertModal.isOpen} onClose={hideAlert} type={alertModal.type} title={alertModal.title} message={alertModal.message} timer={alertModal.timer} showConfirm={alertModal.showConfirm} />
|
||||||
isOpen={alertModal.isOpen}
|
|
||||||
onClose={hideAlert}
|
|
||||||
type={alertModal.type}
|
|
||||||
title={alertModal.title}
|
|
||||||
message={alertModal.message}
|
|
||||||
timer={alertModal.timer}
|
|
||||||
showConfirm={alertModal.showConfirm}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { legadoSubscriptionStore } from '@/lib/legado/subscription-store';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function ensureAdmin(request: NextRequest) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo?.username) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfo = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner') || userInfo.banned) return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return authInfo.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const ensured = await ensureAdmin(request);
|
||||||
|
if (ensured instanceof NextResponse) return ensured;
|
||||||
|
const { id } = await context.params;
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
const opds = config.OPDSConfig || { Enabled: false, Sources: [], LegadoSubscriptions: [], CacheTTL: 10 * 60 * 1000 };
|
||||||
|
const current = (opds.LegadoSubscriptions || []).find((item) => item.id === id);
|
||||||
|
if (!current) return NextResponse.json({ success: false, error: '订阅不存在' }, { status: 404 });
|
||||||
|
const meta = await legadoSubscriptionStore.sync({ id: current.id, name: current.name, url: current.url });
|
||||||
|
const nextConfig = legadoSubscriptionStore.mergeMeta(config, { ...current, ...meta, enabled: current.enabled !== false });
|
||||||
|
await db.saveAdminConfig(nextConfig);
|
||||||
|
await setCachedConfig(nextConfig);
|
||||||
|
return NextResponse.json({ success: true, subscription: { ...current, ...meta, enabled: current.enabled !== false } });
|
||||||
|
} catch (error) {
|
||||||
|
const config = await getConfig();
|
||||||
|
const opds = config.OPDSConfig || { Enabled: false, Sources: [], LegadoSubscriptions: [], CacheTTL: 10 * 60 * 1000 };
|
||||||
|
const now = Date.now();
|
||||||
|
const message = error instanceof Error ? error.message : '刷新 Legado 订阅失败';
|
||||||
|
const nextSubscriptions = (opds.LegadoSubscriptions || []).map((item) => item.id === id ? { ...item, lastSyncAt: now, lastError: message } : item);
|
||||||
|
const nextConfig = { ...config, OPDSConfig: { ...opds, LegadoSubscriptions: nextSubscriptions } };
|
||||||
|
await db.saveAdminConfig(nextConfig);
|
||||||
|
await setCachedConfig(nextConfig);
|
||||||
|
return NextResponse.json({ success: false, error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { legadoSubscriptionStore } from '@/lib/legado/subscription-store';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function ensureAdmin(request: NextRequest) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo?.username) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfo = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner') || userInfo.banned) return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return authInfo.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const ensured = await ensureAdmin(request);
|
||||||
|
if (ensured instanceof NextResponse) return ensured;
|
||||||
|
const { id } = await context.params;
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
const opds = config.OPDSConfig || { Enabled: false, Sources: [], LegadoSubscriptions: [], CacheTTL: 10 * 60 * 1000 };
|
||||||
|
const nextConfig = { ...config, OPDSConfig: { ...opds, LegadoSubscriptions: (opds.LegadoSubscriptions || []).filter((item) => item.id !== id) } };
|
||||||
|
await legadoSubscriptionStore.delete(id);
|
||||||
|
await db.saveAdminConfig(nextConfig);
|
||||||
|
await setCachedConfig(nextConfig);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : '删除 Legado 订阅失败' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { legadoSubscriptionStore } from '@/lib/legado/subscription-store';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function ensureAdmin(request: NextRequest) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo?.username) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfo = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner') || userInfo.banned) return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return authInfo.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const ensured = await ensureAdmin(request);
|
||||||
|
if (ensured instanceof NextResponse) return ensured;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const url = String(body?.url || '').trim();
|
||||||
|
const name = String(body?.name || '').trim() || 'Legado 订阅';
|
||||||
|
const meta = await legadoSubscriptionStore.sync({ name, url });
|
||||||
|
const config = await getConfig();
|
||||||
|
const nextConfig = legadoSubscriptionStore.mergeMeta(config, meta);
|
||||||
|
await db.saveAdminConfig(nextConfig);
|
||||||
|
await setCachedConfig(nextConfig);
|
||||||
|
return NextResponse.json({ success: true, subscription: meta });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : '导入 Legado 订阅失败' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-2
@@ -287,7 +287,7 @@ export interface AdminConfig {
|
|||||||
Sources?: Array<{
|
Sources?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type?: 'opds' | 'legado';
|
type?: 'opds';
|
||||||
url: string;
|
url: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
authMode?: 'none' | 'basic' | 'header';
|
authMode?: 'none' | 'basic' | 'header';
|
||||||
@@ -298,7 +298,16 @@ export interface AdminConfig {
|
|||||||
searchTemplate?: string;
|
searchTemplate?: string;
|
||||||
preferFormat?: Array<'epub' | 'pdf'>;
|
preferFormat?: Array<'epub' | 'pdf'>;
|
||||||
language?: string;
|
language?: string;
|
||||||
legado?: import('./book.types').LegadoBookSourceRule;
|
}>;
|
||||||
|
LegadoSubscriptions?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
sourceCount?: number;
|
||||||
|
lastSyncAt?: number;
|
||||||
|
lastSuccessAt?: number;
|
||||||
|
lastError?: string;
|
||||||
}>;
|
}>;
|
||||||
CacheTTL?: number;
|
CacheTTL?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -715,6 +715,13 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
|
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
|
||||||
adminConfig.OPDSConfig.Sources = [];
|
adminConfig.OPDSConfig.Sources = [];
|
||||||
}
|
}
|
||||||
|
adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter((source: any) => (source?.type || 'opds') === 'opds').map((source: any) => {
|
||||||
|
const { legado: _legado, ...rest } = source || {};
|
||||||
|
return { ...rest, type: 'opds' };
|
||||||
|
});
|
||||||
|
if (!Array.isArray(adminConfig.OPDSConfig.LegadoSubscriptions)) {
|
||||||
|
adminConfig.OPDSConfig.LegadoSubscriptions = [];
|
||||||
|
}
|
||||||
if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) {
|
if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) {
|
||||||
adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
|
adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
LegadoBookSourceRule,
|
LegadoBookSourceRule,
|
||||||
} from './book.types';
|
} from './book.types';
|
||||||
import { validateProxyUrlServerSide } from './server/ssrf';
|
import { validateProxyUrlServerSide } from './server/ssrf';
|
||||||
|
import { legadoSubscriptionStore } from './legado/subscription-store';
|
||||||
|
|
||||||
interface ResolvedLegadoConfig {
|
interface ResolvedLegadoConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -316,11 +317,8 @@ async function resolveLegadoConfig(): Promise<ResolvedLegadoConfig> {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
if (config.OPDSConfig) {
|
if (config.OPDSConfig) {
|
||||||
enabled = config.OPDSConfig.Enabled ?? enabled;
|
enabled = config.OPDSConfig.Enabled ?? enabled;
|
||||||
if (Array.isArray(config.OPDSConfig.Sources)) {
|
const subscriptionSources = await legadoSubscriptionStore.getSourcesForSubscriptions(config.OPDSConfig.LegadoSubscriptions || []);
|
||||||
sources = (config.OPDSConfig.Sources as BookSource[])
|
sources = [...sources, ...subscriptionSources];
|
||||||
.map((source, index) => normalizeConfiguredLegadoSource(source, index))
|
|
||||||
.filter((source): source is BookSource => !!source);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
@@ -378,8 +376,9 @@ function wait(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchText(source: BookSource, url: string): Promise<string> {
|
async function fetchText(source: BookSource, url: string): Promise<string> {
|
||||||
|
if (!url?.trim()) throw new Error('书源请求地址为空');
|
||||||
const safe = await validateProxyUrlServerSide(url);
|
const safe = await validateProxyUrlServerSide(url);
|
||||||
if (!safe) throw new Error('书源地址未通过安全校验');
|
if (!safe) throw new Error(`书源地址未通过安全校验: ${url}`);
|
||||||
const cacheKey = `${LEGADO_CACHE_VERSION}|text|${source.id}|${url}`;
|
const cacheKey = `${LEGADO_CACHE_VERSION}|text|${source.id}|${url}`;
|
||||||
const cached = textCache.get(cacheKey);
|
const cached = textCache.get(cacheKey);
|
||||||
const { cacheTTL } = await resolveLegadoConfig();
|
const { cacheTTL } = await resolveLegadoConfig();
|
||||||
@@ -508,6 +507,7 @@ export class LegadoClient {
|
|||||||
if (dedupeKey && seen.has(dedupeKey)) return;
|
if (dedupeKey && seen.has(dedupeKey)) return;
|
||||||
if (dedupeKey) seen.add(dedupeKey);
|
if (dedupeKey) seen.add(dedupeKey);
|
||||||
results.push(makeItem(source, {
|
results.push(makeItem(source, {
|
||||||
|
id: detailHref || undefined,
|
||||||
title,
|
title,
|
||||||
author: readValue($, root, rule.ruleSearch?.author, targetUrl),
|
author: readValue($, root, rule.ruleSearch?.author, targetUrl),
|
||||||
summary: readValue($, root, rule.ruleSearch?.intro, targetUrl),
|
summary: readValue($, root, rule.ruleSearch?.intro, targetUrl),
|
||||||
@@ -554,13 +554,16 @@ export class LegadoClient {
|
|||||||
const source = await getSourceById(sourceId);
|
const source = await getSourceById(sourceId);
|
||||||
const rule = getRule(source);
|
const rule = getRule(source);
|
||||||
const base = sourceBase(source);
|
const base = sourceBase(source);
|
||||||
const detailHref = rule.ruleSearch?.bookUrl
|
const searchBookUrlRule = rule.ruleSearch?.bookUrl || '';
|
||||||
? normalizeUrl(base, rule.ruleSearch.bookUrl
|
const detailHref = /^https?:\/\//i.test(bookId) || bookId.startsWith('/')
|
||||||
|
? normalizeUrl(base, bookId)
|
||||||
|
: /\{\{\s*(?:\$\.id|id)\s*\}\}|\{id\}/.test(searchBookUrlRule)
|
||||||
|
? normalizeUrl(base, searchBookUrlRule
|
||||||
.replace(/\{\{\s*\$\.id\s*\}\}/g, encodeURIComponent(bookId))
|
.replace(/\{\{\s*\$\.id\s*\}\}/g, encodeURIComponent(bookId))
|
||||||
.replace(/\{\{\s*id\s*\}\}/g, encodeURIComponent(bookId))
|
.replace(/\{\{\s*id\s*\}\}/g, encodeURIComponent(bookId))
|
||||||
.replace(/\{id\}/g, encodeURIComponent(bookId)))
|
.replace(/\{id\}/g, encodeURIComponent(bookId)))
|
||||||
: '';
|
: '';
|
||||||
if (!detailHref) throw new Error('该 Legado 书源无法通过 bookId 定位详情');
|
if (!detailHref) throw new Error('该 Legado 书源无法通过 bookId 定位详情,请重新搜索后打开');
|
||||||
const detail = await this.getBookDetail(sourceId, detailHref, { id: bookId, detailHref });
|
const detail = await this.getBookDetail(sourceId, detailHref, { id: bookId, detailHref });
|
||||||
const tocHref = detail.acquisitionLinks.find((item) => item.rel === 'legado:chapters' || item.type.toLowerCase().includes('legado-chapters'))?.href;
|
const tocHref = detail.acquisitionLinks.find((item) => item.rel === 'legado:chapters' || item.type.toLowerCase().includes('legado-chapters'))?.href;
|
||||||
if (!tocHref) return [];
|
if (!tocHref) return [];
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import type { AdminConfig } from '@/lib/admin.types';
|
||||||
|
import type { BookSource, LegadoBookSourceRule } from '@/lib/book.types';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||||
|
|
||||||
|
export interface LegadoSubscriptionMeta {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
sourceCount?: number;
|
||||||
|
lastSyncAt?: number;
|
||||||
|
lastSuccessAt?: number;
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredManifest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
hash: string;
|
||||||
|
sourceCount: number;
|
||||||
|
chunkCount: number;
|
||||||
|
updatedAt: number;
|
||||||
|
etag?: string;
|
||||||
|
lastModified?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE = Number(process.env.LEGADO_SUBSCRIPTION_CHUNK_SIZE || 100);
|
||||||
|
const TIMEOUT_MS = Number(process.env.LEGADO_SUBSCRIPTION_TIMEOUT_MS || process.env.LEGADO_TIMEOUT_MS || 30000);
|
||||||
|
const MAX_BYTES = Number(process.env.LEGADO_SUBSCRIPTION_MAX_BYTES || 20 * 1024 * 1024);
|
||||||
|
|
||||||
|
function stableId(input: string) {
|
||||||
|
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionId(url: string, name?: string) {
|
||||||
|
return `legado_sub_${stableId(`${name || ''}|${url}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestKey(id: string) {
|
||||||
|
return `legado:subscription:${id}:manifest`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkKey(id: string, index: number) {
|
||||||
|
return `legado:subscription:${id}:chunk:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTextWithRetry(url: string, retries = 2): Promise<{ text: string; etag?: string; lastModified?: string }> {
|
||||||
|
const safe = await validateProxyUrlServerSide(url);
|
||||||
|
if (!safe) throw new Error('订阅地址未通过安全校验');
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||||
|
Accept: 'application/json,text/plain,*/*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`订阅请求失败: ${response.status}`);
|
||||||
|
const contentLength = Number(response.headers.get('content-length') || '0');
|
||||||
|
if (contentLength > MAX_BYTES) throw new Error('订阅内容过大');
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.length > MAX_BYTES) throw new Error('订阅内容过大');
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
etag: response.headers.get('etag') || undefined,
|
||||||
|
lastModified: response.headers.get('last-modified') || undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt < retries) await wait(300 * (attempt + 1));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error('订阅请求失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRuleList(input: any): LegadoBookSourceRule[] {
|
||||||
|
if (Array.isArray(input)) return input.filter((item) => item && typeof item === 'object');
|
||||||
|
if (!input || typeof input !== 'object') return [];
|
||||||
|
for (const key of ['data', 'sources', 'bookSources', 'items', 'list']) {
|
||||||
|
if (Array.isArray(input[key])) return input[key].filter((item: any) => item && typeof item === 'object');
|
||||||
|
}
|
||||||
|
return [input];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRule(rule: LegadoBookSourceRule, subId: string, index: number): BookSource | null {
|
||||||
|
const name = rule.bookSourceName || `Legado 书源 ${index + 1}`;
|
||||||
|
const url = rule.bookSourceUrl || '';
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
id: `legado_${stableId(`${subId}|${name}|${url}|${index}`)}`,
|
||||||
|
name,
|
||||||
|
type: 'legado',
|
||||||
|
url,
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
|
authMode: 'none',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
headerName: '',
|
||||||
|
headerValue: '',
|
||||||
|
searchTemplate: '',
|
||||||
|
preferFormat: ['epub'],
|
||||||
|
language: '',
|
||||||
|
legado: rule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManifest(id: string): Promise<StoredManifest | null> {
|
||||||
|
const raw = await db.getGlobalValue(manifestKey(id));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as StoredManifest;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const legadoSubscriptionStore = {
|
||||||
|
makeId: subscriptionId,
|
||||||
|
|
||||||
|
async sync(input: { id?: string; name?: string; url: string }): Promise<LegadoSubscriptionMeta> {
|
||||||
|
const url = input.url.trim();
|
||||||
|
if (!url) throw new Error('订阅 URL 不能为空');
|
||||||
|
const id = input.id || subscriptionId(url, input.name);
|
||||||
|
const name = input.name?.trim() || 'Legado 订阅';
|
||||||
|
const previous = await readManifest(id);
|
||||||
|
const { text, etag, lastModified } = await fetchTextWithRetry(url);
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error('订阅内容不是合法 JSON');
|
||||||
|
}
|
||||||
|
const rules = extractRuleList(parsed);
|
||||||
|
const sources = rules.map((rule, index) => normalizeRule(rule, id, index)).filter((item): item is BookSource => !!item);
|
||||||
|
if (sources.length === 0) throw new Error('订阅内没有识别到有效 Legado 书源');
|
||||||
|
|
||||||
|
const chunkCount = Math.ceil(sources.length / CHUNK_SIZE);
|
||||||
|
const hash = crypto.createHash('sha1').update(JSON.stringify(sources)).digest('hex');
|
||||||
|
for (let index = 0; index < chunkCount; index += 1) {
|
||||||
|
await db.setGlobalValue(chunkKey(id, index), JSON.stringify(sources.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)));
|
||||||
|
}
|
||||||
|
if (previous && previous.chunkCount > chunkCount) {
|
||||||
|
for (let index = chunkCount; index < previous.chunkCount; index += 1) {
|
||||||
|
await db.deleteGlobalValue(chunkKey(id, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manifest: StoredManifest = { id, name, url, hash, sourceCount: sources.length, chunkCount, updatedAt: Date.now(), etag, lastModified };
|
||||||
|
await db.setGlobalValue(manifestKey(id), JSON.stringify(manifest));
|
||||||
|
return { id, name, url, enabled: true, sourceCount: sources.length, lastSyncAt: manifest.updatedAt, lastSuccessAt: manifest.updatedAt, lastError: '' };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSources(id: string): Promise<BookSource[]> {
|
||||||
|
const manifest = await readManifest(id);
|
||||||
|
if (!manifest) return [];
|
||||||
|
const chunks = await Promise.all(
|
||||||
|
Array.from({ length: manifest.chunkCount }, async (_, index) => {
|
||||||
|
const raw = await db.getGlobalValue(chunkKey(id, index));
|
||||||
|
if (!raw) return [] as BookSource[];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as BookSource[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [] as BookSource[];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return chunks.flat();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSourcesForSubscriptions(subscriptions: LegadoSubscriptionMeta[] = []): Promise<BookSource[]> {
|
||||||
|
const enabled = subscriptions.filter((item) => item.enabled !== false);
|
||||||
|
const groups = await Promise.all(enabled.map((item) => this.getSources(item.id)));
|
||||||
|
return groups.flat().filter((source) => source.enabled !== false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const manifest = await readManifest(id);
|
||||||
|
if (manifest) {
|
||||||
|
for (let index = 0; index < manifest.chunkCount; index += 1) {
|
||||||
|
await db.deleteGlobalValue(chunkKey(id, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.deleteGlobalValue(manifestKey(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeMeta(config: AdminConfig, meta: LegadoSubscriptionMeta): AdminConfig {
|
||||||
|
const opds = config.OPDSConfig || { Enabled: false, Sources: [], CacheTTL: 10 * 60 * 1000 };
|
||||||
|
const list = opds.LegadoSubscriptions || [];
|
||||||
|
const next = list.some((item) => item.id === meta.id)
|
||||||
|
? list.map((item) => item.id === meta.id ? { ...item, ...meta } : item)
|
||||||
|
: [...list, meta];
|
||||||
|
return { ...config, OPDSConfig: { ...opds, LegadoSubscriptions: next } };
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user