mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-05 19:17:30 +08:00
feat: user control fluid search
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowDownWideNarrow, ArrowUpNarrowWide, ArrowUpDown } from 'lucide-react';
|
||||
import { ArrowDownWideNarrow, ArrowUpDown,ArrowUpNarrowWide } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function SearchSuggestions({
|
||||
onClose,
|
||||
}: SearchSuggestionsProps) {
|
||||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 防抖定时器
|
||||
@@ -55,7 +54,6 @@ export default function SearchSuggestions({
|
||||
})
|
||||
);
|
||||
setSuggestions(apiSuggestions);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// 类型保护判断 err 是否是 Error 类型
|
||||
@@ -63,12 +61,10 @@ export default function SearchSuggestions({
|
||||
if (err.name !== 'AbortError') {
|
||||
// 不是取消请求导致的错误才清空
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
} else {
|
||||
// 如果 err 不是 Error 类型,也清空提示
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@@ -84,7 +80,6 @@ export default function SearchSuggestions({
|
||||
fetchSuggestionsFromAPI(searchQuery);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, 300); //300ms
|
||||
},
|
||||
@@ -94,7 +89,6 @@ export default function SearchSuggestions({
|
||||
useEffect(() => {
|
||||
if (!query.trim() || !isVisible) {
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
debouncedFetchSuggestions(query);
|
||||
@@ -107,43 +101,6 @@ export default function SearchSuggestions({
|
||||
};
|
||||
}, [query, isVisible, debouncedFetchSuggestions]);
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isVisible || suggestions.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : suggestions.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
onSelect(suggestions[selectedIndex].text);
|
||||
} else {
|
||||
onSelect(query);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isVisible, query, suggestions, selectedIndex, onSelect, onClose]);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -171,14 +128,11 @@ export default function SearchSuggestions({
|
||||
ref={containerRef}
|
||||
className='absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto'
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={`related-${suggestion.text}`}
|
||||
onClick={() => onSelect(suggestion.text)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3 ${
|
||||
selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3"
|
||||
>
|
||||
<span className='flex-1 text-sm text-gray-700 dark:text-gray-300 truncate'>
|
||||
{suggestion.text}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
|
||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||
const [fluidSearch, setFluidSearch] = useState(true);
|
||||
const [doubanDataSource, setDoubanDataSource] = useState('direct');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('direct');
|
||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||
@@ -66,7 +67,7 @@ export const UserMenu: React.FC = () => {
|
||||
const doubanImageProxyTypeOptions = [
|
||||
{ value: 'direct', label: '直连(浏览器直接请求豆瓣)' },
|
||||
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
|
||||
{ value: 'img3', label: '豆瓣精品 CDN(阿里云)' },
|
||||
{ value: 'img3', label: '豆瓣官方精品 CDN(阿里云)' },
|
||||
{
|
||||
value: 'cmliussss-cdn-tencent',
|
||||
label: '豆瓣 CDN By CMLiussss(腾讯云)',
|
||||
@@ -157,6 +158,15 @@ export const UserMenu: React.FC = () => {
|
||||
if (savedEnableOptimization !== null) {
|
||||
setEnableOptimization(JSON.parse(savedEnableOptimization));
|
||||
}
|
||||
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
if (savedFluidSearch !== null) {
|
||||
setFluidSearch(JSON.parse(savedFluidSearch));
|
||||
} else if (defaultFluidSearch !== undefined) {
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -325,6 +335,13 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFluidSearchToggle = (value: boolean) => {
|
||||
setFluidSearch(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('fluidSearch', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanDataSourceChange = (value: string) => {
|
||||
setDoubanDataSource(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -374,9 +391,12 @@ export const UserMenu: React.FC = () => {
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'direct';
|
||||
const defaultDoubanImageProxyUrl =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
|
||||
setDefaultAggregateSearch(true);
|
||||
setEnableOptimization(true);
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
setDoubanDataSource(defaultDoubanProxyType);
|
||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||
@@ -385,6 +405,7 @@ export const UserMenu: React.FC = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
|
||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||
@@ -807,7 +828,7 @@ export const UserMenu: React.FC = () => {
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
启用优选和测速
|
||||
优选和测速
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
如出现播放器劫持问题可关闭
|
||||
@@ -826,6 +847,30 @@ export const UserMenu: React.FC = () => {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 流式搜索 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
流式搜索输出
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
启用搜索结果实时流式输出,关闭后使用传统一次性搜索
|
||||
</p>
|
||||
</div>
|
||||
<label className='flex items-center cursor-pointer'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={fluidSearch}
|
||||
onChange={(e) => handleFluidSearchToggle(e.target.checked)}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部说明 */}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
|
||||
Reference in New Issue
Block a user