选集屏蔽增加相反模式
This commit is contained in:
+7
-24
@@ -46,13 +46,14 @@ import {
|
||||
saveSkipConfig,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { isEpisodeHiddenByFilter, normalizeEpisodeFilterConfig } from '@/lib/episode-filter';
|
||||
import {
|
||||
buildEpisodeProgressContentKey,
|
||||
loadLocalEpisodeProgress,
|
||||
pruneLocalEpisodeProgressStorage,
|
||||
saveLocalEpisodeProgress,
|
||||
} from '@/lib/episode-progress';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
import {
|
||||
getRecommendationCache,
|
||||
@@ -543,10 +544,11 @@ function PlayPageClient() {
|
||||
// 加载集数过滤配置
|
||||
const episodeConfig = await getEpisodeFilterConfig();
|
||||
if (episodeConfig) {
|
||||
setEpisodeFilterConfig(episodeConfig);
|
||||
episodeFilterConfigRef.current = episodeConfig;
|
||||
const normalizedEpisodeConfig = normalizeEpisodeFilterConfig(episodeConfig);
|
||||
setEpisodeFilterConfig(normalizedEpisodeConfig);
|
||||
episodeFilterConfigRef.current = normalizedEpisodeConfig;
|
||||
} else {
|
||||
const defaultEpisodeConfig: EpisodeFilterConfig = { rules: [] };
|
||||
const defaultEpisodeConfig: EpisodeFilterConfig = normalizeEpisodeFilterConfig();
|
||||
setEpisodeFilterConfig(defaultEpisodeConfig);
|
||||
episodeFilterConfigRef.current = defaultEpisodeConfig;
|
||||
}
|
||||
@@ -4592,26 +4594,7 @@ function PlayPageClient() {
|
||||
|
||||
// 检查集数是否被过滤
|
||||
const isEpisodeFilteredByTitle = (title: string): boolean => {
|
||||
const filterConfig = episodeFilterConfigRef.current;
|
||||
if (!filterConfig || filterConfig.rules.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const rule of filterConfig.rules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
try {
|
||||
if (rule.type === 'normal' && title.includes(rule.keyword)) {
|
||||
return true;
|
||||
}
|
||||
if (rule.type === 'regex' && new RegExp(rule.keyword).test(title)) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('集数过滤规则错误:', e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return isEpisodeHiddenByFilter(title, episodeFilterConfigRef.current);
|
||||
};
|
||||
|
||||
const handleNextEpisode = async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useRef,useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getEpisodeFilterConfig, saveEpisodeFilterConfig } from '@/lib/db.client';
|
||||
import { normalizeEpisodeFilterConfig } from '@/lib/episode-filter';
|
||||
import { EpisodeFilterConfig, EpisodeFilterRule } from '@/lib/types';
|
||||
|
||||
interface EpisodeFilterSettingsProps {
|
||||
@@ -21,7 +22,7 @@ export default function EpisodeFilterSettings({
|
||||
onConfigUpdate,
|
||||
onShowToast,
|
||||
}: EpisodeFilterSettingsProps) {
|
||||
const [config, setConfig] = useState<EpisodeFilterConfig>({ rules: [] });
|
||||
const [config, setConfig] = useState<EpisodeFilterConfig>(normalizeEpisodeFilterConfig());
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [newType, setNewType] = useState<'normal' | 'regex'>('normal');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -130,9 +131,9 @@ export default function EpisodeFilterSettings({
|
||||
try {
|
||||
const loadedConfig = await getEpisodeFilterConfig();
|
||||
if (loadedConfig) {
|
||||
setConfig(loadedConfig);
|
||||
setConfig(normalizeEpisodeFilterConfig(loadedConfig));
|
||||
} else {
|
||||
setConfig({ rules: [] });
|
||||
setConfig(normalizeEpisodeFilterConfig());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载集数过滤配置失败:', error);
|
||||
@@ -141,13 +142,31 @@ export default function EpisodeFilterSettings({
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReverseMode = () => {
|
||||
setConfig((prev) => {
|
||||
const normalizedConfig = normalizeEpisodeFilterConfig(prev);
|
||||
return {
|
||||
...normalizedConfig,
|
||||
reverseMode: !normalizedConfig.reverseMode,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
const normalizedConfig = normalizeEpisodeFilterConfig(config);
|
||||
if (normalizedConfig.reverseMode && normalizedConfig.rules.length === 0) {
|
||||
if (onShowToast) {
|
||||
onShowToast('启用相反模式时,至少需要添加一条规则', 'info');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveEpisodeFilterConfig(config);
|
||||
await saveEpisodeFilterConfig(normalizedConfig);
|
||||
if (onConfigUpdate) {
|
||||
onConfigUpdate(config);
|
||||
onConfigUpdate(normalizedConfig);
|
||||
}
|
||||
if (onShowToast) {
|
||||
onShowToast('保存成功!', 'success');
|
||||
@@ -182,9 +201,13 @@ export default function EpisodeFilterSettings({
|
||||
id: Date.now().toString(),
|
||||
};
|
||||
|
||||
setConfig((prev) => ({
|
||||
rules: [...prev.rules, newRule],
|
||||
}));
|
||||
setConfig((prev) => {
|
||||
const normalizedConfig = normalizeEpisodeFilterConfig(prev);
|
||||
return {
|
||||
...normalizedConfig,
|
||||
rules: [...normalizedConfig.rules, newRule],
|
||||
};
|
||||
});
|
||||
|
||||
// 清空输入框并强制重新渲染
|
||||
setNewKeyword('');
|
||||
@@ -202,19 +225,27 @@ export default function EpisodeFilterSettings({
|
||||
// 删除规则
|
||||
const handleDeleteRule = (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
setConfig((prev) => ({
|
||||
rules: prev.rules.filter((rule) => rule.id !== id),
|
||||
}));
|
||||
setConfig((prev) => {
|
||||
const normalizedConfig = normalizeEpisodeFilterConfig(prev);
|
||||
return {
|
||||
...normalizedConfig,
|
||||
rules: normalizedConfig.rules.filter((rule) => rule.id !== id),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 切换规则启用状态
|
||||
const handleToggleRule = (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
setConfig((prev) => ({
|
||||
rules: prev.rules.map((rule) =>
|
||||
rule.id === id ? { ...rule, enabled: !rule.enabled } : rule
|
||||
),
|
||||
}));
|
||||
setConfig((prev) => {
|
||||
const normalizedConfig = normalizeEpisodeFilterConfig(prev);
|
||||
return {
|
||||
...normalizedConfig,
|
||||
rules: normalizedConfig.rules.map((rule) =>
|
||||
rule.id === id ? { ...rule, enabled: !rule.enabled } : rule
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (!isVisible || !mounted) return null;
|
||||
@@ -294,6 +325,37 @@ export default function EpisodeFilterSettings({
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4 min-h-0">
|
||||
{/* 添加规则 */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl bg-white dark:bg-gray-700/60 border border-gray-200 dark:border-gray-600 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
相反模式
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
开启后,将屏蔽改为仅显示符合规则的集数。
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-amber-600 dark:text-amber-400">
|
||||
启用时必须至少保留一条规则才能保存。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleReverseMode}
|
||||
className="flex-shrink-0 active:scale-95 transition-transform duration-150"
|
||||
title={config.reverseMode ? '关闭相反模式' : '开启相反模式'}
|
||||
>
|
||||
{config.reverseMode ? (
|
||||
<ToggleRight
|
||||
size={28}
|
||||
className="text-green-500 hover:text-green-400 transition-colors duration-150"
|
||||
/>
|
||||
) : (
|
||||
<ToggleLeft
|
||||
size={28}
|
||||
className="text-gray-400 hover:text-gray-300 transition-colors duration-150"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
添加屏蔽规则
|
||||
</h3>
|
||||
@@ -333,7 +395,8 @@ export default function EpisodeFilterSettings({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
💡 普通模式:集数标题包含关键字即屏蔽<br/>
|
||||
💡 普通模式:集数标题包含关键字即命中规则<br/>
|
||||
🔄 相反模式:仅显示命中规则的集数<br/>
|
||||
🔧 正则模式:支持正则表达式匹配(如:^预告.*匹配以"预告"开头的集数)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import React, {
|
||||
|
||||
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
||||
import { generateStorageKey, getCachedPlayRecordsSnapshot } from '@/lib/db.client';
|
||||
import { isEpisodeHiddenByFilter } from '@/lib/episode-filter';
|
||||
import { loadAllLocalEpisodeProgressRecords } from '@/lib/episode-progress';
|
||||
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
||||
@@ -249,29 +250,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
// 获取集数标题
|
||||
const title = episodes_titles?.[episodeNumber - 1];
|
||||
if (!title) return false;
|
||||
|
||||
// 检查每个启用的规则
|
||||
for (const rule of episodeFilterConfig.rules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
try {
|
||||
if (rule.type === 'normal') {
|
||||
// 普通模式:字符串包含匹配
|
||||
if (title.includes(rule.keyword)) {
|
||||
return true;
|
||||
}
|
||||
} else if (rule.type === 'regex') {
|
||||
// 正则模式:正则表达式匹配
|
||||
if (new RegExp(rule.keyword).test(title)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('集数过滤规则错误:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return isEpisodeHiddenByFilter(title, episodeFilterConfig);
|
||||
},
|
||||
[episodeFilterConfig, episodes_titles]
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
|
||||
import { normalizeEpisodeFilterConfig } from './episode-filter';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
|
||||
|
||||
@@ -2643,7 +2644,7 @@ export async function getEpisodeFilterConfig(): Promise<EpisodeFilterConfig | nu
|
||||
try {
|
||||
const raw = localStorage.getItem('moontv_episode_filter_config');
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as EpisodeFilterConfig;
|
||||
return normalizeEpisodeFilterConfig(JSON.parse(raw) as EpisodeFilterConfig);
|
||||
} catch (err) {
|
||||
console.error('读取集数过滤配置失败:', err);
|
||||
return null;
|
||||
@@ -2662,10 +2663,11 @@ export async function saveEpisodeFilterConfig(
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem('moontv_episode_filter_config', JSON.stringify(config));
|
||||
const normalizedConfig = normalizeEpisodeFilterConfig(config);
|
||||
localStorage.setItem('moontv_episode_filter_config', JSON.stringify(normalizedConfig));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('episodeFilterConfigUpdated', {
|
||||
detail: config,
|
||||
detail: normalizedConfig,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { EpisodeFilterConfig } from './types';
|
||||
|
||||
export function normalizeEpisodeFilterConfig(
|
||||
config?: EpisodeFilterConfig | null
|
||||
): EpisodeFilterConfig {
|
||||
return {
|
||||
rules: config?.rules ?? [],
|
||||
reverseMode: config?.reverseMode ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function doesEpisodeTitleMatchFilterRules(
|
||||
title: string,
|
||||
config?: EpisodeFilterConfig | null
|
||||
): boolean {
|
||||
const normalizedConfig = normalizeEpisodeFilterConfig(config);
|
||||
|
||||
for (const rule of normalizedConfig.rules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
try {
|
||||
if (rule.type === 'normal' && title.includes(rule.keyword)) {
|
||||
return true;
|
||||
}
|
||||
if (rule.type === 'regex' && new RegExp(rule.keyword).test(title)) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('集数过滤规则错误:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isEpisodeHiddenByFilter(
|
||||
title: string,
|
||||
config?: EpisodeFilterConfig | null
|
||||
): boolean {
|
||||
const normalizedConfig = normalizeEpisodeFilterConfig(config);
|
||||
if (normalizedConfig.rules.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isMatched = doesEpisodeTitleMatchFilterRules(title, normalizedConfig);
|
||||
return normalizedConfig.reverseMode ? !isMatched : isMatched;
|
||||
}
|
||||
@@ -253,6 +253,7 @@ export interface EpisodeFilterRule {
|
||||
// 集数过滤配置数据结构
|
||||
export interface EpisodeFilterConfig {
|
||||
rules: EpisodeFilterRule[]; // 过滤规则列表
|
||||
reverseMode?: boolean; // 反向模式:开启后仅显示符合规则的集数
|
||||
}
|
||||
|
||||
// 通知类型枚举
|
||||
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
declare module 'sharp' {
|
||||
interface ResizeOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
withoutEnlargement?: boolean;
|
||||
}
|
||||
|
||||
interface PngOptions {
|
||||
compressionLevel?: number;
|
||||
palette?: boolean;
|
||||
quality?: number;
|
||||
effort?: number;
|
||||
}
|
||||
|
||||
interface JpegOptions {
|
||||
quality?: number;
|
||||
mozjpeg?: boolean;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
hasAlpha?: boolean;
|
||||
}
|
||||
|
||||
interface Sharp {
|
||||
rotate(): Sharp;
|
||||
resize(options?: ResizeOptions): Sharp;
|
||||
png(options?: PngOptions): Sharp;
|
||||
jpeg(options?: JpegOptions): Sharp;
|
||||
metadata(): Promise<Metadata>;
|
||||
toBuffer(): Promise<Buffer>;
|
||||
}
|
||||
|
||||
interface SharpOptions {
|
||||
failOn?: string;
|
||||
}
|
||||
|
||||
interface SharpConstructor {
|
||||
(input?: Buffer, options?: SharpOptions): Sharp;
|
||||
}
|
||||
|
||||
const sharp: SharpConstructor;
|
||||
export default sharp;
|
||||
}
|
||||
Reference in New Issue
Block a user