增强追番订阅:关键词表达式、字幕组快捷、右键添加与缺集补搜

- 过滤/排除支持 & | (),兼容旧逗号语义
- 字幕组快捷单选填入;同名订阅拒绝重复
- 单集只下一次、缺集按「番名+补零集数」重搜
- VideoCard 管理员可添加追番;PlayRecord 增加 is_anime 及迁移
This commit is contained in:
mtvpls
2026-07-26 14:48:58 +08:00
parent ca798177b9
commit 333c482e19
23 changed files with 1433 additions and 103 deletions
+321
View File
@@ -0,0 +1,321 @@
'use client';
import { Loader2, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
ANIME_EXCLUDE_PRESETS,
ANIME_FANSUB_PRESETS,
applyExcludeSingleSelect,
applyFansubSingleSelect,
isExcludePresetActive,
isFansubPresetActive,
type AnimeExcludePreset,
type AnimeFansubPreset,
} from '@/lib/anime-filter-presets';
export interface AnimeSubscribeModalProps {
isOpen: boolean;
onClose: () => void;
/** 预填番剧名(搜索词) */
initialTitle: string;
/** 继续观看时可预填已看集数 */
initialLastEpisode?: number;
onSuccess?: () => void;
}
type SourceType = 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
/**
* VideoCard / 管理入口共用的「添加追番订阅」轻量弹层(仅 admin API)
*/
export default function AnimeSubscribeModal({
isOpen,
onClose,
initialTitle,
initialLastEpisode = 0,
onSuccess,
}: AnimeSubscribeModalProps) {
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
title: '',
filterText: '',
excludeText: '',
source: 'mikan' as SourceType,
lastEpisode: 0,
enabled: true,
onePerEpisode: false,
refillMissingEpisodes: false,
});
useEffect(() => {
if (isOpen) {
setVisible(true);
setError('');
setForm({
title: initialTitle || '',
filterText: '',
excludeText: '',
source: 'mikan',
lastEpisode:
typeof initialLastEpisode === 'number' && initialLastEpisode > 0
? initialLastEpisode
: 0,
enabled: true,
onePerEpisode: false,
refillMissingEpisodes: false,
});
} else {
setVisible(false);
}
}, [isOpen, initialTitle, initialLastEpisode]);
if (!isOpen) return null;
const chipClass = (active: boolean) =>
`px-2 py-0.5 text-xs rounded-full border transition-colors ${
active
? 'bg-green-600 text-white border-green-600'
: 'bg-gray-50 dark:bg-gray-700/60 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600'
}`;
const handleFansubSelect = (preset: AnimeFansubPreset) => {
setForm((prev) => ({
...prev,
filterText: applyFansubSingleSelect(prev.filterText, preset),
}));
};
const handleExcludeSelect = (preset: AnimeExcludePreset) => {
setForm((prev) => ({
...prev,
excludeText: applyExcludeSingleSelect(prev.excludeText, preset),
}));
};
const handleSubmit = async () => {
if (!form.title.trim() || !form.filterText.trim()) {
setError('番剧名称和过滤关键词不能为空');
return;
}
try {
setLoading(true);
setError('');
const res = await fetch('/api/admin/anime-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: form.title.trim(),
filterText: form.filterText.trim(),
excludeText: form.excludeText.trim(),
source: form.source,
enabled: form.enabled,
lastEpisode: form.lastEpisode,
onePerEpisode: form.onePerEpisode,
refillMissingEpisodes: form.refillMissingEpisodes,
}),
});
if (res.status === 403) {
setError('无权限:仅管理员可添加追番订阅');
return;
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || '创建订阅失败');
return;
}
onSuccess?.();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : '创建订阅失败');
} finally {
setLoading(false);
}
};
return createPortal(
<div className='fixed inset-0 z-[10000] flex items-center justify-center p-4'>
<div
className={`absolute inset-0 bg-black transition-opacity duration-200 ${
visible ? 'opacity-50' : 'opacity-0'
}`}
onClick={onClose}
/>
<div
className={`relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl bg-white dark:bg-gray-800 shadow-xl transition-all duration-200 ${
visible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
}`}
>
<div className='sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'>
<h3 className='text-base font-semibold text-gray-900 dark:text-white'>
</h3>
<button
type='button'
onClick={onClose}
className='p-1 text-gray-500 hover:text-gray-800 dark:hover:text-gray-200'
>
<X size={18} />
</button>
</div>
<div className='p-4 space-y-3'>
<p className='text-xs text-gray-500 dark:text-gray-400'>
ACG 线
</p>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm'
placeholder='搜索用的番剧名'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
value={form.filterText}
onChange={(e) => setForm({ ...form, filterText: e.target.value })}
className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm'
placeholder='喵萌奶茶屋&简日双语'
/>
<p className='mt-1 text-[11px] text-gray-400'></p>
<div className='mt-1.5 flex flex-wrap gap-1.5'>
{ANIME_FANSUB_PRESETS.map((p) => (
<button
key={p.id}
type='button'
title={p.hint ? `${p.insert}\n${p.hint}` : p.insert}
onClick={() => handleFansubSelect(p)}
className={chipClass(isFansubPresetActive(form.filterText, p))}
>
{p.label}
</button>
))}
</div>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
value={form.excludeText}
onChange={(e) => setForm({ ...form, excludeText: e.target.value })}
className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm'
placeholder='先行|预告|PV'
/>
<div className='mt-2 flex flex-wrap gap-1.5'>
{ANIME_EXCLUDE_PRESETS.map((p) => (
<button
key={p.id}
type='button'
title={p.insert}
onClick={() => handleExcludeSelect(p)}
className={chipClass(isExcludePresetActive(form.excludeText, p))}
>
{p.label}
</button>
))}
</div>
</div>
<div className='grid grid-cols-2 gap-3'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<select
value={form.source}
onChange={(e) =>
setForm({ ...form, source: e.target.value as SourceType })
}
className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm'
>
<option value='mikan'></option>
<option value='acgrip'>ACG.RIP</option>
<option value='dmhy'></option>
<option value='nyaa'>Nyaa</option>
</select>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
type='number'
min={0}
value={form.lastEpisode}
onChange={(e) =>
setForm({
...form,
lastEpisode: parseInt(e.target.value, 10) || 0,
})
}
className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm'
/>
</div>
</div>
<label className='flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300'>
<input
type='checkbox'
checked={form.onePerEpisode}
onChange={(e) =>
setForm({ ...form, onePerEpisode: e.target.checked })
}
className='rounded border-gray-300'
/>
</label>
<label className='flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300'>
<input
type='checkbox'
checked={form.refillMissingEpisodes}
onChange={(e) =>
setForm({ ...form, refillMissingEpisodes: e.target.checked })
}
className='rounded border-gray-300'
/>
+
</label>
{error ? (
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
) : null}
<div className='flex justify-end gap-2 pt-1'>
<button
type='button'
onClick={onClose}
disabled={loading}
className='px-4 py-2 rounded-lg text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200'
>
</button>
<button
type='button'
onClick={handleSubmit}
disabled={loading}
className='px-4 py-2 rounded-lg text-sm bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 flex items-center gap-2'
>
{loading ? <Loader2 size={16} className='animate-spin' /> : null}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
+166 -43
View File
@@ -5,6 +5,16 @@ import { AlertCircle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
ANIME_EXCLUDE_PRESETS,
ANIME_FANSUB_PRESETS,
applyExcludeSingleSelect,
applyFansubSingleSelect,
isExcludePresetActive,
isFansubPresetActive,
type AnimeExcludePreset,
type AnimeFansubPreset,
} from '@/lib/anime-filter-presets';
import { AdminConfig } from '@/lib/admin.types';
import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription';
@@ -183,6 +193,8 @@ export default function AnimeSubscriptionComponent({
source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy' | 'nyaa',
lastEpisode: 0,
enabled: true,
onePerEpisode: false,
refillMissingEpisodes: false,
});
// 加载配置
@@ -203,6 +215,8 @@ export default function AnimeSubscriptionComponent({
source: 'mikan',
lastEpisode: 0,
enabled: true,
onePerEpisode: false,
refillMissingEpisodes: false,
});
setEditingSubscription(null);
setShowAddForm(false);
@@ -279,11 +293,34 @@ export default function AnimeSubscriptionComponent({
source: sub.source,
lastEpisode: sub.lastEpisode,
enabled: sub.enabled,
onePerEpisode: Boolean(sub.onePerEpisode),
refillMissingEpisodes: Boolean(sub.refillMissingEpisodes),
});
setEditingSubscription(sub);
setShowAddForm(false);
};
const handleFansubSelect = (preset: AnimeFansubPreset) => {
setFormData((prev) => ({
...prev,
filterText: applyFansubSingleSelect(prev.filterText, preset),
}));
};
const handleExcludeSelect = (preset: AnimeExcludePreset) => {
setFormData((prev) => ({
...prev,
excludeText: applyExcludeSingleSelect(prev.excludeText, preset),
}));
};
const chipClass = (active: boolean) =>
`px-2 py-0.5 text-xs rounded-full border transition-colors ${
active
? 'bg-green-600 text-white border-green-600'
: 'bg-gray-50 dark:bg-gray-700/50 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600 hover:border-green-500 hover:text-green-700 dark:hover:text-green-300'
}`;
// 保存订阅
const handleSave = async () => {
if (!formData.title.trim() || !formData.filterText.trim()) {
@@ -307,7 +344,8 @@ export default function AnimeSubscriptionComponent({
});
if (!response.ok) {
throw new Error('更新订阅失败');
const data = await response.json().catch(() => ({}));
throw new Error(data.error || '更新订阅失败');
}
} else {
// 创建
@@ -318,7 +356,8 @@ export default function AnimeSubscriptionComponent({
});
if (!response.ok) {
throw new Error('创建订阅失败');
const data = await response.json().catch(() => ({}));
throw new Error(data.error || '创建订阅失败');
}
}
@@ -491,8 +530,13 @@ export default function AnimeSubscriptionComponent({
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> </p>
<p> OpenList离线下载根目录//</p>
<p> </p>
<p> ,,PV</p>
<p>
/ <code className='text-xs'>&amp;</code>
<code className='text-xs'>|</code>
<code className='text-xs'>()</code>==
</p>
<p> /</p>
<p> </p>
<p> </p>
</div>
</div>
@@ -513,33 +557,52 @@ export default function AnimeSubscriptionComponent({
</button>
</div>
<div className='space-y-4'>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
type='text'
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder='葬送的芙莉莲'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
type='text'
value={formData.filterText}
onChange={(e) => setFormData({ ...formData, filterText: e.target.value })}
placeholder='简体,喵萌奶茶屋'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
type='text'
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder='葬送的芙莉莲'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
ACG
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
type='text'
value={formData.filterText}
onChange={(e) => setFormData({ ...formData, filterText: e.target.value })}
placeholder='喵萌奶茶屋&(简日双语|简日内嵌)'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
&amp; | ()
</p>
<div className='mt-2'>
<p className='text-[11px] text-gray-400 dark:text-gray-500 mb-1'>
</p>
<div className='flex flex-wrap gap-1.5'>
{ANIME_FANSUB_PRESETS.map((p) => (
<button
key={p.id}
type='button'
title={p.hint ? `${p.insert}\n${p.hint}` : p.insert}
onClick={() => handleFansubSelect(p)}
className={chipClass(isFansubPresetActive(formData.filterText, p))}
>
{p.label}
</button>
))}
</div>
</div>
</div>
<div>
@@ -550,12 +613,25 @@ export default function AnimeSubscriptionComponent({
type='text'
value={formData.excludeText}
onChange={(e) => setFormData({ ...formData, excludeText: e.target.value })}
placeholder='先行版,预告,PV'
placeholder='先行|预告|PV'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
&amp; | ()
</p>
<div className='mt-2 flex flex-wrap gap-1.5'>
{ANIME_EXCLUDE_PRESETS.map((p) => (
<button
key={p.id}
type='button'
title={p.insert}
onClick={() => handleExcludeSelect(p)}
className={chipClass(isExcludePresetActive(formData.excludeText, p))}
>
{p.label}
</button>
))}
</div>
</div>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<div>
@@ -589,15 +665,40 @@ export default function AnimeSubscriptionComponent({
</p>
</div>
</div>
<div className='flex items-center gap-3'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</span>
<Switch
checked={formData.enabled}
onChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
<div className='flex flex-col sm:flex-row sm:items-center gap-4 flex-wrap'>
<div className='flex items-center gap-3'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</span>
<Switch
checked={formData.enabled}
onChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
</div>
<div className='flex items-center gap-3'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</span>
<Switch
checked={formData.onePerEpisode}
onChange={(checked) => setFormData({ ...formData, onePerEpisode: checked })}
/>
</div>
<div className='flex items-center gap-3'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</span>
<Switch
checked={formData.refillMissingEpisodes}
onChange={(checked) =>
setFormData({ ...formData, refillMissingEpisodes: checked })
}
/>
</div>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400 -mt-2'>
+ 02
</p>
<div className='flex gap-2 justify-end pt-2'>
<button
onClick={resetForm}
@@ -641,6 +742,16 @@ export default function AnimeSubscriptionComponent({
<span className='px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
</span>
{sub.onePerEpisode ? (
<span className='px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'>
×1
</span>
) : null}
{sub.refillMissingEpisodes ? (
<span className='px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200'>
</span>
) : null}
</div>
<div className='text-sm text-gray-600 dark:text-gray-400 space-y-1'>
<p>{sub.filterText}</p>
@@ -695,9 +806,21 @@ export default function AnimeSubscriptionComponent({
<h3 className='text-base font-medium text-gray-900 dark:text-gray-100 truncate'>
{sub.title}
</h3>
<span className='inline-block mt-1 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
</span>
<div className='flex flex-wrap gap-1 mt-1'>
<span className='inline-block px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
</span>
{sub.onePerEpisode ? (
<span className='inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'>
×1
</span>
) : null}
{sub.refillMissingEpisodes ? (
<span className='inline-block px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200'>
</span>
) : null}
</div>
</div>
<Switch
checked={sub.enabled}
+1
View File
@@ -178,6 +178,7 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
orientation='horizontal'
playTime={record.play_time}
totalTime={record.total_time}
isAnime={Boolean(record.is_anime)}
/>
{record.new_episodes && record.new_episodes > 0 && (
<div
+63
View File
@@ -6,6 +6,7 @@ import {
Heart,
Info,
Link,
ListPlus,
PlayCircleIcon,
Radio,
Sparkles,
@@ -25,6 +26,8 @@ import React, {
useState,
} from 'react';
import { isAnimeCategoryText } from '@/lib/anime-keyword-expr';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import {
deleteFavorite,
deletePlayRecord,
@@ -48,6 +51,7 @@ import {
import { useLongPress } from '@/hooks/useLongPress';
import AIChatPanel from '@/components/AIChatPanel';
import AnimeSubscribeModal from '@/components/AnimeSubscribeModal';
import DetailPanel from '@/components/DetailPanel';
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
import ImageViewer from '@/components/ImageViewer';
@@ -80,6 +84,10 @@ export interface VideoCardProps {
rate?: string;
type?: string;
isBangumi?: boolean;
/** 明确标记为动漫(豆瓣动漫页 / CMS 等) */
isAnime?: boolean;
/** CMS 分类名,用于启发式识别动漫 */
typeName?: string;
isAggregate?: boolean;
origin?: 'vod' | 'live';
releaseDate?: string; // 上映日期,格式:YYYY-MM-DD
@@ -125,6 +133,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
rate,
type = '',
isBangumi = false,
isAnime = false,
typeName,
isAggregate = false,
origin = 'vod',
releaseDate,
@@ -141,6 +151,17 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
ref
) {
const router = useRouter();
const [showAnimeSubscribe, setShowAnimeSubscribe] = useState(false);
const [animeSubscribeToast, setAnimeSubscribeToast] = useState('');
const isAdminUser = useMemo(() => {
const auth = getAuthInfoFromBrowserCookie();
return auth?.role === 'admin' || auth?.role === 'owner';
}, []);
const resolvedIsAnime = useMemo(
() =>
Boolean(isBangumi || isAnime || isAnimeCategoryText(typeName)),
[isBangumi, isAnime, typeName]
);
const actualTitle = title;
const actualPoster = poster;
const netdiskPosterPlaceholder = useMemo(() => {
@@ -870,6 +891,27 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
});
}
// 添加追番订阅(仅管理员 + 判定为动漫)
if (
isAdminUser &&
resolvedIsAnime &&
origin !== 'live' &&
actualTitle
) {
actions.push({
id: 'anime-subscribe',
label: '添加追番订阅',
icon: <ListPlus size={20} />,
onClick: () => {
setShowMobileActions(false);
setTimeout(() => {
setShowAnimeSubscribe(true);
}, 250);
},
color: 'primary' as const,
});
}
return actions;
}, [
config,
@@ -893,6 +935,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
origin,
tmdb_id,
openTrailerPicker,
isAdminUser,
resolvedIsAnime,
]);
return (
@@ -2069,6 +2113,25 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
/>
)}
{/* 添加追番订阅(管理员) */}
<AnimeSubscribeModal
isOpen={showAnimeSubscribe}
onClose={() => setShowAnimeSubscribe(false)}
initialTitle={actualTitle}
initialLastEpisode={
from === 'playrecord' && currentEpisode ? currentEpisode : 0
}
onSuccess={() => {
setAnimeSubscribeToast('已添加追番订阅');
window.setTimeout(() => setAnimeSubscribeToast(''), 2500);
}}
/>
{animeSubscribeToast ? (
<div className='fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full bg-green-600 px-4 py-2 text-sm text-white shadow-lg'>
{animeSubscribeToast}
</div>
) : null}
{/* 图片查看器 */}
{showImageViewer && (
<ImageViewer