书架更新通知
This commit is contained in:
@@ -12,6 +12,10 @@ CREATE TABLE IF NOT EXISTS manga_shelf (
|
||||
status TEXT,
|
||||
last_chapter_id TEXT,
|
||||
last_chapter_name TEXT,
|
||||
latest_chapter_id TEXT,
|
||||
latest_chapter_name TEXT,
|
||||
latest_chapter_count INTEGER,
|
||||
unread_chapter_count INTEGER,
|
||||
PRIMARY KEY (username, key),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -12,12 +12,21 @@ CREATE TABLE IF NOT EXISTS manga_shelf (
|
||||
status TEXT,
|
||||
last_chapter_id TEXT,
|
||||
last_chapter_name TEXT,
|
||||
latest_chapter_id TEXT,
|
||||
latest_chapter_name TEXT,
|
||||
latest_chapter_count INTEGER,
|
||||
unread_chapter_count INTEGER,
|
||||
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);
|
||||
|
||||
ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_id TEXT;
|
||||
ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_name TEXT;
|
||||
ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_count INTEGER;
|
||||
ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS unread_chapter_count INTEGER;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manga_read_records (
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"redis": "^4.6.7",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swiper": "^11.2.8",
|
||||
|
||||
Generated
+3
@@ -137,6 +137,9 @@ importers:
|
||||
server-only:
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
socket.io:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.3([email protected])
|
||||
|
||||
@@ -6,13 +6,140 @@ import { checkAnimeSubscriptions } from '@/lib/anime-subscription';
|
||||
import { getConfig, refineConfig } from '@/lib/config';
|
||||
import { db, getStorage } from '@/lib/db';
|
||||
import { EmailService } from '@/lib/email.service';
|
||||
import { FavoriteUpdate,getBatchFavoriteUpdateEmailTemplate } from '@/lib/email.templates';
|
||||
import {
|
||||
FavoriteUpdate,
|
||||
MangaShelfUpdate,
|
||||
getBatchFavoriteUpdateEmailTemplate,
|
||||
getBatchMangaUpdateEmailTemplate,
|
||||
} from '@/lib/email.templates';
|
||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
import { MangaChapter, MangaShelfItem } from '@/lib/manga.types';
|
||||
import { startOpenListRefresh } from '@/lib/openlist-refresh';
|
||||
import { getSuwayomiConfig, loginWithSimpleAuth, SuwayomiClient } from '@/lib/suwayomi.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
const MAX_INLINE_MANGA_COVERS = 3;
|
||||
const MAX_INLINE_MANGA_COVER_BYTES = 350 * 1024;
|
||||
const TARGET_INLINE_MANGA_COVER_WIDTH = 480;
|
||||
|
||||
function buildSuwayomiBasicAuthHeader(username: string, password: string): string {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
async function fetchMangaCoverAsDataUri(coverUrl?: string): Promise<string | undefined> {
|
||||
if (!coverUrl) return undefined;
|
||||
|
||||
try {
|
||||
let requestUrl = coverUrl;
|
||||
let headers: HeadersInit | undefined;
|
||||
|
||||
if (!/^https?:\/\//i.test(coverUrl)) {
|
||||
if (!coverUrl.startsWith('/api/manga/image?')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = await getSuwayomiConfig();
|
||||
const parsedProxyUrl = new URL(`http://localhost${coverUrl}`);
|
||||
const rawPath = parsedProxyUrl.searchParams.get('path')?.trim();
|
||||
if (!rawPath) return undefined;
|
||||
|
||||
if (/^https?:\/\//i.test(rawPath)) {
|
||||
const target = new URL(rawPath);
|
||||
const base = new URL(config.serverBaseUrl);
|
||||
if (target.origin !== base.origin) {
|
||||
return undefined;
|
||||
}
|
||||
requestUrl = target.toString();
|
||||
} else {
|
||||
requestUrl = `${config.serverBaseUrl}${rawPath.startsWith('/') ? rawPath : `/${rawPath}`}`;
|
||||
}
|
||||
|
||||
if (config.authMode === 'basic_auth') {
|
||||
if (!config.username || !config.password) return undefined;
|
||||
headers = new Headers({
|
||||
Authorization: buildSuwayomiBasicAuthHeader(config.username, config.password),
|
||||
});
|
||||
} else if (config.authMode === 'simple_login') {
|
||||
headers = new Headers({
|
||||
Cookie: await loginWithSimpleAuth(config),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (!buffer.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let finalContentType = contentType;
|
||||
|
||||
if (buffer.length > MAX_INLINE_MANGA_COVER_BYTES) {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const transformer = sharp(buffer, { failOn: 'none' }).rotate().resize({
|
||||
width: TARGET_INLINE_MANGA_COVER_WIDTH,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
const metadata = await transformer.metadata();
|
||||
|
||||
if (metadata.hasAlpha) {
|
||||
buffer = await transformer.png({
|
||||
compressionLevel: 9,
|
||||
palette: true,
|
||||
quality: 80,
|
||||
effort: 10,
|
||||
}).toBuffer();
|
||||
finalContentType = 'image/png';
|
||||
} else {
|
||||
const qualities = [72, 60, 48];
|
||||
let compressed: Buffer | null = null;
|
||||
for (const quality of qualities) {
|
||||
const next = await sharp(buffer, { failOn: 'none' })
|
||||
.rotate()
|
||||
.resize({
|
||||
width: TARGET_INLINE_MANGA_COVER_WIDTH,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({
|
||||
quality,
|
||||
mozjpeg: true,
|
||||
})
|
||||
.toBuffer();
|
||||
compressed = next;
|
||||
if (next.length <= MAX_INLINE_MANGA_COVER_BYTES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
buffer = compressed || buffer;
|
||||
finalContentType = 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.length > MAX_INLINE_MANGA_COVER_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `data:${finalContentType};base64,${buffer.toString('base64')}`;
|
||||
} catch (error) {
|
||||
console.warn('漫画封面转 base64 失败:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 内存中记录最后执行时间(毫秒时间戳)
|
||||
let lastExecutionTime = 0;
|
||||
@@ -200,6 +327,11 @@ async function refreshRecordAndFavorites() {
|
||||
|
||||
// 函数级缓存:key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
|
||||
const detailCache = new Map<string, Promise<SearchResult | null>>();
|
||||
const mangaDetailCache = new Map<
|
||||
string,
|
||||
Promise<{ chapters: MangaChapter[]; shelfItem: Partial<MangaShelfItem> } | null>
|
||||
>();
|
||||
const suwayomiClient = new SuwayomiClient();
|
||||
|
||||
// 获取详情 Promise(带缓存和错误处理)
|
||||
const getDetail = async (
|
||||
@@ -230,6 +362,55 @@ async function refreshRecordAndFavorites() {
|
||||
return promise;
|
||||
};
|
||||
|
||||
const getMangaDetail = async (
|
||||
item: MangaShelfItem
|
||||
): Promise<{ chapters: MangaChapter[]; shelfItem: Partial<MangaShelfItem> } | null> => {
|
||||
const key = `${item.sourceId}+${item.mangaId}`;
|
||||
let promise = mangaDetailCache.get(key);
|
||||
if (!promise) {
|
||||
promise = suwayomiClient
|
||||
.getMangaDetail({
|
||||
mangaId: item.mangaId,
|
||||
sourceId: item.sourceId,
|
||||
title: item.title,
|
||||
cover: item.cover,
|
||||
sourceName: item.sourceName,
|
||||
description: item.description,
|
||||
author: item.author,
|
||||
status: item.status,
|
||||
})
|
||||
.then((detail) => {
|
||||
const chapters = [...(detail.chapters || [])].sort((a, b) => {
|
||||
const diff = (a.chapterNumber || 0) - (b.chapterNumber || 0);
|
||||
if (diff !== 0) return diff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
const latestChapter = chapters[chapters.length - 1];
|
||||
return {
|
||||
chapters,
|
||||
shelfItem: {
|
||||
title: detail.title || item.title,
|
||||
cover: detail.cover || item.cover,
|
||||
description: detail.description || item.description,
|
||||
author: detail.author || item.author,
|
||||
status: detail.status || item.status,
|
||||
latestChapterId: latestChapter?.id,
|
||||
latestChapterName: latestChapter?.name,
|
||||
latestChapterCount: chapters.length,
|
||||
},
|
||||
};
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`获取漫画详情失败 (${key}):`, err);
|
||||
mangaDetailCache.delete(key);
|
||||
return null;
|
||||
});
|
||||
mangaDetailCache.set(key, promise);
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
// 处理单个用户的函数
|
||||
const processUser = async (user: string) => {
|
||||
console.log(`开始处理用户: ${user}`);
|
||||
@@ -429,6 +610,151 @@ async function refreshRecordAndFavorites() {
|
||||
} catch (err) {
|
||||
console.error(`获取用户收藏失败 (${user}):`, err);
|
||||
}
|
||||
|
||||
// 漫画书架
|
||||
try {
|
||||
const shelf = await db.getAllMangaShelf(user);
|
||||
const totalShelfItems = Object.keys(shelf).length;
|
||||
let processedShelfItems = 0;
|
||||
const now = Date.now();
|
||||
const mangaUpdates: MangaShelfUpdate[] = [];
|
||||
let inlinedCoverCount = 0;
|
||||
|
||||
for (const [key, item] of Object.entries(shelf)) {
|
||||
try {
|
||||
const detail = await getMangaDetail(item);
|
||||
if (!detail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const latestChapterCount = detail.chapters.length;
|
||||
const previousChapterCount = item.latestChapterCount;
|
||||
const latestChapterId = detail.shelfItem.latestChapterId;
|
||||
const latestChapterName = detail.shelfItem.latestChapterName;
|
||||
const baseItem: MangaShelfItem = {
|
||||
...item,
|
||||
...detail.shelfItem,
|
||||
};
|
||||
|
||||
if (!latestChapterId || latestChapterCount <= 0) {
|
||||
await db.saveMangaShelf(user, item.sourceId, item.mangaId, {
|
||||
...baseItem,
|
||||
unreadChapterCount: item.unreadChapterCount ?? 0,
|
||||
});
|
||||
processedShelfItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 首次为老数据补齐基线,不触发通知
|
||||
if (!previousChapterCount || !item.latestChapterId) {
|
||||
await db.saveMangaShelf(user, item.sourceId, item.mangaId, {
|
||||
...baseItem,
|
||||
latestChapterId,
|
||||
latestChapterName,
|
||||
latestChapterCount,
|
||||
unreadChapterCount: item.unreadChapterCount ?? 0,
|
||||
});
|
||||
processedShelfItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const addedChapters = latestChapterCount - previousChapterCount;
|
||||
const hasNewChapters = addedChapters > 0 && latestChapterId !== item.latestChapterId;
|
||||
const nextUnreadChapterCount = hasNewChapters
|
||||
? Math.max((item.unreadChapterCount || 0) + addedChapters, 0)
|
||||
: item.unreadChapterCount ?? 0;
|
||||
|
||||
const nextItem: MangaShelfItem = {
|
||||
...baseItem,
|
||||
latestChapterId,
|
||||
latestChapterName,
|
||||
latestChapterCount,
|
||||
unreadChapterCount: nextUnreadChapterCount,
|
||||
};
|
||||
|
||||
if (hasNewChapters) {
|
||||
await storage.addNotification(user, {
|
||||
id: `manga_update_${item.sourceId}_${item.mangaId}_${now}`,
|
||||
type: 'manga_update',
|
||||
title: '漫画更新',
|
||||
message: `《${item.title}》新增 ${addedChapters} 话,已更新至 ${latestChapterName || '最新章节'}`,
|
||||
timestamp: now,
|
||||
read: false,
|
||||
metadata: {
|
||||
sourceId: item.sourceId,
|
||||
mangaId: item.mangaId,
|
||||
title: item.title,
|
||||
cover: detail.shelfItem.cover || item.cover,
|
||||
sourceName: item.sourceName,
|
||||
latestChapterId,
|
||||
latestChapterName,
|
||||
unreadChapterCount: nextUnreadChapterCount,
|
||||
},
|
||||
});
|
||||
|
||||
const inlineCover =
|
||||
inlinedCoverCount < MAX_INLINE_MANGA_COVERS
|
||||
? await fetchMangaCoverAsDataUri(detail.shelfItem.cover || item.cover)
|
||||
: undefined;
|
||||
if (inlineCover) {
|
||||
inlinedCoverCount++;
|
||||
}
|
||||
|
||||
mangaUpdates.push({
|
||||
title: item.title,
|
||||
previousChapterCount,
|
||||
latestChapterCount,
|
||||
latestChapterName,
|
||||
url: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/manga/detail?mangaId=${encodeURIComponent(item.mangaId)}&sourceId=${encodeURIComponent(item.sourceId)}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(detail.shelfItem.cover || item.cover || '')}&sourceName=${encodeURIComponent(item.sourceName)}`,
|
||||
cover: inlineCover,
|
||||
});
|
||||
}
|
||||
|
||||
await db.saveMangaShelf(user, item.sourceId, item.mangaId, nextItem);
|
||||
processedShelfItems++;
|
||||
} catch (err) {
|
||||
console.error(`处理漫画书架失败 (${key}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`漫画书架处理完成: ${processedShelfItems}/${totalShelfItems}`);
|
||||
|
||||
if (mangaUpdates.length > 0) {
|
||||
(async () => {
|
||||
try {
|
||||
const userEmail = storage.getUserEmail ? await storage.getUserEmail(user) : null;
|
||||
const emailNotifications = storage.getEmailNotificationPreference
|
||||
? await storage.getEmailNotificationPreference(user)
|
||||
: false;
|
||||
|
||||
if (userEmail && emailNotifications) {
|
||||
const config = await getConfig();
|
||||
const emailConfig = config?.EmailConfig;
|
||||
|
||||
if (emailConfig?.enabled) {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
const siteName = config?.SiteConfig?.SiteName || 'MoonTVPlus';
|
||||
|
||||
await EmailService.send(emailConfig, {
|
||||
to: userEmail,
|
||||
subject: `漫画书架更新汇总 - ${mangaUpdates.length} 部漫画有新章节`,
|
||||
html: getBatchMangaUpdateEmailTemplate(
|
||||
user,
|
||||
mangaUpdates,
|
||||
siteUrl,
|
||||
siteName
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error(`发送漫画更新邮件失败 (${user}):`, emailError);
|
||||
}
|
||||
})().catch((err) => console.error(`漫画更新邮件异步任务失败 (${user}):`, err));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`获取用户漫画书架失败 (${user}):`, err);
|
||||
}
|
||||
};
|
||||
|
||||
// 分批并行处理用户,避免并发过高
|
||||
|
||||
@@ -34,21 +34,23 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const config = await getSuwayomiConfig();
|
||||
const upstreamUrl = resolveUpstreamUrl(config.serverBaseUrl, pathOrUrl);
|
||||
const buildHeaders = async (forceRelogin: boolean) => {
|
||||
const buildHeaders = async (
|
||||
forceRelogin: boolean
|
||||
): Promise<HeadersInit | undefined> => {
|
||||
if (config.authMode === 'basic_auth') {
|
||||
if (!config.username || !config.password) {
|
||||
throw new Error('Suwayomi basic_auth 缺少用户名或密码');
|
||||
}
|
||||
|
||||
return {
|
||||
return new Headers({
|
||||
Authorization: `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (config.authMode === 'simple_login') {
|
||||
return {
|
||||
return new Headers({
|
||||
Cookie: await loginWithSimpleAuth(config, forceRelogin),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { deleteMangaShelf, getAllMangaReadRecords, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
|
||||
import { MangaChapter, MangaDetail, MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
|
||||
@@ -76,6 +76,7 @@ export default function MangaDetailPage() {
|
||||
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
|
||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||
const [descOrder, setDescOrder] = useState(true);
|
||||
const clearedOnOpenRef = useRef<string | null>(null);
|
||||
|
||||
const key = `${sourceId}+${mangaId}`;
|
||||
const currentRecord = history[key];
|
||||
@@ -111,6 +112,45 @@ export default function MangaDetailPage() {
|
||||
});
|
||||
}, [detail?.chapters, descOrder]);
|
||||
|
||||
const chronologicalChapters = useMemo(() => {
|
||||
const list = detail?.chapters || [];
|
||||
return [...list].sort((a, b) => {
|
||||
const diff = (a.chapterNumber || 0) - (b.chapterNumber || 0);
|
||||
if (diff !== 0) return diff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}, [detail?.chapters]);
|
||||
|
||||
const latestChapter = chronologicalChapters[chronologicalChapters.length - 1];
|
||||
const unreadChapterCount = shelf[key]?.unreadChapterCount || 0;
|
||||
const newChapterIds = useMemo(() => {
|
||||
if (unreadChapterCount <= 0) return new Set<string>();
|
||||
return new Set(chronologicalChapters.slice(-unreadChapterCount).map((chapter) => chapter.id));
|
||||
}, [chronologicalChapters, unreadChapterCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const shelfItem = shelf[key];
|
||||
if (!detail || !shelfItem || !latestChapter || (shelfItem.unreadChapterCount || 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearedOnOpenRef.current === key) {
|
||||
return;
|
||||
}
|
||||
clearedOnOpenRef.current = key;
|
||||
|
||||
const nextItem: MangaShelfItem = {
|
||||
...shelfItem,
|
||||
latestChapterId: latestChapter.id,
|
||||
latestChapterName: latestChapter.name,
|
||||
latestChapterCount: chronologicalChapters.length,
|
||||
unreadChapterCount: 0,
|
||||
};
|
||||
|
||||
// 只后台清零,当前页保留进入时看到的更新提示,刷新后再消失。
|
||||
saveMangaShelf(sourceId, mangaId, nextItem).catch(() => undefined);
|
||||
}, [chronologicalChapters.length, detail, key, latestChapter, mangaId, shelf, sourceId]);
|
||||
|
||||
const toggleShelf = async () => {
|
||||
if (!detail) return;
|
||||
if (shelf[key]) {
|
||||
@@ -135,6 +175,10 @@ export default function MangaDetailPage() {
|
||||
status: detail.status,
|
||||
lastChapterId: currentRecord?.chapterId,
|
||||
lastChapterName: currentRecord?.chapterName,
|
||||
latestChapterId: latestChapter?.id,
|
||||
latestChapterName: latestChapter?.name,
|
||||
latestChapterCount: chronologicalChapters.length,
|
||||
unreadChapterCount: 0,
|
||||
};
|
||||
await saveMangaShelf(sourceId, mangaId, item);
|
||||
setShelf((prev) => ({ ...prev, [key]: item }));
|
||||
@@ -196,16 +240,29 @@ export default function MangaDetailPage() {
|
||||
{descOrder ? <ArrowDownWideNarrow className='inline h-4 w-4' /> : <ArrowUpWideNarrow className='inline h-4 w-4' />} {descOrder ? '倒序' : '正序'}
|
||||
</button>
|
||||
</div>
|
||||
{unreadChapterCount > 0 && latestChapter && (
|
||||
<div className='mb-4 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-300'>
|
||||
已更新 {unreadChapterCount} 话,最新章节:{latestChapter.name}
|
||||
</div>
|
||||
)}
|
||||
<div className='grid gap-3'>
|
||||
{chapters.map((chapter) => {
|
||||
const active = currentRecord?.chapterId === chapter.id;
|
||||
const isNewChapter = newChapterIds.has(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'}`}
|
||||
className={`rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-400 bg-sky-50 dark:bg-sky-950/30' : isNewChapter ? 'border-emerald-300 bg-emerald-50/80 dark:border-emerald-800 dark:bg-emerald-950/20' : '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='flex items-center justify-between gap-3'>
|
||||
<div className='font-medium text-gray-900 dark:text-gray-100'>{chapter.name}</div>
|
||||
{isNewChapter && (
|
||||
<span className='rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-300'>
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>
|
||||
{formatChapterMeta(chapter)}
|
||||
{active && currentRecord ? ` · 上次看到第 ${currentRecord.pageIndex + 1} 页` : ''}
|
||||
|
||||
@@ -5,8 +5,8 @@ import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { type MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getAllMangaReadRecords, saveMangaReadRecord } from '@/lib/db.client';
|
||||
import type { MangaChapter, MangaDetail, MangaReadRecord } from '@/lib/manga.types';
|
||||
import { getAllMangaReadRecords, getAllMangaShelf, saveMangaReadRecord, saveMangaShelf } from '@/lib/db.client';
|
||||
import type { MangaChapter, MangaDetail, MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
@@ -478,6 +478,51 @@ export default function MangaReadPage() {
|
||||
};
|
||||
}, [chapterId, mangaId, readMode, sourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mangaId || !sourceId || !chapterId || !mangaDetail) return;
|
||||
|
||||
const key = `${sourceId}+${mangaId}`;
|
||||
const orderedChapters = [...(mangaDetail.chapters || [])].sort((a, b) => {
|
||||
const diff = (a.chapterNumber || 0) - (b.chapterNumber || 0);
|
||||
if (diff !== 0) return diff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const latestChapter = orderedChapters[orderedChapters.length - 1];
|
||||
const currentChapterIndex = orderedChapters.findIndex((chapter) => chapter.id === chapterId);
|
||||
const nextUnreadChapterCount =
|
||||
currentChapterIndex >= 0
|
||||
? Math.max(orderedChapters.length - currentChapterIndex - 1, 0)
|
||||
: undefined;
|
||||
|
||||
getAllMangaShelf()
|
||||
.then(async (shelf) => {
|
||||
const item = shelf[key];
|
||||
if (!item) return;
|
||||
|
||||
const nextItem: MangaShelfItem = {
|
||||
...item,
|
||||
lastChapterId: chapterId,
|
||||
lastChapterName: chapterName,
|
||||
latestChapterId: latestChapter?.id || item.latestChapterId,
|
||||
latestChapterName: latestChapter?.name || item.latestChapterName,
|
||||
latestChapterCount: orderedChapters.length || item.latestChapterCount,
|
||||
unreadChapterCount: nextUnreadChapterCount,
|
||||
};
|
||||
|
||||
const changed =
|
||||
nextItem.lastChapterId !== item.lastChapterId ||
|
||||
nextItem.lastChapterName !== item.lastChapterName ||
|
||||
nextItem.latestChapterId !== item.latestChapterId ||
|
||||
nextItem.latestChapterName !== item.latestChapterName ||
|
||||
nextItem.latestChapterCount !== item.latestChapterCount ||
|
||||
nextItem.unreadChapterCount !== item.unreadChapterCount;
|
||||
|
||||
if (!changed) return;
|
||||
await saveMangaShelf(sourceId, mangaId, nextItem);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [chapterId, chapterName, mangaDetail, mangaId, sourceId]);
|
||||
|
||||
const hideTransientUi = () => {
|
||||
setControlsVisible(false);
|
||||
setSettingsOpen(false);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { deleteMangaShelf, getAllMangaShelf } from '@/lib/db.client';
|
||||
import { deleteMangaShelf, getAllMangaShelf, subscribeToDataUpdates } from '@/lib/db.client';
|
||||
import { MangaShelfItem } from '@/lib/manga.types';
|
||||
|
||||
import MangaCard from '@/components/MangaCard';
|
||||
@@ -32,10 +32,17 @@ export default function MangaShelfPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToDataUpdates<Record<string, MangaShelfItem>>(
|
||||
'mangaShelfUpdated',
|
||||
setShelf
|
||||
);
|
||||
|
||||
getAllMangaShelf()
|
||||
.then(setShelf)
|
||||
.catch(() => undefined)
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const shelfList = useMemo(
|
||||
@@ -71,7 +78,12 @@ export default function MangaShelfPage() {
|
||||
<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}
|
||||
subtitle={
|
||||
item.unreadChapterCount && item.unreadChapterCount > 0
|
||||
? `更新至 ${item.latestChapterName || '最新章节'} · 新增 ${item.unreadChapterCount} 话`
|
||||
: item.lastChapterName || item.author || item.status
|
||||
}
|
||||
updateCount={item.unreadChapterCount}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeItem(item.sourceId, item.mangaId)}
|
||||
|
||||
@@ -12,9 +12,10 @@ interface MangaCardProps {
|
||||
href: string;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
updateCount?: number;
|
||||
}
|
||||
|
||||
export default function MangaCard({ item, href, subtitle, badge }: MangaCardProps) {
|
||||
export default function MangaCard({ item, href, subtitle, badge, updateCount }: MangaCardProps) {
|
||||
const sourceName = useMemo(() => {
|
||||
if ('sourceName' in item) return item.sourceName;
|
||||
return '';
|
||||
@@ -40,6 +41,58 @@ export default function MangaCard({ item, href, subtitle, badge }: MangaCardProp
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{updateCount && updateCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
zIndex: 20,
|
||||
pointerEvents: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgb(14 165 233)',
|
||||
animation: 'ping-scale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgb(14 165 233)',
|
||||
animation: 'pulse-scale 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
background:
|
||||
'linear-gradient(to bottom right, rgb(14 165 233), rgb(2 132 199))',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow:
|
||||
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
animation: 'badge-scale 2s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
+{updateCount}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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'>
|
||||
|
||||
@@ -122,6 +122,17 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
const { source, id, title } = notification.metadata;
|
||||
router.push(`/play?source=${source}&id=${id}&title=${encodeURIComponent(title)}`);
|
||||
onClose();
|
||||
} else if (notification.type === 'manga_update' && notification.metadata) {
|
||||
const { sourceId, mangaId, title, cover, sourceName } = notification.metadata;
|
||||
const params = new URLSearchParams({
|
||||
sourceId,
|
||||
mangaId,
|
||||
title: title || '',
|
||||
cover: cover || '',
|
||||
sourceName: sourceName || '',
|
||||
});
|
||||
router.push(`/manga/detail?${params.toString()}`);
|
||||
onClose();
|
||||
} else if (notification.type === 'movie_request') {
|
||||
// 获取用户角色
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
|
||||
+61
-4
@@ -36,14 +36,39 @@ import { userInfoCache } from './user-cache';
|
||||
*/
|
||||
export class D1Storage implements IStorage {
|
||||
private db: DatabaseAdapter;
|
||||
private schemaReady: Promise<void>;
|
||||
public adapter: RedisHashAdapter;
|
||||
|
||||
constructor(adapter: DatabaseAdapter) {
|
||||
this.db = adapter;
|
||||
this.schemaReady = this.ensureMangaShelfColumns();
|
||||
// 创建 Redis Hash 兼容适配器用于设备管理
|
||||
this.adapter = new RedisHashAdapter(adapter);
|
||||
}
|
||||
|
||||
private async ensureMangaShelfColumns(): Promise<void> {
|
||||
const statements = [
|
||||
'ALTER TABLE manga_shelf ADD COLUMN latest_chapter_id TEXT',
|
||||
'ALTER TABLE manga_shelf ADD COLUMN latest_chapter_name TEXT',
|
||||
'ALTER TABLE manga_shelf ADD COLUMN latest_chapter_count INTEGER',
|
||||
'ALTER TABLE manga_shelf ADD COLUMN unread_chapter_count INTEGER',
|
||||
];
|
||||
|
||||
for (const statement of statements) {
|
||||
try {
|
||||
const result = await this.db.prepare(statement).run();
|
||||
if (!result.success && result.error && !/duplicate column|already exists/i.test(result.error)) {
|
||||
console.warn('D1Storage.ensureMangaShelfColumns warning:', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!/duplicate column|already exists|no such table/i.test(message)) {
|
||||
console.warn('D1Storage.ensureMangaShelfColumns warning:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 播放记录 ====================
|
||||
|
||||
async getPlayRecord(userName: string, key: string): Promise<PlayRecord | null> {
|
||||
@@ -1780,6 +1805,7 @@ export class D1Storage implements IStorage {
|
||||
|
||||
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
|
||||
try {
|
||||
await this.schemaReady;
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
@@ -1798,6 +1824,16 @@ export class D1Storage implements IStorage {
|
||||
status: (result.status as string) || undefined,
|
||||
lastChapterId: (result.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (result.last_chapter_name as string) || undefined,
|
||||
latestChapterId: (result.latest_chapter_id as string) || undefined,
|
||||
latestChapterName: (result.latest_chapter_name as string) || undefined,
|
||||
latestChapterCount:
|
||||
result.latest_chapter_count === null || result.latest_chapter_count === undefined
|
||||
? undefined
|
||||
: Number(result.latest_chapter_count),
|
||||
unreadChapterCount:
|
||||
result.unread_chapter_count === null || result.unread_chapter_count === undefined
|
||||
? undefined
|
||||
: Number(result.unread_chapter_count),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getMangaShelf error:', err);
|
||||
@@ -1807,13 +1843,15 @@ export class D1Storage implements IStorage {
|
||||
|
||||
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
|
||||
try {
|
||||
await this.schemaReady;
|
||||
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
|
||||
description, author, status, last_chapter_id, last_chapter_name,
|
||||
latest_chapter_id, latest_chapter_name, latest_chapter_count, unread_chapter_count
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, key) DO UPDATE SET
|
||||
source_id = excluded.source_id,
|
||||
source_name = excluded.source_name,
|
||||
@@ -1825,7 +1863,11 @@ export class D1Storage implements IStorage {
|
||||
author = excluded.author,
|
||||
status = excluded.status,
|
||||
last_chapter_id = excluded.last_chapter_id,
|
||||
last_chapter_name = excluded.last_chapter_name
|
||||
last_chapter_name = excluded.last_chapter_name,
|
||||
latest_chapter_id = excluded.latest_chapter_id,
|
||||
latest_chapter_name = excluded.latest_chapter_name,
|
||||
latest_chapter_count = excluded.latest_chapter_count,
|
||||
unread_chapter_count = excluded.unread_chapter_count
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
@@ -1840,7 +1882,11 @@ export class D1Storage implements IStorage {
|
||||
item.author || null,
|
||||
item.status || null,
|
||||
item.lastChapterId || null,
|
||||
item.lastChapterName || null
|
||||
item.lastChapterName || null,
|
||||
item.latestChapterId || null,
|
||||
item.latestChapterName || null,
|
||||
item.latestChapterCount ?? null,
|
||||
item.unreadChapterCount ?? null
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
@@ -1851,6 +1897,7 @@ export class D1Storage implements IStorage {
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
|
||||
try {
|
||||
await this.schemaReady;
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = ? ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
@@ -1872,6 +1919,16 @@ export class D1Storage implements IStorage {
|
||||
status: (row.status as string) || undefined,
|
||||
lastChapterId: (row.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (row.last_chapter_name as string) || undefined,
|
||||
latestChapterId: (row.latest_chapter_id as string) || undefined,
|
||||
latestChapterName: (row.latest_chapter_name as string) || undefined,
|
||||
latestChapterCount:
|
||||
row.latest_chapter_count === null || row.latest_chapter_count === undefined
|
||||
? undefined
|
||||
: Number(row.latest_chapter_count),
|
||||
unreadChapterCount:
|
||||
row.unread_chapter_count === null || row.unread_chapter_count === undefined
|
||||
? undefined
|
||||
: Number(row.unread_chapter_count),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,15 @@ export interface FavoriteUpdate {
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
export interface MangaShelfUpdate {
|
||||
title: string;
|
||||
previousChapterCount: number;
|
||||
latestChapterCount: number;
|
||||
latestChapterName?: string;
|
||||
url: string;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏更新邮件模板
|
||||
*/
|
||||
@@ -263,3 +272,130 @@ export function getBatchFavoriteUpdateEmailTemplate(
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function getBatchMangaUpdateEmailTemplate(
|
||||
userName: string,
|
||||
updates: MangaShelfUpdate[],
|
||||
siteUrl: string,
|
||||
siteName?: string
|
||||
): string {
|
||||
const totalUpdates = updates.length;
|
||||
const totalNewChapters = updates.reduce(
|
||||
(sum, item) => sum + Math.max(item.latestChapterCount - item.previousChapterCount, 0),
|
||||
0
|
||||
);
|
||||
|
||||
const updatesList = updates
|
||||
.map(
|
||||
(item) => `
|
||||
<div style="margin: 15px 0; padding: 15px; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<div style="display: flex; align-items: center; gap: 15px;">
|
||||
${
|
||||
item.cover
|
||||
? `<img src="${item.cover}" alt="${item.title}" style="width: 80px; height: 120px; object-fit: cover; border-radius: 5px;" />`
|
||||
: ''
|
||||
}
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 16px; font-weight: bold; margin-bottom: 8px;">${item.title}</div>
|
||||
<div style="color: #666; margin-bottom: 6px;">
|
||||
${item.previousChapterCount} 话 → <span style="color: #2563eb; font-weight: bold;">${item.latestChapterCount} 话</span>
|
||||
<span style="color: #10b981; font-weight: bold;">(+${Math.max(item.latestChapterCount - item.previousChapterCount, 0)})</span>
|
||||
</div>
|
||||
${
|
||||
item.latestChapterName
|
||||
? `<div style="color: #666; margin-bottom: 10px;">最新章节:${item.latestChapterName}</div>`
|
||||
: ''
|
||||
}
|
||||
<a href="${item.url}" style="display: inline-block; padding: 6px 12px; background: #2563eb; color: white; text-decoration: none; border-radius: 5px; font-size: 13px;">查看详情</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header .stats {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
.content {
|
||||
padding: 30px 20px;
|
||||
background: white;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.footer {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
background: white;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
.footer a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>漫画书架更新汇总</h1>
|
||||
<div class="stats">
|
||||
${totalUpdates} 部漫画更新 · 共 ${totalNewChapters} 话新内容
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
Hi <strong>${userName}</strong>,
|
||||
</div>
|
||||
<p style="color: #666; margin-bottom: 20px;">您书架中的漫画有以下更新:</p>
|
||||
${updatesList}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由 <a href="${siteUrl}">${siteName || 'MoonTVPlus'}</a> 自动发送</p>
|
||||
<p>如不想接收此类邮件,请在用户设置中关闭邮件通知</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface MangaShelfItem {
|
||||
status?: string;
|
||||
lastChapterId?: string;
|
||||
lastChapterName?: string;
|
||||
latestChapterId?: string;
|
||||
latestChapterName?: string;
|
||||
latestChapterCount?: number;
|
||||
unreadChapterCount?: number;
|
||||
}
|
||||
|
||||
export interface MangaReadRecord {
|
||||
|
||||
+58
-4
@@ -37,14 +37,36 @@ import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from
|
||||
*/
|
||||
export class PostgresStorage implements IStorage {
|
||||
private db: DatabaseAdapter;
|
||||
private schemaReady: Promise<void>;
|
||||
public adapter: any; // 用于兼容
|
||||
|
||||
constructor(adapter: DatabaseAdapter) {
|
||||
this.db = adapter;
|
||||
this.schemaReady = this.ensureMangaShelfColumns();
|
||||
// 创建一个简单的适配器用于设备管理
|
||||
this.adapter = new PostgresRedisHashAdapter(adapter);
|
||||
}
|
||||
|
||||
private async ensureMangaShelfColumns(): Promise<void> {
|
||||
const statements = [
|
||||
'ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_id TEXT',
|
||||
'ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_name TEXT',
|
||||
'ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_count INTEGER',
|
||||
'ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS unread_chapter_count INTEGER',
|
||||
];
|
||||
|
||||
for (const statement of statements) {
|
||||
try {
|
||||
const result = await this.db.prepare(statement).run();
|
||||
if (!result.success && result.error) {
|
||||
console.warn('PostgresStorage.ensureMangaShelfColumns warning:', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('PostgresStorage.ensureMangaShelfColumns warning:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 播放记录 ====================
|
||||
|
||||
async getPlayRecord(userName: string, key: string): Promise<PlayRecord | null> {
|
||||
@@ -1752,6 +1774,7 @@ export class PostgresStorage implements IStorage {
|
||||
|
||||
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
|
||||
try {
|
||||
await this.schemaReady;
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
@@ -1770,6 +1793,16 @@ export class PostgresStorage implements IStorage {
|
||||
status: (result.status as string) || undefined,
|
||||
lastChapterId: (result.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (result.last_chapter_name as string) || undefined,
|
||||
latestChapterId: (result.latest_chapter_id as string) || undefined,
|
||||
latestChapterName: (result.latest_chapter_name as string) || undefined,
|
||||
latestChapterCount:
|
||||
result.latest_chapter_count === null || result.latest_chapter_count === undefined
|
||||
? undefined
|
||||
: Number(result.latest_chapter_count),
|
||||
unreadChapterCount:
|
||||
result.unread_chapter_count === null || result.unread_chapter_count === undefined
|
||||
? undefined
|
||||
: Number(result.unread_chapter_count),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getMangaShelf error:', err);
|
||||
@@ -1779,13 +1812,15 @@ export class PostgresStorage implements IStorage {
|
||||
|
||||
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
|
||||
try {
|
||||
await this.schemaReady;
|
||||
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
|
||||
description, author, status, last_chapter_id, last_chapter_name,
|
||||
latest_chapter_id, latest_chapter_name, latest_chapter_count, unread_chapter_count
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
ON CONFLICT (username, key) DO UPDATE SET
|
||||
source_id = EXCLUDED.source_id,
|
||||
source_name = EXCLUDED.source_name,
|
||||
@@ -1797,7 +1832,11 @@ export class PostgresStorage implements IStorage {
|
||||
author = EXCLUDED.author,
|
||||
status = EXCLUDED.status,
|
||||
last_chapter_id = EXCLUDED.last_chapter_id,
|
||||
last_chapter_name = EXCLUDED.last_chapter_name
|
||||
last_chapter_name = EXCLUDED.last_chapter_name,
|
||||
latest_chapter_id = EXCLUDED.latest_chapter_id,
|
||||
latest_chapter_name = EXCLUDED.latest_chapter_name,
|
||||
latest_chapter_count = EXCLUDED.latest_chapter_count,
|
||||
unread_chapter_count = EXCLUDED.unread_chapter_count
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
@@ -1812,7 +1851,11 @@ export class PostgresStorage implements IStorage {
|
||||
item.author || null,
|
||||
item.status || null,
|
||||
item.lastChapterId || null,
|
||||
item.lastChapterName || null
|
||||
item.lastChapterName || null,
|
||||
item.latestChapterId || null,
|
||||
item.latestChapterName || null,
|
||||
item.latestChapterCount ?? null,
|
||||
item.unreadChapterCount ?? null
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
@@ -1823,6 +1866,7 @@ export class PostgresStorage implements IStorage {
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
|
||||
try {
|
||||
await this.schemaReady;
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = $1 ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
@@ -1844,6 +1888,16 @@ export class PostgresStorage implements IStorage {
|
||||
status: (row.status as string) || undefined,
|
||||
lastChapterId: (row.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (row.last_chapter_name as string) || undefined,
|
||||
latestChapterId: (row.latest_chapter_id as string) || undefined,
|
||||
latestChapterName: (row.latest_chapter_name as string) || undefined,
|
||||
latestChapterCount:
|
||||
row.latest_chapter_count === null || row.latest_chapter_count === undefined
|
||||
? undefined
|
||||
: Number(row.latest_chapter_count),
|
||||
unreadChapterCount:
|
||||
row.unread_chapter_count === null || row.unread_chapter_count === undefined
|
||||
? undefined
|
||||
: Number(row.unread_chapter_count),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,7 @@ export interface EpisodeFilterConfig {
|
||||
// 通知类型枚举
|
||||
export type NotificationType =
|
||||
| 'favorite_update' // 收藏更新
|
||||
| 'manga_update' // 漫画更新
|
||||
| 'system' // 系统通知
|
||||
| 'announcement' // 公告
|
||||
| 'movie_request' // 新求片通知(给管理员)
|
||||
|
||||
Reference in New Issue
Block a user