Merge branch 'dev'
This commit is contained in:
23
.github/workflows/cloudflare-deploy.yml
vendored
23
.github/workflows/cloudflare-deploy.yml
vendored
@@ -67,13 +67,23 @@ jobs:
|
||||
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $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: |
|
||||
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
|
||||
echo "D1 Database ID replaced successfully"
|
||||
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
|
||||
|
||||
- name: Update wrangler.toml with environment variables
|
||||
@@ -130,6 +140,13 @@ jobs:
|
||||
|
||||
- name: Run D1 Database Migrations
|
||||
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
|
||||
echo "D1_DATABASE_ID not set, skipping migrations"
|
||||
exit 0
|
||||
|
||||
@@ -115,7 +115,7 @@ Cloudflare Workers 提供免费的边缘计算服务,通过 GitHub Actions 可
|
||||
- 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板
|
||||
- 或使用自定义 Token,需要以下权限:
|
||||
- Account - Cloudflare Workers Scripts - Edit
|
||||
- Account - Cloudflare Workers KV Storage - Edit
|
||||
- Account - D1 - Edit(仅在使用 D1 数据库时需要)
|
||||
- 创建后复制生成的 API Token
|
||||
- 在 Dashboard 首页右侧可以看到你的 Account ID
|
||||
|
||||
|
||||
BIN
public/icons/OrionTV.png
Normal file
BIN
public/icons/OrionTV.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icons/Selene.png
Normal file
BIN
public/icons/Selene.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -3621,11 +3621,6 @@ const EmbyConfigComponent = ({
|
||||
|
||||
// 删除源
|
||||
const handleDelete = async (source: any) => {
|
||||
if (sources.length === 1) {
|
||||
showError('至少需要保留一个Emby源', showAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除 "${source.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,6 +170,14 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
|
||||
// 检查存储类型
|
||||
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 方法
|
||||
if (storageType === 'd1') {
|
||||
if (typeof storage.getUserPasswordHash === 'function') {
|
||||
|
||||
@@ -151,6 +151,28 @@ export async function POST(req: NextRequest) {
|
||||
} catch (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 {
|
||||
// Redis 存储:直接设置用户信息
|
||||
const userInfoKey = `user:${username}:info`;
|
||||
|
||||
@@ -14,11 +14,18 @@ export const runtime = 'nodejs';
|
||||
*/
|
||||
function cleanPath(path: string): string {
|
||||
// 移除 UTF-8 BOM (U+FEFF) 和其他零宽度字符
|
||||
return path
|
||||
let cleaned = path
|
||||
.replace(/^\uFEFF/, '') // 移除开头的 BOM
|
||||
.replace(/\uFEFF/g, '') // 移除所有 BOM
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 移除零宽度字符
|
||||
.trim(); // 移除首尾空白
|
||||
|
||||
// 移除末尾的 /(除非路径就是 /)
|
||||
if (cleaned.length > 1 && cleaned.endsWith('/')) {
|
||||
cleaned = cleaned.slice(0, -1);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,14 +125,16 @@ async function replaceAudioUrlsWithOpenList(
|
||||
quality: string,
|
||||
cachePath: string
|
||||
): 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;
|
||||
}
|
||||
|
||||
// 获取配置,检查是否启用缓存代理
|
||||
const config = await getConfig();
|
||||
const cacheProxyEnabled = config?.MusicConfig?.OpenListCacheProxyEnabled ?? true;
|
||||
|
||||
// TuneHub 返回的数据结构是 { code: 0, data: { data: [...], total: 1 } }
|
||||
// 需要提取内层的 data 数组
|
||||
const songsData = data.data.data || data.data;
|
||||
|
||||
@@ -35,13 +35,22 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// 获取当前请求的 origin
|
||||
const requestUrl = new URL(request.url);
|
||||
const origin = `${requestUrl.protocol}//${requestUrl.host}`;
|
||||
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
||||
let origin = process.env.SITE_BASE;
|
||||
if (!origin) {
|
||||
const requestUrl = new URL(request.url);
|
||||
origin = `${requestUrl.protocol}//${requestUrl.host}`;
|
||||
}
|
||||
|
||||
// 获取原始 m3u8 内容
|
||||
const m3u8UrlObj = new URL(m3u8Url);
|
||||
const response = await fetch(m3u8Url, {
|
||||
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}/`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -359,6 +359,13 @@ export default function MusicPage() {
|
||||
}
|
||||
}, [playRecords, pendingSongToPlay]);
|
||||
|
||||
// 同步音量状态到 audio 元素
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume / 100;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
// 执行前端 transform(用于 Cloudflare 环境)
|
||||
const executeTransform = (data: any) => {
|
||||
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 窗口管理
|
||||
const togglePiPLyrics = () => {
|
||||
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="flex flex-col items-center gap-2">
|
||||
<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
|
||||
className="absolute bottom-0 left-0 right-0 bg-green-500 rounded-full transition-all pointer-events-none"
|
||||
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>
|
||||
|
||||
@@ -579,6 +579,7 @@ export default function PrivateLibraryPage() {
|
||||
<button
|
||||
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'
|
||||
style={{ marginTop: '10px' }}
|
||||
>
|
||||
<Film size={20} />
|
||||
<span>求片</span>
|
||||
|
||||
@@ -62,6 +62,8 @@ function SearchPageClient() {
|
||||
const [forceRefresh, setForceRefresh] = useState(false);
|
||||
// 是否使用了缓存结果
|
||||
const [isFromCache, setIsFromCache] = useState(false);
|
||||
// 精确搜索开关
|
||||
const [exactSearch, setExactSearch] = useState(true);
|
||||
|
||||
// 生成缓存键
|
||||
const getCacheKey = (query: string) => {
|
||||
@@ -257,12 +259,28 @@ function SearchPageClient() {
|
||||
// 2.4 兜底:使用 episodes.length(最不可靠)
|
||||
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 filteredResults = exactSearch
|
||||
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
|
||||
: searchResults;
|
||||
|
||||
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
|
||||
const preliminaryMap = new Map<string, SearchResult[]>();
|
||||
|
||||
searchResults.forEach((item) => {
|
||||
filteredResults.forEach((item) => {
|
||||
const normalizedTitle = normalizeTitle(item.title);
|
||||
const type = getType(item);
|
||||
const preliminaryKey = `${normalizedTitle}-${type}`;
|
||||
@@ -316,7 +334,7 @@ function SearchPageClient() {
|
||||
|
||||
// 按出现顺序返回聚合结果
|
||||
return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]);
|
||||
}, [searchResults]);
|
||||
}, [searchResults, exactSearch]);
|
||||
|
||||
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
|
||||
useEffect(() => {
|
||||
@@ -422,7 +440,13 @@ function SearchPageClient() {
|
||||
// 非聚合:应用筛选与排序
|
||||
const filteredAllResults = useMemo(() => {
|
||||
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 (title !== 'all' && item.title !== title) return false;
|
||||
if (year !== 'all' && item.year !== year) return false;
|
||||
@@ -451,7 +475,7 @@ function SearchPageClient() {
|
||||
a.title.localeCompare(b.title) :
|
||||
b.title.localeCompare(a.title);
|
||||
});
|
||||
}, [searchResults, filterAll, searchQuery]);
|
||||
}, [searchResults, filterAll, searchQuery, exactSearch]);
|
||||
|
||||
// 聚合:应用筛选与排序
|
||||
const filteredAggResults = useMemo(() => {
|
||||
@@ -575,6 +599,12 @@ function SearchPageClient() {
|
||||
} else if (defaultFluidSearch !== undefined) {
|
||||
setUseFluidSearch(defaultFluidSearch);
|
||||
}
|
||||
|
||||
// 读取精确搜索设置
|
||||
const savedExactSearch = localStorage.getItem('exactSearch');
|
||||
if (savedExactSearch !== null) {
|
||||
setExactSearch(savedExactSearch === 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// 监听搜索历史更新事件
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Monitor,
|
||||
MoveDown,
|
||||
MoveUp,
|
||||
Package,
|
||||
Rss,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -65,6 +66,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
|
||||
const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false);
|
||||
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
|
||||
const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false);
|
||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -78,7 +80,7 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
||||
useEffect(() => {
|
||||
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen) {
|
||||
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen) {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
@@ -97,7 +99,7 @@ export const UserMenu: React.FC = () => {
|
||||
html.style.overflow = originalHtmlOverflow;
|
||||
};
|
||||
}
|
||||
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen]);
|
||||
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen, isEcoAppsOpen]);
|
||||
|
||||
// 设置相关状态
|
||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||
@@ -121,6 +123,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [danmakuMaxCount, setDanmakuMaxCount] = useState(0);
|
||||
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
|
||||
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
|
||||
const [exactSearch, setExactSearch] = useState(true);
|
||||
|
||||
// 邮件通知设置
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
@@ -470,6 +473,12 @@ export const UserMenu: React.FC = () => {
|
||||
if (savedSearchTraditionalToSimplified !== null) {
|
||||
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) => {
|
||||
setHomeBannerEnabled(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1285,6 +1301,18 @@ export const UserMenu: React.FC = () => {
|
||||
</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>
|
||||
|
||||
@@ -1885,6 +1913,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={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>
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className='relative'>
|
||||
@@ -2942,6 +3182,11 @@ export const UserMenu: React.FC = () => {
|
||||
mounted &&
|
||||
createPortal(deviceManagementPanel, document.body)}
|
||||
|
||||
{/* 使用 Portal 将生态应用面板渲染到 document.body */}
|
||||
{isEcoAppsOpen &&
|
||||
mounted &&
|
||||
createPortal(ecoAppsPanel, document.body)}
|
||||
|
||||
{/* 确认对话框 */}
|
||||
{confirmDialog.isOpen &&
|
||||
mounted &&
|
||||
|
||||
@@ -254,7 +254,8 @@ async function performScan(
|
||||
const pageFolders = listResponse.data.content.filter((item) => item.is_dir);
|
||||
folders.push(...pageFolders);
|
||||
|
||||
if (folders.length >= total) {
|
||||
// 判断是否还有更多数据:当前页为 null 或数据量小于 pageSize 说明已经是最后一页
|
||||
if (!listResponse.data.content || listResponse.data.content.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user