Merge branch 'dev'
This commit is contained in:
@@ -67,13 +67,23 @@ jobs:
|
|||||||
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
||||||
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Replace D1 Database ID in wrangler.toml
|
- name: Configure D1 Database in wrangler.toml
|
||||||
run: |
|
run: |
|
||||||
if [ -n "${{ secrets.D1_DATABASE_ID }}" ]; then
|
STORAGE_TYPE="${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}"
|
||||||
|
|
||||||
|
if [ "$STORAGE_TYPE" != "d1" ]; then
|
||||||
|
echo "Storage type is not d1 (current: $STORAGE_TYPE), removing D1 database configuration"
|
||||||
|
# Remove the entire [[d1_databases]] section including the comment
|
||||||
|
sed -i '/^# D1 数据库绑定$/,/^database_id = .*$/d' wrangler.toml
|
||||||
|
# Also remove any remaining [[d1_databases]] block
|
||||||
|
sed -i '/^\[\[d1_databases\]\]$/,/^$/{ /^\[\[d1_databases\]\]$/d; /^binding = /d; /^database_name = /d; /^database_id = /d; }' wrangler.toml
|
||||||
|
echo "D1 configuration removed from wrangler.toml"
|
||||||
|
elif [ -n "${{ secrets.D1_DATABASE_ID }}" ]; then
|
||||||
sed -i 's/REPLACE_WITH_YOUR_D1_DATABASE_ID/${{ secrets.D1_DATABASE_ID }}/g' wrangler.toml
|
sed -i 's/REPLACE_WITH_YOUR_D1_DATABASE_ID/${{ secrets.D1_DATABASE_ID }}/g' wrangler.toml
|
||||||
echo "D1 Database ID replaced successfully"
|
echo "D1 Database ID replaced successfully"
|
||||||
else
|
else
|
||||||
echo "D1_DATABASE_ID secret not set, skipping replacement"
|
echo "⚠️ Storage type is d1 but D1_DATABASE_ID secret not set"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Update wrangler.toml with environment variables
|
- name: Update wrangler.toml with environment variables
|
||||||
@@ -130,6 +140,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Run D1 Database Migrations
|
- name: Run D1 Database Migrations
|
||||||
run: |
|
run: |
|
||||||
|
STORAGE_TYPE="${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}"
|
||||||
|
|
||||||
|
if [ "$STORAGE_TYPE" != "d1" ]; then
|
||||||
|
echo "Storage type is not d1 (current: $STORAGE_TYPE), skipping D1 migrations"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then
|
if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then
|
||||||
echo "D1_DATABASE_ID not set, skipping migrations"
|
echo "D1_DATABASE_ID not set, skipping migrations"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ Cloudflare Workers 提供免费的边缘计算服务,通过 GitHub Actions 可
|
|||||||
- 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板
|
- 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板
|
||||||
- 或使用自定义 Token,需要以下权限:
|
- 或使用自定义 Token,需要以下权限:
|
||||||
- Account - Cloudflare Workers Scripts - Edit
|
- Account - Cloudflare Workers Scripts - Edit
|
||||||
- Account - Cloudflare Workers KV Storage - Edit
|
- Account - D1 - Edit(仅在使用 D1 数据库时需要)
|
||||||
- 创建后复制生成的 API Token
|
- 创建后复制生成的 API Token
|
||||||
- 在 Dashboard 首页右侧可以看到你的 Account ID
|
- 在 Dashboard 首页右侧可以看到你的 Account ID
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -3621,11 +3621,6 @@ const EmbyConfigComponent = ({
|
|||||||
|
|
||||||
// 删除源
|
// 删除源
|
||||||
const handleDelete = async (source: any) => {
|
const handleDelete = async (source: any) => {
|
||||||
if (sources.length === 1) {
|
|
||||||
showError('至少需要保留一个Emby源', showAlert);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirm(`确定要删除 "${source.name}" 吗?`)) {
|
if (!confirm(`确定要删除 "${source.name}" 吗?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,14 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
|
|||||||
// 检查存储类型
|
// 检查存储类型
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
|
||||||
|
// PostgreSQL 存储:使用 getUserPasswordHash 方法
|
||||||
|
if (storageType === 'postgres') {
|
||||||
|
if (typeof storage.getUserPasswordHash === 'function') {
|
||||||
|
return await storage.getUserPasswordHash(username);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// D1 存储:使用 getUserPasswordHash 方法
|
// D1 存储:使用 getUserPasswordHash 方法
|
||||||
if (storageType === 'd1') {
|
if (storageType === 'd1') {
|
||||||
if (typeof storage.getUserPasswordHash === 'function') {
|
if (typeof storage.getUserPasswordHash === 'function') {
|
||||||
|
|||||||
@@ -151,6 +151,28 @@ export async function POST(req: NextRequest) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`导入用户 ${username} 失败:`, err);
|
console.error(`导入用户 ${username} 失败:`, err);
|
||||||
}
|
}
|
||||||
|
} else if (storageType === 'postgres') {
|
||||||
|
// Postgres 存储:使用 createUserWithHashedPassword 方法
|
||||||
|
try {
|
||||||
|
if (typeof storage.createUserWithHashedPassword === 'function') {
|
||||||
|
await storage.createUserWithHashedPassword(
|
||||||
|
username,
|
||||||
|
user.passwordV2, // 已经是hash过的密码
|
||||||
|
role,
|
||||||
|
createdAt,
|
||||||
|
userV2?.tags,
|
||||||
|
userV2?.oidcSub,
|
||||||
|
userV2?.enabledApis,
|
||||||
|
userV2?.banned
|
||||||
|
);
|
||||||
|
importedCount++;
|
||||||
|
console.log(`用户 ${username} 导入成功 (Postgres)`);
|
||||||
|
} else {
|
||||||
|
console.error(`Postgres storage 缺少 createUserWithHashedPassword 方法`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`导入用户 ${username} 失败:`, err);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Redis 存储:直接设置用户信息
|
// Redis 存储:直接设置用户信息
|
||||||
const userInfoKey = `user:${username}:info`;
|
const userInfoKey = `user:${username}:info`;
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ export const runtime = 'nodejs';
|
|||||||
*/
|
*/
|
||||||
function cleanPath(path: string): string {
|
function cleanPath(path: string): string {
|
||||||
// 移除 UTF-8 BOM (U+FEFF) 和其他零宽度字符
|
// 移除 UTF-8 BOM (U+FEFF) 和其他零宽度字符
|
||||||
return path
|
let cleaned = path
|
||||||
.replace(/^\uFEFF/, '') // 移除开头的 BOM
|
.replace(/^\uFEFF/, '') // 移除开头的 BOM
|
||||||
.replace(/\uFEFF/g, '') // 移除所有 BOM
|
.replace(/\uFEFF/g, '') // 移除所有 BOM
|
||||||
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 移除零宽度字符
|
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 移除零宽度字符
|
||||||
.trim(); // 移除首尾空白
|
.trim(); // 移除首尾空白
|
||||||
|
|
||||||
|
// 移除末尾的 /(除非路径就是 /)
|
||||||
|
if (cleaned.length > 1 && cleaned.endsWith('/')) {
|
||||||
|
cleaned = cleaned.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -125,14 +125,16 @@ async function replaceAudioUrlsWithOpenList(
|
|||||||
quality: string,
|
quality: string,
|
||||||
cachePath: string
|
cachePath: string
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
if (!openListClient || !data?.data) {
|
// 获取配置,检查是否启用 OpenList 缓存
|
||||||
|
const config = await getConfig();
|
||||||
|
const cacheEnabled = config?.MusicConfig?.OpenListCacheEnabled ?? false;
|
||||||
|
const cacheProxyEnabled = config?.MusicConfig?.OpenListCacheProxyEnabled ?? true;
|
||||||
|
|
||||||
|
// 如果没有启用 OpenList 缓存,直接返回原数据
|
||||||
|
if (!cacheEnabled || !openListClient || !data?.data) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置,检查是否启用缓存代理
|
|
||||||
const config = await getConfig();
|
|
||||||
const cacheProxyEnabled = config?.MusicConfig?.OpenListCacheProxyEnabled ?? true;
|
|
||||||
|
|
||||||
// TuneHub 返回的数据结构是 { code: 0, data: { data: [...], total: 1 } }
|
// TuneHub 返回的数据结构是 { code: 0, data: { data: [...], total: 1 } }
|
||||||
// 需要提取内层的 data 数组
|
// 需要提取内层的 data 数组
|
||||||
const songsData = data.data.data || data.data;
|
const songsData = data.data.data || data.data;
|
||||||
|
|||||||
@@ -35,13 +35,22 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前请求的 origin
|
// 获取当前请求的 origin
|
||||||
const requestUrl = new URL(request.url);
|
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
||||||
const origin = `${requestUrl.protocol}//${requestUrl.host}`;
|
let origin = process.env.SITE_BASE;
|
||||||
|
if (!origin) {
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
origin = `${requestUrl.protocol}//${requestUrl.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 获取原始 m3u8 内容
|
// 获取原始 m3u8 内容
|
||||||
|
const m3u8UrlObj = new URL(m3u8Url);
|
||||||
const response = await fetch(m3u8Url, {
|
const response = await fetch(m3u8Url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
'Referer': `${m3u8UrlObj.protocol}//${m3u8UrlObj.host}/`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+58
-9
@@ -359,6 +359,13 @@ export default function MusicPage() {
|
|||||||
}
|
}
|
||||||
}, [playRecords, pendingSongToPlay]);
|
}, [playRecords, pendingSongToPlay]);
|
||||||
|
|
||||||
|
// 同步音量状态到 audio 元素
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = volume / 100;
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
// 执行前端 transform(用于 Cloudflare 环境)
|
// 执行前端 transform(用于 Cloudflare 环境)
|
||||||
const executeTransform = (data: any) => {
|
const executeTransform = (data: any) => {
|
||||||
if (data && typeof data === 'object' && data.__transform) {
|
if (data && typeof data === 'object' && data.__transform) {
|
||||||
@@ -1171,6 +1178,52 @@ export default function MusicPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 触摸/鼠标滑动音量调节(移动端兼容)
|
||||||
|
const handleVolumeSliderInteraction = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const slider = e.currentTarget;
|
||||||
|
const rect = slider.getBoundingClientRect();
|
||||||
|
|
||||||
|
const updateVolume = (clientY: number) => {
|
||||||
|
// 计算相对于滑块顶部的位置
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
// 限制在滑块范围内
|
||||||
|
const clampedY = Math.max(0, Math.min(rect.height, y));
|
||||||
|
// 从上到下:0% -> 100%,从下到上:100% -> 0%
|
||||||
|
const percentage = 100 - (clampedY / rect.height) * 100;
|
||||||
|
const newVolume = Math.round(percentage);
|
||||||
|
|
||||||
|
setVolume(newVolume);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = newVolume / 100;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取初始触摸/点击位置
|
||||||
|
const clientY = 'touches' in e ? e.touches[0]?.clientY || 0 : e.clientY;
|
||||||
|
updateVolume(clientY);
|
||||||
|
|
||||||
|
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
const moveClientY = 'touches' in moveEvent ? moveEvent.touches[0]?.clientY || 0 : moveEvent.clientY;
|
||||||
|
updateVolume(moveClientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMove);
|
||||||
|
document.removeEventListener('mouseup', handleEnd);
|
||||||
|
document.removeEventListener('touchmove', handleMove);
|
||||||
|
document.removeEventListener('touchend', handleEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMove);
|
||||||
|
document.addEventListener('mouseup', handleEnd);
|
||||||
|
document.addEventListener('touchmove', handleMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', handleEnd);
|
||||||
|
};
|
||||||
|
|
||||||
// PiP 窗口管理
|
// PiP 窗口管理
|
||||||
const togglePiPLyrics = () => {
|
const togglePiPLyrics = () => {
|
||||||
if (!('documentPictureInPicture' in window)) {
|
if (!('documentPictureInPicture' in window)) {
|
||||||
@@ -1939,19 +1992,15 @@ export default function MusicPage() {
|
|||||||
<div className="bg-zinc-800/95 backdrop-blur-sm rounded-lg p-3 shadow-xl border border-white/10">
|
<div className="bg-zinc-800/95 backdrop-blur-sm rounded-lg p-3 shadow-xl border border-white/10">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="text-xs text-zinc-400 font-mono">{volume}</span>
|
<span className="text-xs text-zinc-400 font-mono">{volume}</span>
|
||||||
<div className="h-24 w-1 bg-white/10 rounded-full relative">
|
<div
|
||||||
|
className="h-24 w-6 bg-white/10 rounded-full relative cursor-pointer select-none"
|
||||||
|
onMouseDown={handleVolumeSliderInteraction}
|
||||||
|
onTouchStart={handleVolumeSliderInteraction}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 left-0 right-0 bg-green-500 rounded-full transition-all pointer-events-none"
|
className="absolute bottom-0 left-0 right-0 bg-green-500 rounded-full transition-all pointer-events-none"
|
||||||
style={{ height: `${volume}%` }}
|
style={{ height: `${volume}%` }}
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={volume}
|
|
||||||
onChange={handleVolumeChange}
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer [writing-mode:bt-lr] [-webkit-appearance:slider-vertical]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ export default function PrivateLibraryPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => router.push('/movie-request')}
|
onClick={() => router.push('/movie-request')}
|
||||||
className='flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'
|
className='flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'
|
||||||
|
style={{ marginTop: '10px' }}
|
||||||
>
|
>
|
||||||
<Film size={20} />
|
<Film size={20} />
|
||||||
<span>求片</span>
|
<span>求片</span>
|
||||||
|
|||||||
+34
-4
@@ -62,6 +62,8 @@ function SearchPageClient() {
|
|||||||
const [forceRefresh, setForceRefresh] = useState(false);
|
const [forceRefresh, setForceRefresh] = useState(false);
|
||||||
// 是否使用了缓存结果
|
// 是否使用了缓存结果
|
||||||
const [isFromCache, setIsFromCache] = useState(false);
|
const [isFromCache, setIsFromCache] = useState(false);
|
||||||
|
// 精确搜索开关
|
||||||
|
const [exactSearch, setExactSearch] = useState(true);
|
||||||
|
|
||||||
// 生成缓存键
|
// 生成缓存键
|
||||||
const getCacheKey = (query: string) => {
|
const getCacheKey = (query: string) => {
|
||||||
@@ -257,12 +259,28 @@ function SearchPageClient() {
|
|||||||
// 2.4 兜底:使用 episodes.length(最不可靠)
|
// 2.4 兜底:使用 episodes.length(最不可靠)
|
||||||
return item.episodes.length === 1 ? 'movie' : 'tv';
|
return item.episodes.length === 1 ? 'movie' : 'tv';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 辅助函数:检查标题是否包含搜索词(用于精确搜索)
|
||||||
|
const titleContainsQuery = (title: string, query: string): boolean => {
|
||||||
|
if (!exactSearch) return true; // 如果未开启精确搜索,不过滤
|
||||||
|
if (!query || !title) return true; // 如果没有搜索词或标题,不过滤
|
||||||
|
|
||||||
|
const normalizedTitle = title.toLowerCase();
|
||||||
|
const normalizedQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
return normalizedTitle.includes(normalizedQuery);
|
||||||
|
};
|
||||||
// 聚合后的结果(按标题和年份分组)
|
// 聚合后的结果(按标题和年份分组)
|
||||||
const aggregatedResults = useMemo(() => {
|
const aggregatedResults = useMemo(() => {
|
||||||
|
// 首先应用精确搜索过滤
|
||||||
|
const filteredResults = exactSearch
|
||||||
|
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
|
||||||
|
: searchResults;
|
||||||
|
|
||||||
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
|
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
|
||||||
const preliminaryMap = new Map<string, SearchResult[]>();
|
const preliminaryMap = new Map<string, SearchResult[]>();
|
||||||
|
|
||||||
searchResults.forEach((item) => {
|
filteredResults.forEach((item) => {
|
||||||
const normalizedTitle = normalizeTitle(item.title);
|
const normalizedTitle = normalizeTitle(item.title);
|
||||||
const type = getType(item);
|
const type = getType(item);
|
||||||
const preliminaryKey = `${normalizedTitle}-${type}`;
|
const preliminaryKey = `${normalizedTitle}-${type}`;
|
||||||
@@ -316,7 +334,7 @@ function SearchPageClient() {
|
|||||||
|
|
||||||
// 按出现顺序返回聚合结果
|
// 按出现顺序返回聚合结果
|
||||||
return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]);
|
return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]);
|
||||||
}, [searchResults]);
|
}, [searchResults, exactSearch]);
|
||||||
|
|
||||||
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
|
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -422,7 +440,13 @@ function SearchPageClient() {
|
|||||||
// 非聚合:应用筛选与排序
|
// 非聚合:应用筛选与排序
|
||||||
const filteredAllResults = useMemo(() => {
|
const filteredAllResults = useMemo(() => {
|
||||||
const { source, title, year, yearOrder } = filterAll;
|
const { source, title, year, yearOrder } = filterAll;
|
||||||
const filtered = searchResults.filter((item) => {
|
|
||||||
|
// 首先应用精确搜索过滤
|
||||||
|
const exactSearchFiltered = exactSearch
|
||||||
|
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
|
||||||
|
: searchResults;
|
||||||
|
|
||||||
|
const filtered = exactSearchFiltered.filter((item) => {
|
||||||
if (source !== 'all' && item.source !== source) return false;
|
if (source !== 'all' && item.source !== source) return false;
|
||||||
if (title !== 'all' && item.title !== title) return false;
|
if (title !== 'all' && item.title !== title) return false;
|
||||||
if (year !== 'all' && item.year !== year) return false;
|
if (year !== 'all' && item.year !== year) return false;
|
||||||
@@ -451,7 +475,7 @@ function SearchPageClient() {
|
|||||||
a.title.localeCompare(b.title) :
|
a.title.localeCompare(b.title) :
|
||||||
b.title.localeCompare(a.title);
|
b.title.localeCompare(a.title);
|
||||||
});
|
});
|
||||||
}, [searchResults, filterAll, searchQuery]);
|
}, [searchResults, filterAll, searchQuery, exactSearch]);
|
||||||
|
|
||||||
// 聚合:应用筛选与排序
|
// 聚合:应用筛选与排序
|
||||||
const filteredAggResults = useMemo(() => {
|
const filteredAggResults = useMemo(() => {
|
||||||
@@ -575,6 +599,12 @@ function SearchPageClient() {
|
|||||||
} else if (defaultFluidSearch !== undefined) {
|
} else if (defaultFluidSearch !== undefined) {
|
||||||
setUseFluidSearch(defaultFluidSearch);
|
setUseFluidSearch(defaultFluidSearch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 读取精确搜索设置
|
||||||
|
const savedExactSearch = localStorage.getItem('exactSearch');
|
||||||
|
if (savedExactSearch !== null) {
|
||||||
|
setExactSearch(savedExactSearch === 'true');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听搜索历史更新事件
|
// 监听搜索历史更新事件
|
||||||
|
|||||||
+247
-2
@@ -22,6 +22,7 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
MoveDown,
|
MoveDown,
|
||||||
MoveUp,
|
MoveUp,
|
||||||
|
Package,
|
||||||
Rss,
|
Rss,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -65,6 +66,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
|
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
|
||||||
const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false);
|
const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false);
|
||||||
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
|
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
|
||||||
|
const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false);
|
||||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -78,7 +80,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
|
|
||||||
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen) {
|
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen) {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
|
|
||||||
@@ -97,7 +99,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
html.style.overflow = originalHtmlOverflow;
|
html.style.overflow = originalHtmlOverflow;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen]);
|
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen, isEcoAppsOpen]);
|
||||||
|
|
||||||
// 设置相关状态
|
// 设置相关状态
|
||||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||||
@@ -121,6 +123,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [danmakuMaxCount, setDanmakuMaxCount] = useState(0);
|
const [danmakuMaxCount, setDanmakuMaxCount] = useState(0);
|
||||||
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
|
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
|
||||||
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
|
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
|
||||||
|
const [exactSearch, setExactSearch] = useState(true);
|
||||||
|
|
||||||
// 邮件通知设置
|
// 邮件通知设置
|
||||||
const [userEmail, setUserEmail] = useState('');
|
const [userEmail, setUserEmail] = useState('');
|
||||||
@@ -470,6 +473,12 @@ export const UserMenu: React.FC = () => {
|
|||||||
if (savedSearchTraditionalToSimplified !== null) {
|
if (savedSearchTraditionalToSimplified !== null) {
|
||||||
setSearchTraditionalToSimplified(savedSearchTraditionalToSimplified === 'true');
|
setSearchTraditionalToSimplified(savedSearchTraditionalToSimplified === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载精确搜索设置
|
||||||
|
const savedExactSearch = localStorage.getItem('exactSearch');
|
||||||
|
if (savedExactSearch !== null) {
|
||||||
|
setExactSearch(savedExactSearch === 'true');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -929,6 +938,13 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExactSearchToggle = (value: boolean) => {
|
||||||
|
setExactSearch(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('exactSearch', String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleHomeBannerToggle = (value: boolean) => {
|
const handleHomeBannerToggle = (value: boolean) => {
|
||||||
setHomeBannerEnabled(value);
|
setHomeBannerEnabled(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -1285,6 +1301,18 @@ export const UserMenu: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 生态应用按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsEcoAppsOpen(true);
|
||||||
|
}}
|
||||||
|
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||||
|
>
|
||||||
|
<Package className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||||
|
<span className='font-medium'>生态应用</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 分割线 */}
|
{/* 分割线 */}
|
||||||
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
@@ -1885,6 +1913,30 @@ export const UserMenu: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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={exactSearch}
|
||||||
|
onChange={(e) => handleExactSearchToggle(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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2859,6 +2911,194 @@ export const UserMenu: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 生态应用面板内容
|
||||||
|
const ecoAppsPanel = (
|
||||||
|
<>
|
||||||
|
{/* 背景遮罩 */}
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||||
|
onClick={() => setIsEcoAppsOpen(false)}
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 生态应用面板 */}
|
||||||
|
<div
|
||||||
|
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='h-full max-h-[85vh] flex flex-col'
|
||||||
|
data-panel-content
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
touchAction: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className='flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700'>
|
||||||
|
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
生态应用
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEcoAppsOpen(false)}
|
||||||
|
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||||
|
aria-label='Close'
|
||||||
|
>
|
||||||
|
<X className='w-full h-full' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 应用列表 */}
|
||||||
|
<div className='flex-1 overflow-y-auto p-6'>
|
||||||
|
<div className='grid gap-6 md:grid-cols-1'>
|
||||||
|
{/* MoonTVPlus-PC 客户端 */}
|
||||||
|
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
|
||||||
|
<div className='flex items-start gap-4'>
|
||||||
|
<div className='flex-shrink-0 relative'>
|
||||||
|
<img
|
||||||
|
src='/logo.png'
|
||||||
|
alt='MoonTVPlus-PC'
|
||||||
|
className='w-16 h-16 rounded-xl object-cover'
|
||||||
|
/>
|
||||||
|
<div className='absolute -bottom-1 -right-1 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center shadow-lg'>
|
||||||
|
<Monitor className='w-3.5 h-3.5 text-white' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||||
|
MoonTVPlus-PC客户端
|
||||||
|
</h4>
|
||||||
|
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
|
||||||
|
专为Windows开发的客户端,完美支持私人影库mkv视频
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href='https://github.com/mtvpls/MoonTVPlus-PC/releases'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='inline-flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
<Download className='w-4 h-4' />
|
||||||
|
下载
|
||||||
|
<ExternalLink className='w-3 h-3' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selene 跨平台客户端 */}
|
||||||
|
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
|
||||||
|
<div className='flex items-start gap-4'>
|
||||||
|
<div className='flex-shrink-0 relative'>
|
||||||
|
<img
|
||||||
|
src='/icons/Selene.png'
|
||||||
|
alt='Selene'
|
||||||
|
className='w-16 h-16 rounded-xl object-cover'
|
||||||
|
/>
|
||||||
|
<span className='absolute -top-1 -right-1 px-1.5 py-0.5 bg-orange-500 text-white text-[10px] font-bold rounded'>
|
||||||
|
二开
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||||
|
Selene 跨平台客户端
|
||||||
|
</h4>
|
||||||
|
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
|
||||||
|
多平台客户端
|
||||||
|
</p>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<a
|
||||||
|
href='https://github.com/mtvpls/MoonTVPlus/releases/tag/Selene_Beta4'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='inline-flex items-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 text-white text-sm font-medium rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
<Download className='w-4 h-4' />
|
||||||
|
安卓下载
|
||||||
|
<ExternalLink className='w-3 h-3' />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
let targetUrl = 'https://github.com/mtvpls/Selene-Build/actions/workflows/build.yml';
|
||||||
|
|
||||||
|
// 根据 UA 判断平台
|
||||||
|
if (ua.includes('windows')) {
|
||||||
|
targetUrl = 'https://github.com/mtvpls/Selene-Build/actions/workflows/build.yml';
|
||||||
|
} else if (ua.includes('mac')) {
|
||||||
|
targetUrl = 'https://github.com/mtvpls/Selene-Build/actions/workflows/build.yml';
|
||||||
|
} else if (ua.includes('linux')) {
|
||||||
|
targetUrl = 'https://github.com/mtvpls/Selene-Build/actions/workflows/build.yml';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(targetUrl, '_blank');
|
||||||
|
}}
|
||||||
|
className='inline-flex items-center gap-2 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white text-sm font-medium rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
<Download className='w-4 h-4' />
|
||||||
|
其他平台
|
||||||
|
<ExternalLink className='w-3 h-3' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OrionTV TV专用客户端 */}
|
||||||
|
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
|
||||||
|
<div className='flex items-start gap-4'>
|
||||||
|
<div className='flex-shrink-0 relative'>
|
||||||
|
<img
|
||||||
|
src='/icons/OrionTV.png'
|
||||||
|
alt='OrionTV'
|
||||||
|
className='w-16 h-16 rounded-xl object-cover'
|
||||||
|
/>
|
||||||
|
<span className='absolute -top-1 -right-1 px-1.5 py-0.5 bg-orange-500 text-white text-[10px] font-bold rounded'>
|
||||||
|
二开
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||||
|
OrionTV TV专用客户端
|
||||||
|
</h4>
|
||||||
|
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
|
||||||
|
tv专用
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href='https://github.com/mtvpls/MoonTVPlus/releases/tag/OrionTV%E9%80%82%E9%85%8D%E7%89%882'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='inline-flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
<Download className='w-4 h-4' />
|
||||||
|
下载
|
||||||
|
<ExternalLink className='w-3 h-3' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部说明 */}
|
||||||
|
<div className='p-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||||
|
选择适合您设备的客户端下载使用
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
@@ -2942,6 +3182,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
mounted &&
|
mounted &&
|
||||||
createPortal(deviceManagementPanel, document.body)}
|
createPortal(deviceManagementPanel, document.body)}
|
||||||
|
|
||||||
|
{/* 使用 Portal 将生态应用面板渲染到 document.body */}
|
||||||
|
{isEcoAppsOpen &&
|
||||||
|
mounted &&
|
||||||
|
createPortal(ecoAppsPanel, document.body)}
|
||||||
|
|
||||||
{/* 确认对话框 */}
|
{/* 确认对话框 */}
|
||||||
{confirmDialog.isOpen &&
|
{confirmDialog.isOpen &&
|
||||||
mounted &&
|
mounted &&
|
||||||
|
|||||||
@@ -254,7 +254,8 @@ async function performScan(
|
|||||||
const pageFolders = listResponse.data.content.filter((item) => item.is_dir);
|
const pageFolders = listResponse.data.content.filter((item) => item.is_dir);
|
||||||
folders.push(...pageFolders);
|
folders.push(...pageFolders);
|
||||||
|
|
||||||
if (folders.length >= total) {
|
// 判断是否还有更多数据:当前页为 null 或数据量小于 pageSize 说明已经是最后一页
|
||||||
|
if (!listResponse.data.content || listResponse.data.content.length < pageSize) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user