书架更新通知

This commit is contained in:
mtvpls
2026-04-18 00:48:17 +08:00
parent 7fb11f0ff7
commit 308650f771
16 changed files with 797 additions and 22 deletions
+4
View File
@@ -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
);
+9
View File
@@ -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,
+1
View File
@@ -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",
+3
View File
@@ -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])
+327 -1
View File
@@ -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);
}
};
// 分批并行处理用户,避免并发过高
+7 -5
View File
@@ -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;
+60 -3
View File
@@ -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}` : ''}
+47 -2
View File
@@ -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);
+14 -2
View File
@@ -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)}
+54 -1
View File
@@ -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'>
+11
View File
@@ -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
View File
@@ -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),
};
}
+136
View File
@@ -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>
`;
}
+4
View File
@@ -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
View File
@@ -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),
};
}
+1
View File
@@ -258,6 +258,7 @@ export interface EpisodeFilterConfig {
// 通知类型枚举
export type NotificationType =
| 'favorite_update' // 收藏更新
| 'manga_update' // 漫画更新
| 'system' // 系统通知
| 'announcement' // 公告
| 'movie_request' // 新求片通知(给管理员)