增加漫画展馆功能
This commit is contained in:
@@ -66,3 +66,4 @@ public/workbox-*.js.map
|
||||
|
||||
# local scripts
|
||||
scripts/tvbox/
|
||||
scripts/test
|
||||
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE IF NOT EXISTS manga_shelf (
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
manga_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
save_time INTEGER NOT NULL,
|
||||
description TEXT,
|
||||
author TEXT,
|
||||
status TEXT,
|
||||
last_chapter_id TEXT,
|
||||
last_chapter_name TEXT,
|
||||
PRIMARY KEY (username, key),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manga_shelf_user_time ON manga_shelf(username, save_time DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manga_read_records (
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
manga_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
chapter_id TEXT NOT NULL,
|
||||
chapter_name TEXT NOT NULL,
|
||||
page_index INTEGER NOT NULL,
|
||||
page_count INTEGER NOT NULL,
|
||||
save_time INTEGER NOT NULL,
|
||||
PRIMARY KEY (username, key),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manga_read_records_user_time ON manga_read_records(username, save_time DESC);
|
||||
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE IF NOT EXISTS manga_shelf (
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
manga_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
save_time BIGINT NOT NULL,
|
||||
description TEXT,
|
||||
author TEXT,
|
||||
status TEXT,
|
||||
last_chapter_id TEXT,
|
||||
last_chapter_name TEXT,
|
||||
PRIMARY KEY (username, key),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manga_shelf_user_time ON manga_shelf(username, save_time DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manga_read_records (
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
manga_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
chapter_id TEXT NOT NULL,
|
||||
chapter_name TEXT NOT NULL,
|
||||
page_index INTEGER NOT NULL,
|
||||
page_count INTEGER NOT NULL,
|
||||
save_time BIGINT NOT NULL,
|
||||
PRIMARY KEY (username, key),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manga_read_records_user_time ON manga_read_records(username, save_time DESC);
|
||||
+189
-1
@@ -24,6 +24,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Cat,
|
||||
Check,
|
||||
@@ -10114,6 +10115,181 @@ const CustomAdFilterConfig = ({
|
||||
};
|
||||
|
||||
// 小雅配置组件
|
||||
|
||||
const SuwayomiConfigComponent = ({
|
||||
config,
|
||||
refreshConfig,
|
||||
}: {
|
||||
config: AdminConfig | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
}) => {
|
||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||
const { isLoading, withLoading } = useLoadingState();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [serverURL, setServerURL] = useState('');
|
||||
const [authToken, setAuthToken] = useState('');
|
||||
const [defaultLang, setDefaultLang] = useState('zh');
|
||||
const [sourceIds, setSourceIds] = useState('');
|
||||
const [maxSources, setMaxSources] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.SuwayomiConfig) {
|
||||
setEnabled(config.SuwayomiConfig.Enabled || false);
|
||||
setServerURL(config.SuwayomiConfig.ServerURL || '');
|
||||
setAuthToken(config.SuwayomiConfig.AuthToken || '');
|
||||
setDefaultLang(config.SuwayomiConfig.DefaultLang || 'zh');
|
||||
setSourceIds((config.SuwayomiConfig.SourceIds || []).join(','));
|
||||
setMaxSources(config.SuwayomiConfig.MaxSources || 10);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const buildConfig = () => ({
|
||||
Enabled: enabled,
|
||||
ServerURL: serverURL,
|
||||
AuthToken: authToken,
|
||||
DefaultLang: defaultLang || 'zh',
|
||||
SourceIds: sourceIds.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
MaxSources: Math.max(1, maxSources || 10),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
await withLoading('saveSuwayomi', async () => {
|
||||
try {
|
||||
if (!config) throw new Error('配置未加载');
|
||||
|
||||
const response = await fetch('/api/admin/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...config,
|
||||
SuwayomiConfig: buildConfig(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || '保存失败');
|
||||
}
|
||||
|
||||
showSuccess('漫画后端配置已保存', showAlert);
|
||||
await refreshConfig();
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '保存失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||
<h3 className='text-sm font-medium text-blue-900 dark:text-blue-100 mb-2'>
|
||||
关于漫画展馆 / Suwayomi
|
||||
</h3>
|
||||
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
|
||||
<p>• 漫画展馆通过 Suwayomi Server 的 GraphQL 接口搜索、拉取章节与阅读页。</p>
|
||||
<p>• 可限制默认语言、可用源白名单,以及单次搜索最多查询的源数量。</p>
|
||||
<p>• 保存后漫画模块会优先使用这里的配置,环境变量只作为兜底。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>启用漫画展馆</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>关闭后仍保留代码,但不建议在未配置时对用户开放入口。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-blue-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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Suwayomi 服务地址</label>
|
||||
<input
|
||||
type='text'
|
||||
value={serverURL}
|
||||
onChange={(e) => setServerURL(e.target.value)}
|
||||
placeholder='http://127.0.0.1:4567'
|
||||
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'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>只填服务根地址,程序会自动拼接 /api/graphql。</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>认证 Token</label>
|
||||
<input
|
||||
type='password'
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
placeholder='可选,若 Suwayomi 开启认证请填写'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>默认语言</label>
|
||||
<input
|
||||
type='text'
|
||||
value={defaultLang}
|
||||
onChange={(e) => setDefaultLang(e.target.value)}
|
||||
placeholder='zh'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>单次搜索最大源数</label>
|
||||
<input
|
||||
type='number'
|
||||
min='1'
|
||||
value={maxSources}
|
||||
onChange={(e) => setMaxSources(parseInt(e.target.value) || 10)}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>源白名单</label>
|
||||
<textarea
|
||||
value={sourceIds}
|
||||
onChange={(e) => setSourceIds(e.target.value)}
|
||||
rows={3}
|
||||
placeholder='留空表示使用默认语言下全部源;填写时用英文逗号分隔 sourceId'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveSuwayomi')}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
{isLoading('saveSuwayomi') ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
type={alertModal.type}
|
||||
title={alertModal.title}
|
||||
message={alertModal.message}
|
||||
timer={alertModal.timer}
|
||||
showConfirm={alertModal.showConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const XiaoyaConfigComponent = ({
|
||||
config,
|
||||
refreshConfig,
|
||||
@@ -12822,6 +12998,7 @@ function AdminPageClient() {
|
||||
netDiskConfig: false,
|
||||
embyConfig: false,
|
||||
xiaoyaConfig: false,
|
||||
suwayomiConfig: false,
|
||||
animeSubscription: false,
|
||||
aiConfig: false,
|
||||
liveSource: false,
|
||||
@@ -13213,6 +13390,18 @@ function AdminPageClient() {
|
||||
<MusicConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
|
||||
|
||||
<CollapsibleTab
|
||||
title='漫画配置'
|
||||
icon={
|
||||
<BookOpen size={20} className='text-gray-600 dark:text-gray-400' />
|
||||
}
|
||||
isExpanded={expandedTabs.suwayomiConfig}
|
||||
onToggle={() => toggleTab('suwayomiConfig')}
|
||||
>
|
||||
<SuwayomiConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* 电视直播源配置标签 */}
|
||||
<CollapsibleTab
|
||||
title='电视直播源配置'
|
||||
@@ -13283,7 +13472,6 @@ function AdminPageClient() {
|
||||
>
|
||||
<XiaoyaConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* 求片管理子标签 */}
|
||||
<CollapsibleTab
|
||||
title='求片管理'
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function getAuthorizedUsername(request: NextRequest): Promise<string | NextResponse> {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
return authInfo.username;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { suwayomiClient } from '@/lib/suwayomi.client';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const mangaId = searchParams.get('mangaId')?.trim();
|
||||
const sourceId = searchParams.get('sourceId')?.trim();
|
||||
|
||||
if (!mangaId || !sourceId) {
|
||||
return NextResponse.json({ error: '缺少 mangaId 或 sourceId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const detail = await suwayomiClient.getMangaDetail({
|
||||
mangaId,
|
||||
sourceId,
|
||||
title: searchParams.get('title') || undefined,
|
||||
cover: searchParams.get('cover') || undefined,
|
||||
sourceName: searchParams.get('sourceName') || undefined,
|
||||
description: searchParams.get('description') || undefined,
|
||||
author: searchParams.get('author') || undefined,
|
||||
status: searchParams.get('status') || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(detail);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { MangaReadRecord } from '@/lib/manga.types';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (key) {
|
||||
const [sourceId, mangaId] = key.split('+');
|
||||
if (!sourceId || !mangaId) {
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
const record = await db.getMangaReadRecord(username, sourceId, mangaId);
|
||||
return NextResponse.json(record, { status: 200 });
|
||||
}
|
||||
|
||||
const records = await db.getAllMangaReadRecords(username);
|
||||
return NextResponse.json(records, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { key, record }: { key: string; record: MangaReadRecord } = await request.json();
|
||||
if (!key || !record?.chapterId) {
|
||||
return NextResponse.json({ error: 'Missing key or record' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [sourceId, mangaId] = key.split('+');
|
||||
if (!sourceId || !mangaId) {
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.saveMangaReadRecord(username, sourceId, mangaId, {
|
||||
...record,
|
||||
saveTime: record.saveTime ?? Date.now(),
|
||||
});
|
||||
|
||||
if ((db as any).storage.cleanupOldMangaReadRecords) {
|
||||
(db as any).storage.cleanupOldMangaReadRecords(username).catch((err: Error) => {
|
||||
console.error('异步清理漫画阅读历史失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (key) {
|
||||
const [sourceId, mangaId] = key.split('+');
|
||||
if (!sourceId || !mangaId) {
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
await db.deleteMangaReadRecord(username, sourceId, mangaId);
|
||||
} else {
|
||||
const all = await db.getAllMangaReadRecords(username);
|
||||
await Promise.all(Object.keys(all).map(async (itemKey) => {
|
||||
const [sourceId, mangaId] = itemKey.split('+');
|
||||
if (sourceId && mangaId) {
|
||||
await db.deleteMangaReadRecord(username, sourceId, mangaId);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
import { getSuwayomiConfig } from '@/lib/suwayomi.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function resolveUpstreamUrl(serverBaseUrl: string, pathOrUrl: string): string {
|
||||
if (/^https?:\/\//i.test(pathOrUrl)) {
|
||||
const target = new URL(pathOrUrl);
|
||||
const base = new URL(serverBaseUrl);
|
||||
if (target.origin !== base.origin) {
|
||||
throw new Error('不允许代理非当前 Suwayomi 服务的地址');
|
||||
}
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
if (!pathOrUrl.startsWith('/')) {
|
||||
pathOrUrl = `/${pathOrUrl}`;
|
||||
}
|
||||
|
||||
return `${serverBaseUrl}${pathOrUrl}`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const pathOrUrl = new URL(request.url).searchParams.get('path')?.trim();
|
||||
if (!pathOrUrl) {
|
||||
return NextResponse.json({ error: '缺少 path 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getSuwayomiConfig();
|
||||
const upstreamUrl = resolveUpstreamUrl(config.serverBaseUrl, pathOrUrl);
|
||||
const response = await fetch(upstreamUrl, {
|
||||
headers: config.token ? { Authorization: `Bearer ${config.token}` } : undefined,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Suwayomi 图片请求失败: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
const contentType = response.headers.get('content-type');
|
||||
const cacheControl = response.headers.get('cache-control');
|
||||
if (contentType) headers.set('content-type', contentType);
|
||||
headers.set('cache-control', cacheControl || 'public, max-age=300');
|
||||
|
||||
return new NextResponse(response.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '图片代理失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { suwayomiClient } from '@/lib/suwayomi.client';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const chapterId = new URL(request.url).searchParams.get('chapterId')?.trim();
|
||||
if (!chapterId) {
|
||||
return NextResponse.json({ error: '缺少 chapterId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pages = await suwayomiClient.getChapterPages(chapterId);
|
||||
return NextResponse.json({ pages });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { suwayomiClient } from '@/lib/suwayomi.client';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get('q')?.trim();
|
||||
const sourceId = searchParams.get('sourceId')?.trim() || undefined;
|
||||
const page = Number(searchParams.get('page') || '1');
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json({ results: [] });
|
||||
}
|
||||
|
||||
const results = await suwayomiClient.searchManga(q, sourceId, page);
|
||||
return NextResponse.json({ results });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { MangaShelfItem } from '@/lib/manga.types';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (key) {
|
||||
const [sourceId, mangaId] = key.split('+');
|
||||
if (!sourceId || !mangaId) {
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
const item = await db.getMangaShelf(username, sourceId, mangaId);
|
||||
return NextResponse.json(item, { status: 200 });
|
||||
}
|
||||
|
||||
const records = await db.getAllMangaShelf(username);
|
||||
return NextResponse.json(records, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { key, item }: { key: string; item: MangaShelfItem } = await request.json();
|
||||
if (!key || !item?.title) {
|
||||
return NextResponse.json({ error: 'Missing key or item' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [sourceId, mangaId] = key.split('+');
|
||||
if (!sourceId || !mangaId) {
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.saveMangaShelf(username, sourceId, mangaId, {
|
||||
...item,
|
||||
saveTime: item.saveTime ?? Date.now(),
|
||||
});
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (key) {
|
||||
const [sourceId, mangaId] = key.split('+');
|
||||
if (!sourceId || !mangaId) {
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
await db.deleteMangaShelf(username, sourceId, mangaId);
|
||||
} else {
|
||||
const all = await db.getAllMangaShelf(username);
|
||||
await Promise.all(Object.keys(all).map(async (itemKey) => {
|
||||
const [sourceId, mangaId] = itemKey.split('+');
|
||||
if (sourceId && mangaId) {
|
||||
await db.deleteMangaShelf(username, sourceId, mangaId);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { suwayomiClient } from '@/lib/suwayomi.client';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const lang = new URL(request.url).searchParams.get('lang') || process.env.SUWAYOMI_DEFAULT_LANG || 'zh';
|
||||
const sources = await suwayomiClient.getSources(lang);
|
||||
return NextResponse.json({ sources });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ export default async function RootLayout({
|
||||
let webLiveEnabled = false;
|
||||
let customAdFilterVersion = 0;
|
||||
let tuneHubEnabled = false;
|
||||
let suwayomiEnabled = false;
|
||||
let musicProxyEnabled = true;
|
||||
let advancedRecommendationEnabled = false;
|
||||
let customCategories = [] as {
|
||||
@@ -153,6 +154,11 @@ export default async function RootLayout({
|
||||
// 音乐功能配置
|
||||
tuneHubEnabled = config.MusicConfig?.Enabled || false;
|
||||
musicProxyEnabled = config.MusicConfig?.ProxyEnabled ?? true;
|
||||
// 漫画功能配置
|
||||
suwayomiEnabled = !!(
|
||||
config.SuwayomiConfig?.Enabled &&
|
||||
config.SuwayomiConfig?.ServerURL
|
||||
);
|
||||
// 高级推荐功能配置:存在已启用视频源脚本时显示
|
||||
advancedRecommendationEnabled =
|
||||
(await listEnabledSourceScripts()).length > 0;
|
||||
@@ -229,6 +235,7 @@ export default async function RootLayout({
|
||||
CUSTOM_AD_FILTER_VERSION: customAdFilterVersion,
|
||||
MUSIC_ENABLED: tuneHubEnabled,
|
||||
MUSIC_PROXY_ENABLED: musicProxyEnabled,
|
||||
SUWAYOMI_ENABLED: suwayomiEnabled,
|
||||
FESTIVE_EFFECT_ENABLED:
|
||||
process.env.FESTIVE_EFFECT_ENABLED === 'true',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowDownWideNarrow, ArrowUpWideNarrow, BookOpen, Clock3 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { deleteMangaShelf, getAllMangaReadRecords, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
|
||||
import { MangaChapter, MangaDetail, MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
|
||||
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
|
||||
function formatChapterMeta(chapter: MangaChapter): string {
|
||||
if (typeof chapter.pageCount === 'number' && chapter.pageCount > 0) {
|
||||
return `${chapter.pageCount} 页`;
|
||||
}
|
||||
|
||||
if (typeof chapter.uploadDate === 'number' && chapter.uploadDate > 0) {
|
||||
const timestamp =
|
||||
chapter.uploadDate > 1_000_000_000_000
|
||||
? chapter.uploadDate
|
||||
: chapter.uploadDate * 1000;
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
}
|
||||
|
||||
return '日期未知';
|
||||
}
|
||||
|
||||
export default function MangaDetailPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mangaId = searchParams.get('mangaId') || '';
|
||||
const sourceId = searchParams.get('sourceId') || '';
|
||||
const [detail, setDetail] = useState<MangaDetail | null>(null);
|
||||
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
|
||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||
const [descOrder, setDescOrder] = useState(true);
|
||||
|
||||
const key = `${sourceId}+${mangaId}`;
|
||||
const currentRecord = history[key];
|
||||
|
||||
useEffect(() => {
|
||||
if (!mangaId || !sourceId) return;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
mangaId,
|
||||
sourceId,
|
||||
title: searchParams.get('title') || '',
|
||||
cover: searchParams.get('cover') || '',
|
||||
sourceName: searchParams.get('sourceName') || '',
|
||||
description: searchParams.get('description') || '',
|
||||
author: searchParams.get('author') || '',
|
||||
status: searchParams.get('status') || '',
|
||||
});
|
||||
|
||||
fetch(`/api/manga/detail?${params.toString()}`)
|
||||
.then((res) => res.json())
|
||||
.then(setDetail)
|
||||
.catch(() => undefined);
|
||||
|
||||
getAllMangaReadRecords().then(setHistory).catch(() => undefined);
|
||||
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
||||
}, [mangaId, searchParams, sourceId]);
|
||||
|
||||
const chapters = useMemo(() => {
|
||||
const list = detail?.chapters || [];
|
||||
return [...list].sort((a, b) => {
|
||||
const diff = (a.chapterNumber || 0) - (b.chapterNumber || 0);
|
||||
return descOrder ? -diff : diff;
|
||||
});
|
||||
}, [detail?.chapters, descOrder]);
|
||||
|
||||
const toggleShelf = async () => {
|
||||
if (!detail) return;
|
||||
if (shelf[key]) {
|
||||
await deleteMangaShelf(sourceId, mangaId);
|
||||
setShelf((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const item: MangaShelfItem = {
|
||||
title: detail.title,
|
||||
cover: detail.cover,
|
||||
sourceId: detail.sourceId,
|
||||
sourceName: detail.sourceName,
|
||||
mangaId: detail.id,
|
||||
saveTime: Date.now(),
|
||||
description: detail.description,
|
||||
author: detail.author,
|
||||
status: detail.status,
|
||||
lastChapterId: currentRecord?.chapterId,
|
||||
lastChapterName: currentRecord?.chapterName,
|
||||
};
|
||||
await saveMangaShelf(sourceId, mangaId, item);
|
||||
setShelf((prev) => ({ ...prev, [key]: item }));
|
||||
};
|
||||
|
||||
const chapterHref = (chapter: MangaChapter) =>
|
||||
`/manga/read?mangaId=${mangaId}&sourceId=${sourceId}&chapterId=${chapter.id}&title=${encodeURIComponent(detail?.title || '')}&cover=${encodeURIComponent(detail?.cover || '')}&sourceName=${encodeURIComponent(detail?.sourceName || '')}&chapterName=${encodeURIComponent(chapter.name)}`;
|
||||
|
||||
if (!detail) return <div className='text-sm text-gray-500'>加载中...</div>;
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-6 rounded-[28px] border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[260px_1fr]'>
|
||||
<div className='overflow-hidden rounded-3xl bg-gray-100 dark:bg-gray-800'>
|
||||
{detail.cover ? (
|
||||
<ProxyImage originalSrc={detail.cover} alt={detail.title} className='h-full w-full object-cover' />
|
||||
) : (
|
||||
<div className='flex aspect-[3/4] items-center justify-center text-sm text-gray-400'>暂无封面</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold'>{detail.title}</h1>
|
||||
<div className='mt-3 flex flex-wrap gap-2 text-xs text-gray-500'>
|
||||
<span className='rounded-full bg-sky-50 px-3 py-1 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300'>
|
||||
{detail.sourceName}
|
||||
</span>
|
||||
{detail.author && <span className='rounded-full bg-gray-100 px-3 py-1 dark:bg-gray-800'>{detail.author}</span>}
|
||||
{detail.status && <span className='rounded-full bg-gray-100 px-3 py-1 dark:bg-gray-800'>{detail.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{detail.description && <p className='text-sm leading-7 text-gray-600 dark:text-gray-300'>{detail.description}</p>}
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{chapters[0] && (
|
||||
<Link href={chapterHref(chapters[0])} className='rounded-2xl bg-sky-600 px-5 py-3 text-sm font-medium text-white transition hover:bg-sky-700'>
|
||||
<BookOpen className='mr-2 inline h-4 w-4' />开始阅读
|
||||
</Link>
|
||||
)}
|
||||
{currentRecord && (
|
||||
<Link
|
||||
href={`/manga/read?mangaId=${mangaId}&sourceId=${sourceId}&chapterId=${currentRecord.chapterId}&title=${encodeURIComponent(detail.title)}&cover=${encodeURIComponent(detail.cover)}&sourceName=${encodeURIComponent(detail.sourceName)}&chapterName=${encodeURIComponent(currentRecord.chapterName)}`}
|
||||
className='rounded-2xl border border-sky-300 px-5 py-3 text-sm font-medium text-sky-700 transition hover:bg-sky-50 dark:text-sky-300 dark:hover:bg-sky-950/30'
|
||||
>
|
||||
<Clock3 className='mr-2 inline h-4 w-4' />继续阅读 第 {currentRecord.pageIndex + 1}/{currentRecord.pageCount} 页
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-5 py-3 text-sm font-medium text-gray-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-gray-700 dark:text-gray-200'>
|
||||
{shelf[key] ? '移出书架' : '加入书架'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-[28px] border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-lg font-semibold'>章节列表</h2>
|
||||
<button onClick={() => setDescOrder((prev) => !prev)} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>
|
||||
{descOrder ? <ArrowDownWideNarrow className='inline h-4 w-4' /> : <ArrowUpWideNarrow className='inline h-4 w-4' />} {descOrder ? '倒序' : '正序'}
|
||||
</button>
|
||||
</div>
|
||||
<div className='grid gap-3'>
|
||||
{chapters.map((chapter) => {
|
||||
const active = currentRecord?.chapterId === chapter.id;
|
||||
return (
|
||||
<Link
|
||||
key={chapter.id}
|
||||
href={chapterHref(chapter)}
|
||||
className={`rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-400 bg-sky-50 dark:bg-sky-950/30' : 'border-gray-200 hover:border-sky-300 dark:border-gray-700'}`}
|
||||
>
|
||||
<div className='font-medium text-gray-900 dark:text-gray-100'>{chapter.name}</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>
|
||||
{formatChapterMeta(chapter)}
|
||||
{active && currentRecord ? ` · 上次看到第 ${currentRecord.pageIndex + 1} 页` : ''}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { History } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getAllMangaReadRecords } from '@/lib/db.client';
|
||||
import { MangaReadRecord } from '@/lib/manga.types';
|
||||
|
||||
import MangaCard from '@/components/MangaCard';
|
||||
|
||||
export default function MangaHistoryPage() {
|
||||
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getAllMangaReadRecords().then(setHistory).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const historyList = useMemo(
|
||||
() => Object.entries(history).sort(([, a], [, b]) => b.saveTime - a.saveTime),
|
||||
[history]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className='mx-auto max-w-6xl'>
|
||||
<div className='mb-4 flex items-center gap-2 text-sm text-gray-500'>
|
||||
<History className='h-4 w-4 text-violet-500' /> 共 {historyList.length} 条阅读记录
|
||||
</div>
|
||||
{historyList.length === 0 ? (
|
||||
<div className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||
暂无阅读历史
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{historyList.map(([key, item]) => (
|
||||
<MangaCard
|
||||
key={key}
|
||||
item={item}
|
||||
href={`/manga/read?mangaId=${item.mangaId}&sourceId=${item.sourceId}&chapterId=${item.chapterId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}&chapterName=${encodeURIComponent(item.chapterName)}`}
|
||||
subtitle={`${item.chapterName} · 第 ${item.pageIndex + 1}/${item.pageCount} 页`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import MangaLayout from '@/components/manga/MangaLayout';
|
||||
|
||||
export default function MangaAppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MangaLayout>{children}</MangaLayout>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { deleteMangaShelf, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
|
||||
import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types';
|
||||
|
||||
import MangaCard from '@/components/MangaCard';
|
||||
|
||||
export default function MangaPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [sources, setSources] = useState<MangaSource[]>([]);
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [results, setResults] = useState<MangaSearchItem[]>([]);
|
||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/manga/sources')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setSources(data.sources || []))
|
||||
.catch(() => undefined);
|
||||
|
||||
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query.trim() });
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
const res = await fetch(`/api/manga/search?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '搜索失败');
|
||||
setResults(data.results || []);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShelf = async (item: MangaSearchItem) => {
|
||||
const key = `${item.sourceId}+${item.id}`;
|
||||
if (shelf[key]) {
|
||||
await deleteMangaShelf(item.sourceId, item.id);
|
||||
setShelf((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const shelfItem: MangaShelfItem = {
|
||||
title: item.title,
|
||||
cover: item.cover,
|
||||
sourceId: item.sourceId,
|
||||
sourceName: item.sourceName,
|
||||
mangaId: item.id,
|
||||
saveTime: Date.now(),
|
||||
description: item.description,
|
||||
author: item.author,
|
||||
status: item.status,
|
||||
};
|
||||
await saveMangaShelf(item.sourceId, item.id, shelfItem);
|
||||
setShelf((prev) => ({ ...prev, [key]: shelfItem }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<form
|
||||
className='mx-auto mb-8 max-w-4xl'
|
||||
onSubmit={handleSearch}
|
||||
>
|
||||
<div className='flex flex-col gap-3 lg:flex-row'>
|
||||
<div className='flex-1'>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder='搜索漫画标题'
|
||||
className='w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-sky-500 dark:border-gray-700 dark:bg-gray-900'
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(e) => setSourceId(e.target.value)}
|
||||
className='rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm dark:border-gray-700 dark:bg-gray-900 lg:w-56'
|
||||
>
|
||||
<option value=''>全部来源</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.displayName || source.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className='inline-flex items-center justify-center gap-2 rounded-2xl bg-sky-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-sky-700 lg:w-32'>
|
||||
<Search className='h-4 w-4' /> 搜索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-lg font-semibold'>搜索结果</h2>
|
||||
{loading && <span className='text-sm text-gray-500'>搜索中...</span>}
|
||||
</div>
|
||||
{error && <div className='mb-4 text-sm text-red-500'>{error}</div>}
|
||||
{results.length === 0 ? (
|
||||
<div className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||
请输入关键词开始搜索漫画
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{results.map((item) => {
|
||||
const key = `${item.sourceId}+${item.id}`;
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<MangaCard
|
||||
item={item}
|
||||
href={`/manga/detail?mangaId=${item.id}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}&description=${encodeURIComponent(item.description || '')}&author=${encodeURIComponent(item.author || '')}&status=${encodeURIComponent(item.status || '')}`}
|
||||
subtitle={item.author || item.status || item.description}
|
||||
/>
|
||||
<button
|
||||
onClick={() => toggleShelf(item)}
|
||||
className='w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-sky-500 hover:text-sky-600 dark:border-gray-700 dark:text-gray-200'
|
||||
>
|
||||
{shelf[key] ? '移出书架' : '加入书架'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { type MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { saveMangaReadRecord } from '@/lib/db.client';
|
||||
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
|
||||
type ReadMode = 'single' | 'double' | 'vertical' | 'horizontal';
|
||||
type ScaleMode = 'fit' | 'original';
|
||||
|
||||
const READ_MODE_STORAGE_KEY = 'mangaReadMode';
|
||||
const SCALE_MODE_STORAGE_KEY = 'mangaScaleMode';
|
||||
const PAGE_GAP_STORAGE_KEY = 'mangaPageGap';
|
||||
|
||||
const READ_MODE_OPTIONS: Array<{ value: ReadMode; label: string }> = [
|
||||
{ value: 'single', label: '单页' },
|
||||
{ value: 'double', label: '双页' },
|
||||
{ value: 'vertical', label: '垂直滚动' },
|
||||
{ value: 'horizontal', label: '水平滚动' },
|
||||
];
|
||||
|
||||
const SCALE_MODE_OPTIONS: Array<{ value: ScaleMode; label: string }> = [
|
||||
{ value: 'fit', label: '适配屏幕' },
|
||||
{ value: 'original', label: '原始大小' },
|
||||
];
|
||||
|
||||
export default function MangaReadPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mangaId = searchParams.get('mangaId') || '';
|
||||
const sourceId = searchParams.get('sourceId') || '';
|
||||
const chapterId = searchParams.get('chapterId') || '';
|
||||
const title = searchParams.get('title') || '漫画阅读';
|
||||
const cover = searchParams.get('cover') || '';
|
||||
const sourceName = searchParams.get('sourceName') || sourceId;
|
||||
const chapterName = searchParams.get('chapterName') || '章节';
|
||||
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
const [readMode, setReadMode] = useState<ReadMode>('vertical');
|
||||
const [scaleMode, setScaleMode] = useState<ScaleMode>('fit');
|
||||
const [pageGap, setPageGap] = useState(0);
|
||||
const [controlsVisible, setControlsVisible] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const verticalPageRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const horizontalContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const savedMode = window.localStorage.getItem(READ_MODE_STORAGE_KEY) as ReadMode | null;
|
||||
if (savedMode && READ_MODE_OPTIONS.some((item) => item.value === savedMode)) {
|
||||
setReadMode(savedMode);
|
||||
}
|
||||
const savedScaleMode = window.localStorage.getItem(SCALE_MODE_STORAGE_KEY) as ScaleMode | null;
|
||||
if (savedScaleMode && SCALE_MODE_OPTIONS.some((item) => item.value === savedScaleMode)) {
|
||||
setScaleMode(savedScaleMode);
|
||||
}
|
||||
const savedGap = Number(window.localStorage.getItem(PAGE_GAP_STORAGE_KEY) || 0);
|
||||
if (!Number.isNaN(savedGap)) {
|
||||
setPageGap(Math.min(Math.max(savedGap, 0), 48));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(READ_MODE_STORAGE_KEY, readMode);
|
||||
}, [readMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(SCALE_MODE_STORAGE_KEY, scaleMode);
|
||||
}, [scaleMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(PAGE_GAP_STORAGE_KEY, String(pageGap));
|
||||
}, [pageGap]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleSettings = () => {
|
||||
setSettingsOpen((prev) => !prev);
|
||||
setControlsVisible(false);
|
||||
};
|
||||
|
||||
window.addEventListener('manga-read-toggle-settings', handleToggleSettings);
|
||||
return () => {
|
||||
window.removeEventListener('manga-read-toggle-settings', handleToggleSettings);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterId) return;
|
||||
fetch(`/api/manga/pages?chapterId=${encodeURIComponent(chapterId)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setPages(data.pages || []))
|
||||
.catch(() => setPages([]));
|
||||
}, [chapterId]);
|
||||
|
||||
useEffect(() => {
|
||||
setActivePage(0);
|
||||
}, [chapterId, readMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (readMode !== 'vertical' || !pages.length || !mangaId || !sourceId || !chapterId) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||
if (!visible) return;
|
||||
const index = Number((visible.target as HTMLElement).dataset.index || 0);
|
||||
setActivePage(index);
|
||||
},
|
||||
{ rootMargin: '-15% 0px -70% 0px', threshold: 0.2 }
|
||||
);
|
||||
|
||||
verticalPageRefs.current.forEach((node) => node && observer.observe(node));
|
||||
return () => observer.disconnect();
|
||||
}, [readMode, pages, mangaId, sourceId, chapterId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (readMode !== 'horizontal') return;
|
||||
const container = horizontalContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
const width = container.clientWidth || 1;
|
||||
const nextPage = Math.round(container.scrollLeft / width);
|
||||
setActivePage(Math.min(Math.max(nextPage, 0), Math.max(pages.length - 1, 0)));
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', onScroll);
|
||||
}, [readMode, pages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pages.length || !mangaId || !sourceId || !chapterId) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
saveMangaReadRecord(sourceId, mangaId, {
|
||||
title,
|
||||
cover,
|
||||
sourceId,
|
||||
sourceName,
|
||||
mangaId,
|
||||
chapterId,
|
||||
chapterName,
|
||||
pageIndex: activePage,
|
||||
pageCount: pages.length,
|
||||
saveTime: Date.now(),
|
||||
}).catch(() => undefined);
|
||||
}, 300);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activePage, chapterId, chapterName, cover, mangaId, pages.length, sourceId, sourceName, title]);
|
||||
|
||||
const hideTransientUi = () => {
|
||||
setControlsVisible(false);
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
|
||||
const clampPage = (page: number) => {
|
||||
if (!pages.length) return 0;
|
||||
return Math.min(Math.max(page, 0), pages.length - 1);
|
||||
};
|
||||
|
||||
const scrollHorizontalToPage = (page: number) => {
|
||||
const container = horizontalContainerRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTo({
|
||||
left: container.clientWidth * page,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
if (!pages.length) return;
|
||||
if (readMode === 'vertical') {
|
||||
window.scrollBy({ top: -window.innerHeight * 0.85, behavior: 'smooth' });
|
||||
hideTransientUi();
|
||||
return;
|
||||
}
|
||||
if (readMode === 'horizontal') {
|
||||
const nextPage = clampPage(activePage - 1);
|
||||
setActivePage(nextPage);
|
||||
scrollHorizontalToPage(nextPage);
|
||||
hideTransientUi();
|
||||
return;
|
||||
}
|
||||
setActivePage((prev) => clampPage(prev - (readMode === 'double' ? 2 : 1)));
|
||||
hideTransientUi();
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (!pages.length) return;
|
||||
if (readMode === 'vertical') {
|
||||
window.scrollBy({ top: window.innerHeight * 0.85, behavior: 'smooth' });
|
||||
hideTransientUi();
|
||||
return;
|
||||
}
|
||||
if (readMode === 'horizontal') {
|
||||
const nextPage = clampPage(activePage + 1);
|
||||
setActivePage(nextPage);
|
||||
scrollHorizontalToPage(nextPage);
|
||||
hideTransientUi();
|
||||
return;
|
||||
}
|
||||
setActivePage((prev) => clampPage(prev + (readMode === 'double' ? 2 : 1)));
|
||||
hideTransientUi();
|
||||
};
|
||||
|
||||
const progress = useMemo(
|
||||
() => (pages.length ? Math.round(((activePage + 1) / pages.length) * 100) : 0),
|
||||
[activePage, pages.length]
|
||||
);
|
||||
|
||||
const pagedItems = useMemo(() => {
|
||||
if (readMode === 'single') {
|
||||
return pages[activePage] ? [pages[activePage]] : [];
|
||||
}
|
||||
if (readMode === 'double') {
|
||||
return pages.slice(activePage, activePage + 2);
|
||||
}
|
||||
return [];
|
||||
}, [activePage, pages, readMode]);
|
||||
|
||||
const imageClassName = useMemo(() => {
|
||||
if (scaleMode === 'original') {
|
||||
return 'mx-auto h-auto w-auto max-w-none object-none';
|
||||
}
|
||||
return 'h-auto w-full object-contain';
|
||||
}, [scaleMode]);
|
||||
|
||||
const handleReaderClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (settingsOpen) return;
|
||||
const { clientX } = event;
|
||||
const width = window.innerWidth;
|
||||
const leftBoundary = width / 3;
|
||||
const rightBoundary = (width / 3) * 2;
|
||||
|
||||
if (clientX < leftBoundary) {
|
||||
goPrev();
|
||||
return;
|
||||
}
|
||||
if (clientX > rightBoundary) {
|
||||
goNext();
|
||||
return;
|
||||
}
|
||||
setControlsVisible((prev) => !prev);
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
{settingsOpen && (
|
||||
<div
|
||||
className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4'
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='mb-4'>
|
||||
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>阅读设置</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>可继续扩展更多阅读参数</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-5'>
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-gray-700 dark:text-gray-200'>显示方式</div>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{READ_MODE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
className={`rounded-2xl px-3 py-2 text-sm transition ${
|
||||
readMode === option.value
|
||||
? 'bg-sky-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
onClick={() => setReadMode(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-gray-700 dark:text-gray-200'>缩放类型</div>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{SCALE_MODE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
className={`rounded-2xl px-3 py-2 text-sm transition ${
|
||||
scaleMode === option.value
|
||||
? 'bg-sky-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
onClick={() => setScaleMode(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mb-2 flex items-center justify-between text-sm font-medium text-gray-700 dark:text-gray-200'>
|
||||
<span>图片间隔</span>
|
||||
<span className='text-xs text-gray-500'>{pageGap}px</span>
|
||||
</div>
|
||||
<input
|
||||
type='range'
|
||||
min='0'
|
||||
max='48'
|
||||
step='2'
|
||||
value={pageGap}
|
||||
onChange={(e) => setPageGap(Number(e.target.value))}
|
||||
className='w-full accent-sky-600'
|
||||
/>
|
||||
<div className='mt-1 text-xs text-gray-500'>滚动阅读时,两张图片之间的间隔</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white'
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className='relative min-h-[calc(100vh-5rem)] select-none px-2 py-3 sm:px-3'
|
||||
onClick={handleReaderClick}
|
||||
>
|
||||
<div
|
||||
className={`fixed right-3 top-1/2 z-20 h-40 w-1 -translate-y-1/2 overflow-hidden rounded-full bg-gray-200/80 transition-all duration-200 dark:bg-gray-700/80 ${
|
||||
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className='absolute bottom-0 left-0 w-full rounded-full bg-sky-500 transition-all'
|
||||
style={{ height: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
<div className='rounded-[24px] bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||
加载漫画图片中...
|
||||
</div>
|
||||
) : readMode === 'vertical' ? (
|
||||
<div className='flex flex-col' style={{ gap: `${pageGap}px` }}>
|
||||
{pages.map((page, index) => (
|
||||
<div
|
||||
key={`${page}-${index}`}
|
||||
ref={(node) => {
|
||||
verticalPageRefs.current[index] = node;
|
||||
}}
|
||||
data-index={index}
|
||||
className='overflow-hidden rounded-[24px] bg-gray-100 shadow-sm dark:bg-gray-900'
|
||||
>
|
||||
<ProxyImage originalSrc={page} alt={`${chapterName}-${index + 1}`} className={imageClassName} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : readMode === 'horizontal' ? (
|
||||
<div
|
||||
ref={horizontalContainerRef}
|
||||
className='flex min-h-[calc(100vh-8rem)] snap-x snap-mandatory overflow-x-auto overflow-y-hidden scrollbar-hide'
|
||||
style={{ gap: `${pageGap}px` }}
|
||||
>
|
||||
{pages.map((page, index) => (
|
||||
<div key={`${page}-${index}`} className='flex min-w-full snap-center items-center justify-center px-1'>
|
||||
<div className='w-full overflow-hidden rounded-[24px] bg-gray-100 shadow-sm dark:bg-gray-900'>
|
||||
<ProxyImage originalSrc={page} alt={`${chapterName}-${index + 1}`} className={imageClassName} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex min-h-[calc(100vh-8rem)] items-center justify-center'>
|
||||
<div className={`grid w-full max-w-6xl ${readMode === 'double' ? 'md:grid-cols-2' : 'grid-cols-1'}`} style={{ gap: `${pageGap}px` }}>
|
||||
{pagedItems.map((page, index) => (
|
||||
<div
|
||||
key={`${page}-${index}`}
|
||||
className='overflow-hidden rounded-[24px] bg-gray-100 shadow-sm dark:bg-gray-900'
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={page}
|
||||
alt={`${chapterName}-${activePage + index + 1}`}
|
||||
className={imageClassName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{readMode === 'double' && pagedItems.length === 1 && (
|
||||
<div className='hidden rounded-[24px] bg-transparent md:block' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/manga/detail?mangaId=${mangaId}&sourceId=${sourceId}&title=${encodeURIComponent(title)}&cover=${encodeURIComponent(cover)}&sourceName=${encodeURIComponent(sourceName)}`}
|
||||
className='sr-only'
|
||||
>
|
||||
返回详情
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { deleteMangaShelf, getAllMangaShelf } from '@/lib/db.client';
|
||||
import { MangaShelfItem } from '@/lib/manga.types';
|
||||
|
||||
import MangaCard from '@/components/MangaCard';
|
||||
|
||||
export default function MangaShelfPage() {
|
||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const shelfList = useMemo(
|
||||
() => Object.entries(shelf).sort(([, a], [, b]) => b.saveTime - a.saveTime),
|
||||
[shelf]
|
||||
);
|
||||
|
||||
const removeItem = async (sourceId: string, mangaId: string) => {
|
||||
const key = `${sourceId}+${mangaId}`;
|
||||
await deleteMangaShelf(sourceId, mangaId);
|
||||
setShelf((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className='mx-auto max-w-6xl'>
|
||||
<div className='mb-4 flex items-center gap-2 text-sm text-gray-500'>
|
||||
<BookOpen className='h-4 w-4 text-emerald-500' /> 共 {shelfList.length} 本漫画
|
||||
</div>
|
||||
{shelfList.length === 0 ? (
|
||||
<div className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||
暂无书架内容
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{shelfList.map(([key, item]) => (
|
||||
<div key={key} className='space-y-2'>
|
||||
<MangaCard
|
||||
item={item}
|
||||
href={`/manga/detail?mangaId=${item.mangaId}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}`}
|
||||
subtitle={item.lastChapterName || item.author || item.status}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeItem(item.sourceId, item.mangaId)}
|
||||
className='w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-red-300 hover:text-red-600 dark:border-gray-700 dark:text-gray-200'
|
||||
>
|
||||
移出书架
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+21
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
|
||||
import { BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
@@ -67,6 +67,7 @@ function HomeClient() {
|
||||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
||||
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
||||
const [musicEnabled, setMusicEnabled] = useState(false);
|
||||
const [mangaEnabled, setMangaEnabled] = useState(false);
|
||||
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
|
||||
const [directPlayUrl, setDirectPlayUrl] = useState('');
|
||||
|
||||
@@ -157,6 +158,14 @@ function HomeClient() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查漫画功能是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled = !!(window as any).RUNTIME_CONFIG?.SUWAYOMI_ENABLED;
|
||||
setMangaEnabled(enabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查公告弹窗状态
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && announcement) {
|
||||
@@ -624,6 +633,17 @@ function HomeClient() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{mangaEnabled && (
|
||||
<Link href='/manga'>
|
||||
<button
|
||||
className='p-1.5 rounded-lg text-emerald-500 hover:text-emerald-600 transition-colors'
|
||||
title='漫画展馆'
|
||||
>
|
||||
<BookOpen size={18} />
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 源站寻片入口 */}
|
||||
{sourceSearchEnabled && (
|
||||
<Link href='/source-search'>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { MangaReadRecord, MangaSearchItem, MangaShelfItem } from '@/lib/manga.types';
|
||||
|
||||
import ProxyImage from './ProxyImage';
|
||||
|
||||
interface MangaCardProps {
|
||||
item: MangaSearchItem | MangaShelfItem | MangaReadRecord;
|
||||
href: string;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export default function MangaCard({ item, href, subtitle, badge }: MangaCardProps) {
|
||||
const sourceName = useMemo(() => {
|
||||
if ('sourceName' in item) return item.sourceName;
|
||||
return '';
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className='group overflow-hidden rounded-2xl border border-gray-200/70 bg-white/90 shadow-sm transition hover:-translate-y-1 hover:shadow-xl dark:border-gray-700 dark:bg-gray-900/80'
|
||||
>
|
||||
<div className='relative aspect-[3/4] overflow-hidden bg-gray-100 dark:bg-gray-800'>
|
||||
{item.cover ? (
|
||||
<ProxyImage
|
||||
originalSrc={item.cover}
|
||||
alt={item.title}
|
||||
className='h-full w-full object-cover transition duration-300 group-hover:scale-105'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center text-sm text-gray-400'>暂无封面</div>
|
||||
)}
|
||||
{badge && (
|
||||
<span className='absolute left-3 top-3 rounded-full bg-black/65 px-2 py-1 text-xs text-white'>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-1 p-3'>
|
||||
<div className='line-clamp-2 min-h-[2.75rem] text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{item.title}
|
||||
</div>
|
||||
{sourceName && <div className='text-xs text-gray-500 dark:text-gray-400'>{sourceName}</div>}
|
||||
{subtitle && <div className='line-clamp-2 text-xs text-sky-600 dark:text-sky-400'>{subtitle}</div>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const tabs = [
|
||||
{ href: '/manga', label: '搜索' },
|
||||
{ href: '/manga/shelf', label: '书架' },
|
||||
{ href: '/manga/history', label: '历史' },
|
||||
];
|
||||
|
||||
export default function MangaSectionNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className='mb-6 flex flex-wrap gap-2'>
|
||||
{tabs.map((tab) => {
|
||||
const active = pathname === tab.href;
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
|
||||
active
|
||||
? 'bg-sky-600 text-white shadow-sm'
|
||||
: 'border border-gray-200 bg-white text-gray-700 hover:border-sky-300 hover:text-sky-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { BookOpen, ChevronLeft, History, Home, Search, Settings2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { UpdateNotification } from '@/components/UpdateNotification';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
|
||||
interface MangaLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const sectionTabs = [
|
||||
{ href: '/manga', label: '搜索', icon: Search },
|
||||
{ href: '/manga/shelf', label: '书架', icon: BookOpen },
|
||||
{ href: '/manga/history', label: '历史', icon: History },
|
||||
];
|
||||
|
||||
const bottomTabs = [{ href: '/', label: '首页', icon: Home }, ...sectionTabs];
|
||||
|
||||
function getMeta(pathname: string, searchParams: ReturnType<typeof useSearchParams>) {
|
||||
if (pathname === '/manga/shelf') {
|
||||
return { title: '漫画书架', subtitle: '集中管理收藏的漫画' };
|
||||
}
|
||||
if (pathname === '/manga/history') {
|
||||
return { title: '漫画历史', subtitle: '从上次阅读的位置继续' };
|
||||
}
|
||||
if (pathname === '/manga/detail') {
|
||||
return {
|
||||
title: searchParams.get('title') || '漫画详情',
|
||||
subtitle: searchParams.get('sourceName') || '漫画详情',
|
||||
backHref: '/manga',
|
||||
};
|
||||
}
|
||||
if (pathname === '/manga/read') {
|
||||
const mangaId = searchParams.get('mangaId') || '';
|
||||
const sourceId = searchParams.get('sourceId') || '';
|
||||
const title = searchParams.get('title') || '漫画阅读';
|
||||
const cover = searchParams.get('cover') || '';
|
||||
const sourceName = searchParams.get('sourceName') || sourceId;
|
||||
return {
|
||||
title,
|
||||
subtitle: searchParams.get('chapterName') || '章节',
|
||||
backHref: `/manga/detail?mangaId=${encodeURIComponent(mangaId)}&sourceId=${encodeURIComponent(sourceId)}&title=${encodeURIComponent(title)}&cover=${encodeURIComponent(cover)}&sourceName=${encodeURIComponent(sourceName)}`,
|
||||
};
|
||||
}
|
||||
return { title: '漫画展馆', subtitle: '搜索漫画并加入书架' };
|
||||
}
|
||||
|
||||
export default function MangaLayout({ children }: MangaLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { siteName } = useSite();
|
||||
const meta = getMeta(pathname, searchParams);
|
||||
const isReadingPage = pathname === '/manga/read';
|
||||
|
||||
const isActive = (href: string) => pathname === href;
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-gray-100'>
|
||||
<header className='fixed inset-x-0 top-0 z-[999] border-b border-gray-200/70 bg-white/85 backdrop-blur-xl shadow-sm dark:border-gray-800/80 dark:bg-gray-950/85'>
|
||||
<div className='mx-auto flex h-14 max-w-7xl items-center gap-3 px-3 sm:h-16 sm:px-6'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
{meta.backHref ? (
|
||||
<Link
|
||||
href={meta.backHref}
|
||||
className='flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
>
|
||||
<ChevronLeft className='h-5 w-5' />
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href='/'
|
||||
className='flex h-10 items-center rounded-full px-3 text-sm font-semibold text-sky-600 transition hover:bg-sky-50 dark:hover:bg-sky-950/40'
|
||||
>
|
||||
{siteName}
|
||||
</Link>
|
||||
)}
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
|
||||
{meta.subtitle && (
|
||||
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
|
||||
{meta.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className='ml-auto hidden items-center gap-2 lg:flex'>
|
||||
{sectionTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = isActive(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm transition ${
|
||||
active
|
||||
? 'bg-sky-600 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Icon className='h-4 w-4' />
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={`${isReadingPage ? 'flex' : 'hidden md:flex'} items-center gap-2`}>
|
||||
{isReadingPage ? (
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('manga-read-toggle-settings'));
|
||||
}}
|
||||
aria-label='阅读设置'
|
||||
>
|
||||
<Settings2 className='h-5 w-5' />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
<UpdateNotification />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className='mx-auto max-w-7xl px-3 pb-24 pt-20 sm:px-6 sm:pb-28 sm:pt-24'
|
||||
style={{ paddingBottom: isReadingPage ? undefined : 'calc(5rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{!isReadingPage && (
|
||||
<nav
|
||||
className='fixed inset-x-0 bottom-0 z-[998] border-t border-gray-200/70 bg-white/92 backdrop-blur-xl dark:border-gray-800/80 dark:bg-gray-950/92'
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div className='mx-auto grid max-w-3xl grid-cols-4'>
|
||||
{bottomTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = isActive(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className='flex min-h-16 flex-col items-center justify-center gap-1 py-2 text-xs'
|
||||
>
|
||||
<Icon
|
||||
className={`h-5 w-5 ${
|
||||
active
|
||||
? 'text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? 'text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -243,6 +243,14 @@ export interface AdminConfig {
|
||||
Password?: string; // 密码认证(备选)
|
||||
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
|
||||
};
|
||||
SuwayomiConfig?: {
|
||||
Enabled: boolean; // 是否启用漫画展馆
|
||||
ServerURL: string; // Suwayomi 服务地址
|
||||
AuthToken?: string; // 可选认证 Token
|
||||
DefaultLang?: string; // 默认语言,如 zh
|
||||
SourceIds?: string[]; // 限制可用源
|
||||
MaxSources?: number; // 搜索时最多查询多少个源
|
||||
};
|
||||
EmailConfig?: {
|
||||
enabled: boolean; // 是否启用邮件通知
|
||||
provider: 'smtp' | 'resend'; // 邮件发送方式
|
||||
|
||||
@@ -623,6 +623,35 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if (!adminConfig.SuwayomiConfig) {
|
||||
adminConfig.SuwayomiConfig = {
|
||||
Enabled: process.env.SUWAYOMI_ENABLED === 'true',
|
||||
ServerURL: process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '',
|
||||
AuthToken: process.env.SUWAYOMI_AUTH_TOKEN || '',
|
||||
DefaultLang: process.env.SUWAYOMI_DEFAULT_LANG || 'zh',
|
||||
SourceIds: [],
|
||||
MaxSources: Number(process.env.SUWAYOMI_MAX_SOURCES || 10),
|
||||
};
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.Enabled === undefined) {
|
||||
adminConfig.SuwayomiConfig.Enabled = false;
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.ServerURL === undefined) {
|
||||
adminConfig.SuwayomiConfig.ServerURL = '';
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.AuthToken === undefined) {
|
||||
adminConfig.SuwayomiConfig.AuthToken = '';
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.DefaultLang === undefined) {
|
||||
adminConfig.SuwayomiConfig.DefaultLang = 'zh';
|
||||
}
|
||||
if (!Array.isArray(adminConfig.SuwayomiConfig.SourceIds)) {
|
||||
adminConfig.SuwayomiConfig.SourceIds = [];
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.MaxSources === undefined || Number.isNaN(adminConfig.SuwayomiConfig.MaxSources)) {
|
||||
adminConfig.SuwayomiConfig.MaxSources = 10;
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig) {
|
||||
adminConfig.NetDiskConfig = {
|
||||
Quark: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MovieRequest,
|
||||
} from './types';
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { DatabaseAdapter } from './d1-adapter';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { userInfoCache } from './user-cache';
|
||||
@@ -1775,6 +1776,268 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 漫画书架 ====================
|
||||
|
||||
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
title: result.title as string,
|
||||
cover: (result.cover as string) || '',
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
mangaId: result.manga_id as string,
|
||||
saveTime: Number(result.save_time || 0),
|
||||
description: (result.description as string) || undefined,
|
||||
author: (result.author as string) || undefined,
|
||||
status: (result.status as string) || undefined,
|
||||
lastChapterId: (result.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (result.last_chapter_name as string) || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO manga_shelf (
|
||||
username, key, source_id, source_name, manga_id, title, cover, save_time,
|
||||
description, author, status, last_chapter_id, last_chapter_name
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, key) DO UPDATE SET
|
||||
source_id = excluded.source_id,
|
||||
source_name = excluded.source_name,
|
||||
manga_id = excluded.manga_id,
|
||||
title = excluded.title,
|
||||
cover = excluded.cover,
|
||||
save_time = excluded.save_time,
|
||||
description = excluded.description,
|
||||
author = excluded.author,
|
||||
status = excluded.status,
|
||||
last_chapter_id = excluded.last_chapter_id,
|
||||
last_chapter_name = excluded.last_chapter_name
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
item.sourceId,
|
||||
item.sourceName,
|
||||
item.mangaId,
|
||||
item.title,
|
||||
item.cover || '',
|
||||
item.saveTime,
|
||||
item.description || null,
|
||||
item.author || null,
|
||||
item.status || null,
|
||||
item.lastChapterId || null,
|
||||
item.lastChapterName || null
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.setMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = ? ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const shelves: { [key: string]: MangaShelfItem } = {};
|
||||
if (!results.results) return shelves;
|
||||
|
||||
for (const row of results.results) {
|
||||
shelves[row.key as string] = {
|
||||
title: row.title as string,
|
||||
cover: (row.cover as string) || '',
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
mangaId: row.manga_id as string,
|
||||
saveTime: Number(row.save_time || 0),
|
||||
description: (row.description as string) || undefined,
|
||||
author: (row.author as string) || undefined,
|
||||
status: (row.status as string) || undefined,
|
||||
lastChapterId: (row.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (row.last_chapter_name as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return shelves;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getAllMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMangaShelf(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM manga_shelf WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.deleteMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 漫画阅读历史 ====================
|
||||
|
||||
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_read_records WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
title: result.title as string,
|
||||
cover: (result.cover as string) || '',
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
mangaId: result.manga_id as string,
|
||||
chapterId: result.chapter_id as string,
|
||||
chapterName: result.chapter_name as string,
|
||||
pageIndex: Number(result.page_index || 0),
|
||||
pageCount: Number(result.page_count || 0),
|
||||
saveTime: Number(result.save_time || 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO manga_read_records (
|
||||
username, key, source_id, source_name, manga_id, title, cover,
|
||||
chapter_id, chapter_name, page_index, page_count, save_time
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, key) DO UPDATE SET
|
||||
source_id = excluded.source_id,
|
||||
source_name = excluded.source_name,
|
||||
manga_id = excluded.manga_id,
|
||||
title = excluded.title,
|
||||
cover = excluded.cover,
|
||||
chapter_id = excluded.chapter_id,
|
||||
chapter_name = excluded.chapter_name,
|
||||
page_index = excluded.page_index,
|
||||
page_count = excluded.page_count,
|
||||
save_time = excluded.save_time
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
record.sourceId,
|
||||
record.sourceName,
|
||||
record.mangaId,
|
||||
record.title,
|
||||
record.cover || '',
|
||||
record.chapterId,
|
||||
record.chapterName,
|
||||
record.pageIndex,
|
||||
record.pageCount,
|
||||
record.saveTime
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.setMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_read_records WHERE username = ? ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const records: { [key: string]: MangaReadRecord } = {};
|
||||
if (!results.results) return records;
|
||||
|
||||
for (const row of results.results) {
|
||||
records[row.key as string] = {
|
||||
title: row.title as string,
|
||||
cover: (row.cover as string) || '',
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
mangaId: row.manga_id as string,
|
||||
chapterId: row.chapter_id as string,
|
||||
chapterName: row.chapter_name as string,
|
||||
pageIndex: Number(row.page_index || 0),
|
||||
pageCount: Number(row.page_count || 0),
|
||||
saveTime: Number(row.save_time || 0),
|
||||
};
|
||||
}
|
||||
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getAllMangaReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM manga_read_records WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.deleteMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOldMangaReadRecords(userName: string): Promise<void> {
|
||||
try {
|
||||
const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10);
|
||||
const countResult = await this.db
|
||||
.prepare('SELECT COUNT(*) as count FROM manga_read_records WHERE username = ?')
|
||||
.bind(userName)
|
||||
.first();
|
||||
|
||||
const count = Number(countResult?.count || 0);
|
||||
if (count <= maxRecords) return;
|
||||
|
||||
await this.db
|
||||
.prepare(`
|
||||
DELETE FROM manga_read_records
|
||||
WHERE username = ?
|
||||
AND key NOT IN (
|
||||
SELECT key FROM manga_read_records
|
||||
WHERE username = ?
|
||||
ORDER BY save_time DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`)
|
||||
.bind(userName, userName, maxRecords)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.cleanupOldMangaReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 跳过配置 ====================
|
||||
|
||||
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> {
|
||||
@@ -2235,6 +2498,8 @@ export class D1Storage implements IStorage {
|
||||
'play_records',
|
||||
'favorites',
|
||||
'search_history',
|
||||
'manga_shelf',
|
||||
'manga_read_records',
|
||||
'skip_configs',
|
||||
'music_play_records',
|
||||
'music_playlists',
|
||||
|
||||
+323
-3
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
|
||||
|
||||
// 全局错误触发函数
|
||||
@@ -81,6 +82,8 @@ interface CacheData<T> {
|
||||
interface UserCacheStore {
|
||||
playRecords?: CacheData<Record<string, PlayRecord>>;
|
||||
favorites?: CacheData<Record<string, Favorite>>;
|
||||
mangaShelf?: CacheData<Record<string, MangaShelfItem>>;
|
||||
mangaReadRecords?: CacheData<Record<string, MangaReadRecord>>;
|
||||
searchHistory?: CacheData<string[]>;
|
||||
skipConfigs?: CacheData<Record<string, SkipConfig>>;
|
||||
danmakuFilterConfig?: CacheData<DanmakuFilterConfig>;
|
||||
@@ -90,6 +93,8 @@ interface UserCacheStore {
|
||||
// ---- 常量 ----
|
||||
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||
const FAVORITES_KEY = 'moontv_favorites';
|
||||
const MANGA_SHELF_KEY = 'moontv_manga_shelf';
|
||||
const MANGA_HISTORY_KEY = 'moontv_manga_history';
|
||||
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||
const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records';
|
||||
|
||||
@@ -236,6 +241,14 @@ class HybridCacheManager {
|
||||
if (cache.favorites && now - cache.favorites.timestamp > maxAge) {
|
||||
delete cache.favorites;
|
||||
}
|
||||
|
||||
if (cache.mangaShelf && now - cache.mangaShelf.timestamp > maxAge) {
|
||||
delete cache.mangaShelf;
|
||||
}
|
||||
|
||||
if (cache.mangaReadRecords && now - cache.mangaReadRecords.timestamp > maxAge) {
|
||||
delete cache.mangaReadRecords;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,6 +343,52 @@ class HybridCacheManager {
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
getCachedMangaShelf(): Record<string, MangaShelfItem> | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.mangaShelf;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheMangaShelf(data: Record<string, MangaShelfItem>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.mangaShelf = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
getCachedMangaReadRecords(): Record<string, MangaReadRecord> | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.mangaReadRecords;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheMangaReadRecords(data: Record<string, MangaReadRecord>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.mangaReadRecords = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的搜索历史
|
||||
*/
|
||||
@@ -503,7 +562,7 @@ const cacheManager = HybridCacheManager.getInstance();
|
||||
* 立即从数据库刷新对应类型的缓存以保持数据一致性
|
||||
*/
|
||||
async function handleDatabaseOperationFailure(
|
||||
dataType: 'playRecords' | 'favorites' | 'searchHistory',
|
||||
dataType: 'playRecords' | 'favorites' | 'searchHistory' | 'mangaShelf' | 'mangaHistory',
|
||||
error: any
|
||||
): Promise<void> {
|
||||
console.error(`数据库操作失败 (${dataType}):`, error);
|
||||
@@ -535,6 +594,16 @@ async function handleDatabaseOperationFailure(
|
||||
cacheManager.cacheSearchHistory(freshData);
|
||||
eventName = 'searchHistoryUpdated';
|
||||
break;
|
||||
case 'mangaShelf':
|
||||
freshData = await fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`);
|
||||
cacheManager.cacheMangaShelf(freshData);
|
||||
eventName = 'mangaShelfUpdated';
|
||||
break;
|
||||
case 'mangaHistory':
|
||||
freshData = await fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`);
|
||||
cacheManager.cacheMangaReadRecords(freshData);
|
||||
eventName = 'mangaHistoryUpdated';
|
||||
break;
|
||||
}
|
||||
|
||||
// 触发更新事件通知组件
|
||||
@@ -1519,6 +1588,229 @@ export async function clearAllFavorites(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------- 漫画书架 / 历史 API ----------------
|
||||
|
||||
export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cachedData = cacheManager.getCachedMangaShelf();
|
||||
if (cachedData) {
|
||||
fetchFromApi<Record<string, MangaShelfItem>>('/api/manga/shelf')
|
||||
.then((freshData) => {
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheMangaShelf(freshData);
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: freshData }));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步漫画书架失败:', err);
|
||||
});
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, MangaShelfItem>>('/api/manga/shelf');
|
||||
cacheManager.cacheMangaShelf(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取漫画书架失败:', err);
|
||||
triggerGlobalError('获取漫画书架失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(MANGA_SHELF_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, MangaShelfItem>;
|
||||
} catch (err) {
|
||||
console.error('读取漫画书架失败:', err);
|
||||
triggerGlobalError('读取漫画书架失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMangaShelf(sourceId: string, mangaId: string, item: MangaShelfItem): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, mangaId);
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cached = cacheManager.getCachedMangaShelf() || {};
|
||||
cached[key] = item;
|
||||
cacheManager.cacheMangaShelf(cached);
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: cached }));
|
||||
|
||||
try {
|
||||
await fetchWithAuth('/api/manga/shelf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, item }),
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaShelf', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allItems = await getAllMangaShelf();
|
||||
allItems[key] = item;
|
||||
localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems));
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems }));
|
||||
}
|
||||
|
||||
export async function deleteMangaShelf(sourceId: string, mangaId: string): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, mangaId);
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cached = cacheManager.getCachedMangaShelf() || {};
|
||||
delete cached[key];
|
||||
cacheManager.cacheMangaShelf(cached);
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: cached }));
|
||||
|
||||
try {
|
||||
await fetchWithAuth(`/api/manga/shelf?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaShelf', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allItems = await getAllMangaShelf();
|
||||
delete allItems[key];
|
||||
localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems));
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems }));
|
||||
}
|
||||
|
||||
export async function clearAllMangaShelf(): Promise<void> {
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
cacheManager.cacheMangaShelf({});
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} }));
|
||||
try {
|
||||
await fetchWithAuth('/api/manga/shelf', { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaShelf', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(MANGA_SHELF_KEY);
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} }));
|
||||
}
|
||||
|
||||
export async function getAllMangaReadRecords(): Promise<Record<string, MangaReadRecord>> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cachedData = cacheManager.getCachedMangaReadRecords();
|
||||
if (cachedData) {
|
||||
fetchFromApi<Record<string, MangaReadRecord>>('/api/manga/history')
|
||||
.then((freshData) => {
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheMangaReadRecords(freshData);
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: freshData }));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步漫画历史失败:', err);
|
||||
});
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, MangaReadRecord>>('/api/manga/history');
|
||||
cacheManager.cacheMangaReadRecords(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取漫画历史失败:', err);
|
||||
triggerGlobalError('获取漫画历史失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(MANGA_HISTORY_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, MangaReadRecord>;
|
||||
} catch (err) {
|
||||
console.error('读取漫画历史失败:', err);
|
||||
triggerGlobalError('读取漫画历史失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMangaReadRecord(sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, mangaId);
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cached = cacheManager.getCachedMangaReadRecords() || {};
|
||||
cached[key] = record;
|
||||
cacheManager.cacheMangaReadRecords(cached);
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: cached }));
|
||||
|
||||
try {
|
||||
await fetchWithAuth('/api/manga/history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, record }),
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaHistory', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allRecords = await getAllMangaReadRecords();
|
||||
allRecords[key] = record;
|
||||
localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(allRecords));
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: allRecords }));
|
||||
}
|
||||
|
||||
export async function deleteMangaReadRecord(sourceId: string, mangaId: string): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, mangaId);
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cached = cacheManager.getCachedMangaReadRecords() || {};
|
||||
delete cached[key];
|
||||
cacheManager.cacheMangaReadRecords(cached);
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: cached }));
|
||||
|
||||
try {
|
||||
await fetchWithAuth(`/api/manga/history?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaHistory', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allRecords = await getAllMangaReadRecords();
|
||||
delete allRecords[key];
|
||||
localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(allRecords));
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: allRecords }));
|
||||
}
|
||||
|
||||
export async function clearAllMangaReadRecords(): Promise<void> {
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
cacheManager.cacheMangaReadRecords({});
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: {} }));
|
||||
try {
|
||||
await fetchWithAuth('/api/manga/history', { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaHistory', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(MANGA_HISTORY_KEY);
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: {} }));
|
||||
}
|
||||
|
||||
// ---------------- 混合缓存辅助函数 ----------------
|
||||
|
||||
/**
|
||||
@@ -1542,10 +1834,12 @@ export async function refreshAllCache(): Promise<void> {
|
||||
// 使用 Promise 缓存防止并发重复刷新
|
||||
await cacheManager.getOrCreateRequest('refresh-all-cache', async () => {
|
||||
// 并行刷新所有数据
|
||||
const [playRecords, favorites, searchHistory, skipConfigs] =
|
||||
const [playRecords, favorites, mangaShelf, mangaHistory, searchHistory, skipConfigs] =
|
||||
await Promise.allSettled([
|
||||
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
||||
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
||||
fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`),
|
||||
fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`),
|
||||
fetchFromApi<string[]>(`/api/searchhistory`),
|
||||
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`),
|
||||
]);
|
||||
@@ -1568,6 +1862,24 @@ export async function refreshAllCache(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
if (mangaShelf.status === 'fulfilled') {
|
||||
cacheManager.cacheMangaShelf(mangaShelf.value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mangaShelfUpdated', {
|
||||
detail: mangaShelf.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (mangaHistory.status === 'fulfilled') {
|
||||
cacheManager.cacheMangaReadRecords(mangaHistory.value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mangaHistoryUpdated', {
|
||||
detail: mangaHistory.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (searchHistory.status === 'fulfilled') {
|
||||
cacheManager.cacheSearchHistory(searchHistory.value);
|
||||
window.dispatchEvent(
|
||||
@@ -1601,6 +1913,8 @@ export function getCacheStatus(): {
|
||||
hasFavorites: boolean;
|
||||
hasSearchHistory: boolean;
|
||||
hasSkipConfigs: boolean;
|
||||
hasMangaShelf: boolean;
|
||||
hasMangaHistory: boolean;
|
||||
username: string | null;
|
||||
} {
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
@@ -1609,6 +1923,8 @@ export function getCacheStatus(): {
|
||||
hasFavorites: false,
|
||||
hasSearchHistory: false,
|
||||
hasSkipConfigs: false,
|
||||
hasMangaShelf: false,
|
||||
hasMangaHistory: false,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
@@ -1619,6 +1935,8 @@ export function getCacheStatus(): {
|
||||
hasFavorites: !!cacheManager.getCachedFavorites(),
|
||||
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
|
||||
hasSkipConfigs: !!cacheManager.getCachedSkipConfigs(),
|
||||
hasMangaShelf: !!cacheManager.getCachedMangaShelf(),
|
||||
hasMangaHistory: !!cacheManager.getCachedMangaReadRecords(),
|
||||
username: authInfo?.username || null,
|
||||
};
|
||||
}
|
||||
@@ -1629,7 +1947,9 @@ export type CacheUpdateEvent =
|
||||
| 'playRecordsUpdated'
|
||||
| 'favoritesUpdated'
|
||||
| 'searchHistoryUpdated'
|
||||
| 'skipConfigsUpdated';
|
||||
| 'skipConfigsUpdated'
|
||||
| 'mangaShelfUpdated'
|
||||
| 'mangaHistoryUpdated';
|
||||
|
||||
/**
|
||||
* 用于 React 组件监听数据更新的事件监听器
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MusicPlayRecord } from './db.client';
|
||||
import { KvrocksStorage } from './kvrocks.db';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { RedisStorage } from './redis.db';
|
||||
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
@@ -726,6 +727,40 @@ export class DbManager {
|
||||
await this.storage.deleteSearchHistory(userName, keyword);
|
||||
}
|
||||
|
||||
// ---------- 漫画书架 ----------
|
||||
async getMangaShelf(userName: string, sourceId: string, mangaId: string): Promise<MangaShelfItem | null> {
|
||||
return this.storage.getMangaShelf(userName, generateStorageKey(sourceId, mangaId));
|
||||
}
|
||||
|
||||
async saveMangaShelf(userName: string, sourceId: string, mangaId: string, item: MangaShelfItem): Promise<void> {
|
||||
await this.storage.setMangaShelf(userName, generateStorageKey(sourceId, mangaId), item);
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
|
||||
return this.storage.getAllMangaShelf(userName);
|
||||
}
|
||||
|
||||
async deleteMangaShelf(userName: string, sourceId: string, mangaId: string): Promise<void> {
|
||||
await this.storage.deleteMangaShelf(userName, generateStorageKey(sourceId, mangaId));
|
||||
}
|
||||
|
||||
// ---------- 漫画阅读历史 ----------
|
||||
async getMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise<MangaReadRecord | null> {
|
||||
return this.storage.getMangaReadRecord(userName, generateStorageKey(sourceId, mangaId));
|
||||
}
|
||||
|
||||
async saveMangaReadRecord(userName: string, sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> {
|
||||
await this.storage.setMangaReadRecord(userName, generateStorageKey(sourceId, mangaId), record);
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> {
|
||||
return this.storage.getAllMangaReadRecords(userName);
|
||||
}
|
||||
|
||||
async deleteMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise<void> {
|
||||
await this.storage.deleteMangaReadRecord(userName, generateStorageKey(sourceId, mangaId));
|
||||
}
|
||||
|
||||
// 获取全部用户名
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
if (typeof (this.storage as any).getAllUsers === 'function') {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface MangaSource {
|
||||
id: string;
|
||||
name: string;
|
||||
lang?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface MangaSearchItem {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
}
|
||||
|
||||
export interface MangaChapter {
|
||||
id: string;
|
||||
mangaId: string;
|
||||
name: string;
|
||||
chapterNumber?: number;
|
||||
scanlator?: string;
|
||||
isRead?: boolean;
|
||||
isDownloaded?: boolean;
|
||||
pageCount?: number;
|
||||
uploadDate?: number;
|
||||
}
|
||||
|
||||
export interface MangaDetail extends MangaSearchItem {
|
||||
chapters: MangaChapter[];
|
||||
}
|
||||
|
||||
export interface MangaShelfItem {
|
||||
title: string;
|
||||
cover: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
mangaId: string;
|
||||
saveTime: number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status?: string;
|
||||
lastChapterId?: string;
|
||||
lastChapterName?: string;
|
||||
}
|
||||
|
||||
export interface MangaReadRecord {
|
||||
title: string;
|
||||
cover: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
mangaId: string;
|
||||
chapterId: string;
|
||||
chapterName: string;
|
||||
pageIndex: number;
|
||||
pageCount: number;
|
||||
saveTime: number;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MovieRequest,
|
||||
} from './types';
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { DatabaseAdapter } from './d1-adapter';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
|
||||
@@ -1747,6 +1748,268 @@ export class PostgresStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 漫画书架 ====================
|
||||
|
||||
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
title: result.title as string,
|
||||
cover: (result.cover as string) || '',
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
mangaId: result.manga_id as string,
|
||||
saveTime: Number(result.save_time || 0),
|
||||
description: (result.description as string) || undefined,
|
||||
author: (result.author as string) || undefined,
|
||||
status: (result.status as string) || undefined,
|
||||
lastChapterId: (result.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (result.last_chapter_name as string) || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO manga_shelf (
|
||||
username, key, source_id, source_name, manga_id, title, cover, save_time,
|
||||
description, author, status, last_chapter_id, last_chapter_name
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (username, key) DO UPDATE SET
|
||||
source_id = EXCLUDED.source_id,
|
||||
source_name = EXCLUDED.source_name,
|
||||
manga_id = EXCLUDED.manga_id,
|
||||
title = EXCLUDED.title,
|
||||
cover = EXCLUDED.cover,
|
||||
save_time = EXCLUDED.save_time,
|
||||
description = EXCLUDED.description,
|
||||
author = EXCLUDED.author,
|
||||
status = EXCLUDED.status,
|
||||
last_chapter_id = EXCLUDED.last_chapter_id,
|
||||
last_chapter_name = EXCLUDED.last_chapter_name
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
item.sourceId,
|
||||
item.sourceName,
|
||||
item.mangaId,
|
||||
item.title,
|
||||
item.cover || '',
|
||||
item.saveTime,
|
||||
item.description || null,
|
||||
item.author || null,
|
||||
item.status || null,
|
||||
item.lastChapterId || null,
|
||||
item.lastChapterName || null
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.setMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = $1 ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const shelves: { [key: string]: MangaShelfItem } = {};
|
||||
if (!results.results) return shelves;
|
||||
|
||||
for (const row of results.results) {
|
||||
shelves[row.key as string] = {
|
||||
title: row.title as string,
|
||||
cover: (row.cover as string) || '',
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
mangaId: row.manga_id as string,
|
||||
saveTime: Number(row.save_time || 0),
|
||||
description: (row.description as string) || undefined,
|
||||
author: (row.author as string) || undefined,
|
||||
status: (row.status as string) || undefined,
|
||||
lastChapterId: (row.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (row.last_chapter_name as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return shelves;
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getAllMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMangaShelf(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM manga_shelf WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.deleteMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 漫画阅读历史 ====================
|
||||
|
||||
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_read_records WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
title: result.title as string,
|
||||
cover: (result.cover as string) || '',
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
mangaId: result.manga_id as string,
|
||||
chapterId: result.chapter_id as string,
|
||||
chapterName: result.chapter_name as string,
|
||||
pageIndex: Number(result.page_index || 0),
|
||||
pageCount: Number(result.page_count || 0),
|
||||
saveTime: Number(result.save_time || 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO manga_read_records (
|
||||
username, key, source_id, source_name, manga_id, title, cover,
|
||||
chapter_id, chapter_name, page_index, page_count, save_time
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (username, key) DO UPDATE SET
|
||||
source_id = EXCLUDED.source_id,
|
||||
source_name = EXCLUDED.source_name,
|
||||
manga_id = EXCLUDED.manga_id,
|
||||
title = EXCLUDED.title,
|
||||
cover = EXCLUDED.cover,
|
||||
chapter_id = EXCLUDED.chapter_id,
|
||||
chapter_name = EXCLUDED.chapter_name,
|
||||
page_index = EXCLUDED.page_index,
|
||||
page_count = EXCLUDED.page_count,
|
||||
save_time = EXCLUDED.save_time
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
record.sourceId,
|
||||
record.sourceName,
|
||||
record.mangaId,
|
||||
record.title,
|
||||
record.cover || '',
|
||||
record.chapterId,
|
||||
record.chapterName,
|
||||
record.pageIndex,
|
||||
record.pageCount,
|
||||
record.saveTime
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.setMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_read_records WHERE username = $1 ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const records: { [key: string]: MangaReadRecord } = {};
|
||||
if (!results.results) return records;
|
||||
|
||||
for (const row of results.results) {
|
||||
records[row.key as string] = {
|
||||
title: row.title as string,
|
||||
cover: (row.cover as string) || '',
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
mangaId: row.manga_id as string,
|
||||
chapterId: row.chapter_id as string,
|
||||
chapterName: row.chapter_name as string,
|
||||
pageIndex: Number(row.page_index || 0),
|
||||
pageCount: Number(row.page_count || 0),
|
||||
saveTime: Number(row.save_time || 0),
|
||||
};
|
||||
}
|
||||
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getAllMangaReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM manga_read_records WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.deleteMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOldMangaReadRecords(userName: string): Promise<void> {
|
||||
try {
|
||||
const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10);
|
||||
const countResult = await this.db
|
||||
.prepare('SELECT COUNT(*) as count FROM manga_read_records WHERE username = $1')
|
||||
.bind(userName)
|
||||
.first();
|
||||
|
||||
const count = Number(countResult?.count || 0);
|
||||
if (count <= maxRecords) return;
|
||||
|
||||
await this.db
|
||||
.prepare(`
|
||||
DELETE FROM manga_read_records
|
||||
WHERE username = $1
|
||||
AND key NOT IN (
|
||||
SELECT key FROM manga_read_records
|
||||
WHERE username = $1
|
||||
ORDER BY save_time DESC
|
||||
LIMIT $2
|
||||
)
|
||||
`)
|
||||
.bind(userName, maxRecords)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.cleanupOldMangaReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 跳过配置 ====================
|
||||
|
||||
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> {
|
||||
@@ -2205,6 +2468,8 @@ export class PostgresStorage implements IStorage {
|
||||
'play_records',
|
||||
'favorites',
|
||||
'search_history',
|
||||
'manga_shelf',
|
||||
'manga_read_records',
|
||||
'skip_configs',
|
||||
'music_play_records',
|
||||
'music_playlists',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { RedisAdapter } from './redis-adapter';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
@@ -1005,6 +1006,10 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
// 删除收藏夹(新hash结构)
|
||||
await this.withRetry(() => this.adapter.del(this.favHashKey(userName)));
|
||||
|
||||
// 删除漫画书架与历史
|
||||
await this.withRetry(() => this.adapter.del(this.mangaShelfHashKey(userName)));
|
||||
await this.withRetry(() => this.adapter.del(this.mangaReadHashKey(userName)));
|
||||
|
||||
// 删除旧的收藏key(如果有)
|
||||
const favoritePattern = `u:${userName}:fav:*`;
|
||||
const favoriteKeys = await this.withRetry(() =>
|
||||
@@ -1495,6 +1500,73 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 漫画书架 ----------
|
||||
private mangaShelfHashKey(user: string) {
|
||||
return `u:${user}:manga:shelf`;
|
||||
}
|
||||
|
||||
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.mangaShelfHashKey(userName), key));
|
||||
return val ? (JSON.parse(val) as MangaShelfItem) : null;
|
||||
}
|
||||
|
||||
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.mangaShelfHashKey(userName), key, JSON.stringify(item)));
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<Record<string, MangaShelfItem>> {
|
||||
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaShelfHashKey(userName)));
|
||||
const result: Record<string, MangaShelfItem> = {};
|
||||
for (const [key, value] of Object.entries(hashData)) {
|
||||
if (value) result[key] = JSON.parse(value) as MangaShelfItem;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteMangaShelf(userName: string, key: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.mangaShelfHashKey(userName), key));
|
||||
}
|
||||
|
||||
// ---------- 漫画阅读历史 ----------
|
||||
private mangaReadHashKey(user: string) {
|
||||
return `u:${user}:manga:history`;
|
||||
}
|
||||
|
||||
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.mangaReadHashKey(userName), key));
|
||||
return val ? (JSON.parse(val) as MangaReadRecord) : null;
|
||||
}
|
||||
|
||||
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.mangaReadHashKey(userName), key, JSON.stringify(record)));
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<Record<string, MangaReadRecord>> {
|
||||
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaReadHashKey(userName)));
|
||||
const result: Record<string, MangaReadRecord> = {};
|
||||
for (const [key, value] of Object.entries(hashData)) {
|
||||
if (value) result[key] = JSON.parse(value) as MangaReadRecord;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.mangaReadHashKey(userName), key));
|
||||
}
|
||||
|
||||
async cleanupOldMangaReadRecords(userName: string): Promise<void> {
|
||||
const records = await this.getAllMangaReadRecords(userName);
|
||||
const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10);
|
||||
const keys = Object.entries(records)
|
||||
.sort(([, a], [, b]) => b.saveTime - a.saveTime)
|
||||
.slice(maxRecords)
|
||||
.map(([key]) => key);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await this.withRetry(() => this.adapter.hDel(this.mangaReadHashKey(userName), ...keys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
// 从新版用户列表获取
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { getConfig } from './config';
|
||||
import {
|
||||
MangaChapter,
|
||||
MangaDetail,
|
||||
MangaSearchItem,
|
||||
MangaSource,
|
||||
} from './manga.types';
|
||||
|
||||
interface GraphQLResponse<T> {
|
||||
data?: T;
|
||||
errors?: Array<{ message?: string }>;
|
||||
}
|
||||
|
||||
interface SuwayomiClientOptions {
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface ResolvedSuwayomiConfig {
|
||||
serverBaseUrl: string;
|
||||
serverUrl: string;
|
||||
token?: string;
|
||||
defaultLang: string;
|
||||
sourceIds: string[];
|
||||
maxSources: number;
|
||||
}
|
||||
|
||||
async function resolveSuwayomiConfig(options: SuwayomiClientOptions = {}): Promise<ResolvedSuwayomiConfig> {
|
||||
let serverUrl = options.serverUrl || process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '';
|
||||
let token = options.token || process.env.SUWAYOMI_AUTH_TOKEN || '';
|
||||
let defaultLang = process.env.SUWAYOMI_DEFAULT_LANG || 'zh';
|
||||
let sourceIds: string[] = [];
|
||||
let maxSources = Number(process.env.SUWAYOMI_MAX_SOURCES || 10);
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
if (config.SuwayomiConfig?.Enabled) {
|
||||
serverUrl = config.SuwayomiConfig.ServerURL || serverUrl;
|
||||
token = config.SuwayomiConfig.AuthToken || token;
|
||||
defaultLang = config.SuwayomiConfig.DefaultLang || defaultLang;
|
||||
sourceIds = config.SuwayomiConfig.SourceIds || sourceIds;
|
||||
maxSources = config.SuwayomiConfig.MaxSources || maxSources;
|
||||
}
|
||||
} catch {
|
||||
// 配置读取失败时回退到环境变量
|
||||
}
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error('Suwayomi 未配置,请先在管理面板或环境变量中设置服务地址');
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = serverUrl.replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
serverBaseUrl: normalizedBaseUrl,
|
||||
serverUrl: normalizedBaseUrl + '/api/graphql',
|
||||
token: token || undefined,
|
||||
defaultLang,
|
||||
sourceIds,
|
||||
maxSources,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSuwayomiConfig(options: SuwayomiClientOptions = {}): Promise<ResolvedSuwayomiConfig> {
|
||||
return resolveSuwayomiConfig(options);
|
||||
}
|
||||
|
||||
export function buildSuwayomiImageProxyUrl(pathOrUrl: string): string {
|
||||
if (!pathOrUrl) return '';
|
||||
if (pathOrUrl.startsWith('/api/manga/image?')) return pathOrUrl;
|
||||
return `/api/manga/image?path=${encodeURIComponent(pathOrUrl)}`;
|
||||
}
|
||||
|
||||
export class SuwayomiClient {
|
||||
private options: SuwayomiClientOptions;
|
||||
|
||||
constructor(options: SuwayomiClientOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private async graphqlRequest<T>(query: string, variables?: Record<string, any>, operationName?: string): Promise<T> {
|
||||
const resolved = await resolveSuwayomiConfig(this.options);
|
||||
const response = await fetch(resolved.serverUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(resolved.token ? { Authorization: `Bearer ${resolved.token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ query, variables, operationName }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Suwayomi 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GraphQLResponse<T>;
|
||||
if (data.errors?.length) {
|
||||
throw new Error(data.errors.map((item) => item.message || 'Unknown error').join('; '));
|
||||
}
|
||||
if (!data.data) {
|
||||
throw new Error('Suwayomi 返回空数据');
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async getSources(lang?: string): Promise<MangaSource[]> {
|
||||
const resolved = await resolveSuwayomiConfig(this.options);
|
||||
const query = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
lang
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await this.graphqlRequest<{
|
||||
sources?: { nodes?: Array<{ id: string; name?: string; lang?: string; displayName?: string }> };
|
||||
}>(query);
|
||||
|
||||
const nodes = data.sources?.nodes || [];
|
||||
const filtered = nodes.filter((item) => !lang || item.lang === lang);
|
||||
const scoped = resolved.sourceIds.length > 0
|
||||
? filtered.filter((item) => resolved.sourceIds.includes(String(item.id)))
|
||||
: filtered;
|
||||
|
||||
return scoped.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || item.displayName || String(item.id),
|
||||
lang: item.lang,
|
||||
displayName: item.displayName,
|
||||
}));
|
||||
}
|
||||
|
||||
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchItem[]> {
|
||||
const resolved = await resolveSuwayomiConfig(this.options);
|
||||
const sources = sourceId
|
||||
? [{ id: sourceId, displayName: sourceId, name: sourceId }]
|
||||
: (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources);
|
||||
const query = `
|
||||
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
|
||||
fetchSourceManga(input: $input) {
|
||||
mangas {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
sourceId
|
||||
description
|
||||
author
|
||||
artist
|
||||
genre
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const results: MangaSearchItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const source of sources) {
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchSourceManga?: {
|
||||
mangas?: Array<{
|
||||
id: string | number;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceId?: string | number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
query,
|
||||
{
|
||||
input: {
|
||||
type: 'SEARCH',
|
||||
source: source.id,
|
||||
query: keyword,
|
||||
page,
|
||||
},
|
||||
},
|
||||
'GET_SOURCE_MANGAS_FETCH'
|
||||
).catch(() => ({ fetchSourceManga: { mangas: [] } }));
|
||||
|
||||
const mangas = data.fetchSourceManga?.mangas || [];
|
||||
for (const manga of mangas) {
|
||||
const key = `${source.id}:${manga.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
results.push({
|
||||
id: String(manga.id),
|
||||
sourceId: String(manga.sourceId || source.id),
|
||||
sourceName: source.displayName || source.name || String(source.id),
|
||||
title: manga.title || '未命名漫画',
|
||||
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
||||
description: manga.description,
|
||||
author: manga.author,
|
||||
artist: manga.artist,
|
||||
genre: manga.genre,
|
||||
status: manga.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getChapters(mangaId: string): Promise<MangaChapter[]> {
|
||||
const mutation = `
|
||||
mutation GET_MANGA_CHAPTERS_FETCH($input: FetchChaptersInput!) {
|
||||
fetchChapters(input: $input) {
|
||||
chapters {
|
||||
id
|
||||
mangaId
|
||||
name
|
||||
chapterNumber
|
||||
scanlator
|
||||
isRead
|
||||
isDownloaded
|
||||
pageCount
|
||||
uploadDate
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchChapters?: {
|
||||
chapters?: Array<{
|
||||
id: string | number;
|
||||
mangaId?: string | number;
|
||||
name?: string;
|
||||
chapterNumber?: number;
|
||||
scanlator?: string;
|
||||
isRead?: boolean;
|
||||
isDownloaded?: boolean;
|
||||
pageCount?: number;
|
||||
uploadDate?: number;
|
||||
}>;
|
||||
};
|
||||
}>(mutation, { input: { mangaId: Number(mangaId) || mangaId } }, 'GET_MANGA_CHAPTERS_FETCH');
|
||||
|
||||
return (data.fetchChapters?.chapters || []).map((chapter) => ({
|
||||
id: String(chapter.id),
|
||||
mangaId: String(chapter.mangaId || mangaId),
|
||||
name: chapter.name || '未命名章节',
|
||||
chapterNumber: chapter.chapterNumber,
|
||||
scanlator: chapter.scanlator,
|
||||
isRead: chapter.isRead,
|
||||
isDownloaded: chapter.isDownloaded,
|
||||
pageCount: chapter.pageCount,
|
||||
uploadDate: chapter.uploadDate,
|
||||
}));
|
||||
}
|
||||
|
||||
async getMangaDetail(input: {
|
||||
mangaId: string;
|
||||
sourceId: string;
|
||||
title?: string;
|
||||
cover?: string;
|
||||
sourceName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status?: string;
|
||||
}): Promise<MangaDetail> {
|
||||
const chapters = await this.getChapters(input.mangaId);
|
||||
|
||||
let metadata: Partial<MangaSearchItem> = {
|
||||
id: input.mangaId,
|
||||
sourceId: input.sourceId,
|
||||
sourceName: input.sourceName || input.sourceId,
|
||||
title: input.title || '漫画详情',
|
||||
cover: input.cover || '',
|
||||
description: input.description,
|
||||
author: input.author,
|
||||
status: input.status,
|
||||
};
|
||||
|
||||
const detailQuery = `
|
||||
query MangaDetail($id: LongString!) {
|
||||
manga(id: $id) {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
sourceId
|
||||
description
|
||||
author
|
||||
artist
|
||||
genre
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const detailData = await this.graphqlRequest<{
|
||||
manga?: {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceId?: string | number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
status?: string;
|
||||
};
|
||||
}>(detailQuery, { id: input.mangaId }, 'MangaDetail');
|
||||
|
||||
if (detailData.manga) {
|
||||
metadata = {
|
||||
id: String(detailData.manga.id),
|
||||
sourceId: String(detailData.manga.sourceId || input.sourceId),
|
||||
sourceName: input.sourceName || input.sourceId,
|
||||
title: detailData.manga.title || metadata.title || '漫画详情',
|
||||
cover: buildSuwayomiImageProxyUrl(detailData.manga.thumbnailUrl || metadata.cover || ''),
|
||||
description: detailData.manga.description || metadata.description,
|
||||
author: detailData.manga.author || metadata.author,
|
||||
artist: detailData.manga.artist,
|
||||
genre: detailData.manga.genre,
|
||||
status: detailData.manga.status || metadata.status,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// 某些 Suwayomi 版本不支持直接 manga(id) 查询,降级为外部参数 + 章节信息
|
||||
}
|
||||
|
||||
return {
|
||||
id: metadata.id || input.mangaId,
|
||||
sourceId: metadata.sourceId || input.sourceId,
|
||||
sourceName: metadata.sourceName || input.sourceId,
|
||||
title: metadata.title || '漫画详情',
|
||||
cover: buildSuwayomiImageProxyUrl(metadata.cover || ''),
|
||||
description: metadata.description,
|
||||
author: metadata.author,
|
||||
artist: metadata.artist,
|
||||
genre: metadata.genre,
|
||||
status: metadata.status,
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
async getChapterPages(chapterId: string): Promise<string[]> {
|
||||
const mutation = `
|
||||
mutation GET_CHAPTER_PAGES_FETCH($input: FetchChapterPagesInput!) {
|
||||
fetchChapterPages(input: $input) {
|
||||
pages
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchChapterPages?: { pages?: string[] };
|
||||
}>(mutation, { input: { chapterId: Number(chapterId) || chapterId } }, 'GET_CHAPTER_PAGES_FETCH');
|
||||
|
||||
return (data.fetchChapterPages?.pages || []).map((item) => buildSuwayomiImageProxyUrl(item));
|
||||
}
|
||||
}
|
||||
|
||||
export const suwayomiClient = new SuwayomiClient();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
|
||||
// 播放记录数据结构
|
||||
export interface PlayRecord {
|
||||
@@ -75,6 +76,19 @@ export interface IStorage {
|
||||
addSearchHistory(userName: string, keyword: string): Promise<void>;
|
||||
deleteSearchHistory(userName: string, keyword?: string): Promise<void>;
|
||||
|
||||
// 漫画书架相关
|
||||
getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null>;
|
||||
setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void>;
|
||||
getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }>;
|
||||
deleteMangaShelf(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 漫画阅读历史相关
|
||||
getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null>;
|
||||
setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void>;
|
||||
getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }>;
|
||||
deleteMangaReadRecord(userName: string, key: string): Promise<void>;
|
||||
cleanupOldMangaReadRecords?(userName: string): Promise<void>;
|
||||
|
||||
// 用户列表
|
||||
getAllUsers(): Promise<string[]>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user