+
{t('显示内容:', 'Display:')}
-
-
+
{analyzedCount > 0 && (
-
+
• {analyzedCount} {t('个已AI分析', 'AI analyzed')}
)}
{failedCount > 0 && (
-
+
• {failedCount} {t('个分析失败', 'analysis failed')}
)}
{unanalyzedCount > 0 && (
-
+
• {unanalyzedCount} {t('个未分析', 'unanalyzed')}
)}
@@ -586,13 +1100,22 @@ export const RepositoryList: React.FC = ({
{/* Repository Grid with consistent card widths */}
-
+
{visibleRepositories.map(repo => (
-
))}
@@ -601,6 +1124,35 @@ export const RepositoryList: React.FC
= ({
{visibleCount < filteredRepositories.length && (
)}
+
+ {/* Bulk Action Toolbar */}
+ {showBulkToolbar && (
+ {
+ setIsExitingSelection(true);
+ setTimeout(() => {
+ setShowBulkToolbar(false);
+ setSelectedRepoIds(new Set());
+ requestAnimationFrame(() => {
+ setIsExitingSelection(false);
+ });
+ }, 250);
+ }}
+ />
+ )}
+
+ {/* Bulk Categorize Modal */}
+ setShowCategorizeModal(false)}
+ repositories={selectedRepositories}
+ onCategorize={handleBulkCategorize}
+ />
);
};
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx
index 6134fea6..5b82514e 100644
--- a/src/components/SearchBar.tsx
+++ b/src/components/SearchBar.tsx
@@ -1,7 +1,8 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { Search, Filter, X, SlidersHorizontal, Monitor, Smartphone, Globe, Terminal, Package, CheckCircle, Bell, BellOff, Apple, Bot } from 'lucide-react';
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import { Search, X, SlidersHorizontal, Monitor, Smartphone, Globe, Terminal, Package, CheckCircle, Bell, BellOff, Apple, Bot, Edit3, Lock, Unlock, AlertCircle } from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
import { AIService } from '../services/aiService';
+import { Repository } from '../types';
import { useSearchShortcuts } from '../hooks/useSearchShortcuts';
@@ -24,6 +25,81 @@ export const SearchBar: React.FC = () => {
const [availableTags, setAvailableTags] = useState
([]);
const [availablePlatforms, setAvailablePlatforms] = useState([]);
const [isRealTimeSearch, setIsRealTimeSearch] = useState(false);
+
+ // 统计各种状态的数量
+ const statusStats = useMemo(() => {
+ const stats = {
+ analyzed: 0, // 已AI分析(成功)
+ notAnalyzed: 0, // 未AI分析
+ failed: 0, // 分析失败
+ subscribed: 0, // 已订阅Release
+ notSubscribed: 0, // 未订阅Release
+ edited: 0, // 已编辑
+ notEdited: 0, // 未编辑
+ locked: 0, // 分类已锁定
+ notLocked: 0, // 分类未锁定
+ };
+
+ repositories.forEach(repo => {
+ // AI分析状态统计
+ if (repo.analyzed_at && repo.analysis_failed) {
+ stats.failed++;
+ } else if (repo.analyzed_at && !repo.analysis_failed) {
+ stats.analyzed++;
+ } else {
+ stats.notAnalyzed++;
+ }
+
+ // 订阅状态统计
+ if (releaseSubscriptions.has(repo.id)) {
+ stats.subscribed++;
+ } else {
+ stats.notSubscribed++;
+ }
+
+ // 自定义状态统计 - 与编辑页面逻辑一致
+ // 描述:有自定义描述标记(包括明确清空),且内容与AI/原始不同
+ const hasCustomDesc = repo.custom_description !== undefined;
+ const repoDesc = (repo.description || '').trim();
+ const aiDesc = (repo.ai_summary || '').trim();
+ const customDesc = (repo.custom_description || '').trim();
+ const isDescEdited = hasCustomDesc &&
+ (customDesc === '' || (customDesc !== repoDesc && customDesc !== aiDesc));
+
+ // 标签:有自定义标签标记(包括明确清空),且内容与AI/Topics不同
+ const hasCustomTags = repo.custom_tags !== undefined;
+ const aiTags = repo.ai_tags || [];
+ const topics = repo.topics || [];
+ const customTags = repo.custom_tags || [];
+ const isTagsEdited = hasCustomTags &&
+ (customTags.length === 0 || (
+ JSON.stringify([...customTags].sort()) !== JSON.stringify([...aiTags].sort()) &&
+ JSON.stringify([...customTags].sort()) !== JSON.stringify([...topics].sort())
+ ));
+
+ // 分类:有自定义分类标记(包括明确清空)
+ const isCategoryEdited = repo.custom_category !== undefined &&
+ (repo.custom_category === '' || repo.custom_category.trim() !== '');
+
+ // 任意一个为true则视为已编辑(注意:分类锁定不算编辑)
+ const isCustomized = isDescEdited || isTagsEdited || isCategoryEdited;
+ if (isCustomized) {
+ stats.edited++;
+ } else {
+ stats.notEdited++;
+ }
+
+ // 锁定状态统计
+ const isCategoryLocked = !!repo.category_locked;
+ if (isCategoryLocked) {
+ stats.locked++;
+ } else {
+ stats.notLocked++;
+ }
+ });
+
+ return stats;
+ }, [repositories, releaseSubscriptions]);
const [searchHistory, setSearchHistory] = useState([]);
const [showSearchHistory, setShowSearchHistory] = useState(false);
const [searchSuggestions, setSearchSuggestions] = useState([]);
@@ -32,13 +108,15 @@ export const SearchBar: React.FC = () => {
useEffect(() => {
// Extract unique languages, tags, and platforms from repositories
- const languages = [...new Set(repositories.map(r => r.language).filter(Boolean))];
+ const languages = [...new Set(repositories.map(r => r.language).filter(Boolean))] as string[];
+ // 标签包含AI标签、GitHub topics和用户自定义标签
const tags = [...new Set([
...repositories.flatMap(r => r.ai_tags || []),
- ...repositories.flatMap(r => r.topics || [])
+ ...repositories.flatMap(r => r.topics || []),
+ ...repositories.flatMap(r => r.custom_tags || [])
])];
- const platforms = [...new Set(repositories.flatMap(r => r.ai_platforms || []))];
-
+ const platforms = [...new Set(repositories.flatMap(r => r.ai_platforms || []))] as string[];
+
setAvailableLanguages(languages);
setAvailableTags(tags);
setAvailablePlatforms(platforms);
@@ -64,16 +142,18 @@ export const SearchBar: React.FC = () => {
}, [repositories]);
useEffect(() => {
- // Only perform search when filters change (not when query changes from AI search)
const performSearch = async () => {
if (!searchFilters.query) {
performBasicFilter();
+ } else {
+ const textResults = performBasicTextSearch(repositories, searchFilters.query);
+ const finalFiltered = applyFilters(textResults);
+ setSearchResults(finalFiltered);
}
- // Note: AI search is handled by handleAISearch function directly
};
performSearch();
- }, [searchFilters.languages, searchFilters.tags, searchFilters.platforms, searchFilters.isAnalyzed, searchFilters.isSubscribed, searchFilters.minStars, searchFilters.maxStars, searchFilters.sortBy, searchFilters.sortOrder, repositories, releaseSubscriptions]);
+ }, [searchFilters.languages, searchFilters.tags, searchFilters.platforms, searchFilters.isAnalyzed, searchFilters.isSubscribed, searchFilters.isEdited, searchFilters.isCategoryLocked, searchFilters.analysisFailed, searchFilters.minStars, searchFilters.maxStars, searchFilters.sortBy, searchFilters.sortOrder, searchFilters.query, repositories, releaseSubscriptions]);
// Real-time search effect for repository name matching
useEffect(() => {
@@ -126,43 +206,6 @@ export const SearchBar: React.FC = () => {
console.log(`Real-time search completed in ${(endTime - startTime).toFixed(2)}ms`);
};
- const performAdvancedSearch = async () => {
- const startTime = performance.now();
- let filtered = repositories;
-
- // AI-powered natural language search with semantic understanding and re-ranking
- if (searchFilters.query) {
- const activeConfig = aiConfigs.find(config => config.id === activeAIConfig);
- if (activeConfig) {
- try {
- const aiService = new AIService(activeConfig, language);
- // Use enhanced AI search with semantic understanding and relevance scoring
- filtered = await aiService.searchRepositoriesWithReranking(filtered, searchFilters.query);
- } catch (error) {
- console.warn('AI search failed, falling back to basic search:', error);
- // Fallback to basic text search
- filtered = performBasicTextSearch(filtered, searchFilters.query);
- }
- } else {
- // Basic text search if no AI config
- filtered = performBasicTextSearch(filtered, searchFilters.query);
- }
- }
-
- // Apply other filters
- filtered = applyFilters(filtered);
- setSearchResults(filtered);
-
- const endTime = performance.now();
- const searchTime = endTime - startTime;
- console.log(`AI search completed in ${searchTime.toFixed(2)}ms`);
-
- // 通知搜索完成时间(可以通过store或其他方式传递给统计组件)
- if (searchFilters.query) {
- localStorage.setItem('lastSearchTime', searchTime.toString());
- }
- };
-
const performBasicFilter = () => {
const filtered = applyFilters(repositories);
setSearchResults(filtered);
@@ -176,14 +219,15 @@ export const SearchBar: React.FC = () => {
repo.name,
repo.full_name,
repo.description || '',
+ repo.custom_description || '',
repo.language || '',
...(repo.topics || []),
repo.ai_summary || '',
...(repo.ai_tags || []),
...(repo.ai_platforms || []),
+ ...(repo.custom_tags || []),
].join(' ').toLowerCase();
- // Split query into words and check if all words are present
const queryWords = normalizedQuery.split(/\s+/);
return queryWords.every(word => searchableText.includes(word));
});
@@ -199,10 +243,14 @@ export const SearchBar: React.FC = () => {
);
}
- // Tag filter
+ // Tag filter - 包含AI标签、GitHub topics和用户自定义标签
if (searchFilters.tags.length > 0) {
filtered = filtered.filter(repo => {
- const repoTags = [...(repo.ai_tags || []), ...(repo.topics || [])];
+ const repoTags = [
+ ...(repo.ai_tags || []),
+ ...(repo.topics || []),
+ ...(repo.custom_tags || [])
+ ];
return searchFilters.tags.some(tag => repoTags.includes(tag));
});
}
@@ -215,10 +263,10 @@ export const SearchBar: React.FC = () => {
});
}
- // AI analyzed filter
- if (searchFilters.isAnalyzed !== undefined) {
+ // AI analyzed filter - 与 analysisFailed 互斥
+ if (searchFilters.isAnalyzed !== undefined && searchFilters.analysisFailed === undefined) {
filtered = filtered.filter(repo =>
- searchFilters.isAnalyzed ? !!repo.analyzed_at : !repo.analyzed_at
+ searchFilters.isAnalyzed ? (!!repo.analyzed_at && !repo.analysis_failed) : !repo.analyzed_at
);
}
@@ -229,6 +277,50 @@ export const SearchBar: React.FC = () => {
);
}
+ // 自定义筛选 - 与编辑页面逻辑一致
+ if (searchFilters.isEdited !== undefined) {
+ filtered = filtered.filter(repo => {
+ const hasCustomDesc = repo.custom_description !== undefined;
+ const repoDesc = (repo.description || '').trim();
+ const aiDesc = (repo.ai_summary || '').trim();
+ const customDesc = (repo.custom_description || '').trim();
+ const isDescEdited = hasCustomDesc &&
+ (customDesc === '' || (customDesc !== repoDesc && customDesc !== aiDesc));
+
+ const hasCustomTags = repo.custom_tags !== undefined;
+ const aiTags = repo.ai_tags || [];
+ const topics = repo.topics || [];
+ const customTags = repo.custom_tags || [];
+ const isTagsEdited = hasCustomTags &&
+ (customTags.length === 0 || (
+ JSON.stringify([...customTags].sort()) !== JSON.stringify([...aiTags].sort()) &&
+ JSON.stringify([...customTags].sort()) !== JSON.stringify([...topics].sort())
+ ));
+
+ const isCategoryEdited = repo.custom_category !== undefined &&
+ (repo.custom_category === '' || repo.custom_category.trim() !== '');
+
+ const isRepoCustomized = isDescEdited || isTagsEdited || isCategoryEdited;
+ return searchFilters.isEdited ? isRepoCustomized : !isRepoCustomized;
+ });
+ }
+
+ // Category locked filter - 检查分类是否被锁定
+ if (searchFilters.isCategoryLocked !== undefined) {
+ filtered = filtered.filter(repo => {
+ const isLocked = !!repo.category_locked;
+ return searchFilters.isCategoryLocked ? isLocked : !isLocked;
+ });
+ }
+
+ // Analysis failed filter - 检查分析是否失败(需要有分析记录且标记为失败),与 isAnalyzed 互斥
+ if (searchFilters.analysisFailed !== undefined && searchFilters.isAnalyzed === undefined) {
+ filtered = filtered.filter(repo => {
+ const hasFailed = !!(repo.analyzed_at && repo.analysis_failed);
+ return searchFilters.analysisFailed ? hasFailed : !hasFailed;
+ });
+ }
+
// Star count filter
if (searchFilters.minStars !== undefined) {
filtered = filtered.filter(repo => repo.stargazers_count >= searchFilters.minStars!);
@@ -238,38 +330,118 @@ export const SearchBar: React.FC = () => {
}
// Sort
- filtered.sort((a, b) => {
- let aValue: any, bValue: any;
-
+ const getSortValue = (repo: Repository): number | string => {
switch (searchFilters.sortBy) {
case 'stars':
- aValue = a.stargazers_count;
- bValue = b.stargazers_count;
- break;
+ return repo.stargazers_count;
case 'updated':
- aValue = new Date(a.pushed_at || a.updated_at).getTime();
- bValue = new Date(b.pushed_at || b.updated_at).getTime();
- break;
+ return new Date(repo.pushed_at || repo.updated_at).getTime();
case 'name':
- aValue = a.name.toLowerCase();
- bValue = b.name.toLowerCase();
- break;
+ return repo.name.toLowerCase();
case 'starred':
- aValue = a.starred_at ? new Date(a.starred_at).getTime() : 0;
- bValue = b.starred_at ? new Date(b.starred_at).getTime() : 0;
- break;
+ return repo.starred_at ? new Date(repo.starred_at).getTime() : 0;
default:
- aValue = new Date(a.pushed_at || a.updated_at).getTime();
- bValue = new Date(b.pushed_at || b.updated_at).getTime();
+ return new Date(repo.pushed_at || repo.updated_at).getTime();
}
+ };
- if (searchFilters.sortOrder === 'desc') {
- return bValue > aValue ? 1 : -1;
- } else {
- return aValue > bValue ? 1 : -1;
- }
+ filtered.sort((a, b) => {
+ const aValue = getSortValue(a);
+ const bValue = getSortValue(b);
+ if (aValue < bValue) return searchFilters.sortOrder === 'desc' ? 1 : -1;
+ if (aValue > bValue) return searchFilters.sortOrder === 'desc' ? -1 : 1;
+ return 0;
});
+ // 如果分类锁定筛选导致结果为0,自动清除该筛选条件
+ if (searchFilters.isCategoryLocked !== undefined && filtered.length === 0) {
+ // 检查是否是分类锁定筛选导致的结果为空
+ const filteredWithoutCategoryLock = repos.filter(repo => {
+ // 复制当前的筛选条件,但排除分类锁定
+ let tempFiltered = true;
+
+ // Language filter
+ if (searchFilters.languages.length > 0) {
+ tempFiltered = tempFiltered && !!(repo.language && searchFilters.languages.includes(repo.language));
+ }
+
+ // Tag filter
+ if (searchFilters.tags.length > 0) {
+ const repoTags = [...(repo.ai_tags || []), ...(repo.topics || []), ...(repo.custom_tags || [])];
+ tempFiltered = tempFiltered && searchFilters.tags.some(tag => repoTags.includes(tag));
+ }
+
+ // Platform filter
+ if (searchFilters.platforms.length > 0) {
+ const repoPlatforms = repo.ai_platforms || [];
+ tempFiltered = tempFiltered && searchFilters.platforms.some(platform => repoPlatforms.includes(platform));
+ }
+
+ // AI analyzed filter
+ if (searchFilters.isAnalyzed !== undefined && searchFilters.analysisFailed === undefined) {
+ tempFiltered = tempFiltered && (searchFilters.isAnalyzed ? (!!repo.analyzed_at && !repo.analysis_failed) : !repo.analyzed_at);
+ }
+
+ // Release subscription filter
+ if (searchFilters.isSubscribed !== undefined) {
+ tempFiltered = tempFiltered && (searchFilters.isSubscribed ? releaseSubscriptions.has(repo.id) : !releaseSubscriptions.has(repo.id));
+ }
+
+ // Edited filter
+ if (searchFilters.isEdited !== undefined) {
+ const hasCustomDesc = repo.custom_description !== undefined;
+ const repoDesc = (repo.description || '').trim();
+ const aiDesc = (repo.ai_summary || '').trim();
+ const customDesc = (repo.custom_description || '').trim();
+ const isDescEdited = hasCustomDesc &&
+ (customDesc === '' || (customDesc !== repoDesc && customDesc !== aiDesc));
+ const hasCustomTags = repo.custom_tags !== undefined;
+ const aiTags = repo.ai_tags || [];
+ const topics = repo.topics || [];
+ const customTags = repo.custom_tags || [];
+ const isTagsEdited = hasCustomTags &&
+ (customTags.length === 0 || (
+ JSON.stringify([...customTags].sort()) !== JSON.stringify([...aiTags].sort()) &&
+ JSON.stringify([...customTags].sort()) !== JSON.stringify([...topics].sort())
+ ));
+ const isCategoryEdited = repo.custom_category !== undefined &&
+ (repo.custom_category === '' || repo.custom_category.trim() !== '');
+ const isRepoCustomized = isDescEdited || isTagsEdited || isCategoryEdited;
+ tempFiltered = tempFiltered && (searchFilters.isEdited ? isRepoCustomized : !isRepoCustomized);
+ }
+
+ // Analysis failed filter
+ if (searchFilters.analysisFailed !== undefined && searchFilters.isAnalyzed === undefined) {
+ const hasFailed = !!(repo.analyzed_at && repo.analysis_failed);
+ tempFiltered = tempFiltered && (searchFilters.analysisFailed ? hasFailed : !hasFailed);
+ }
+
+ // Star count filter
+ if (searchFilters.minStars !== undefined) {
+ tempFiltered = tempFiltered && repo.stargazers_count >= searchFilters.minStars;
+ }
+ if (searchFilters.maxStars !== undefined) {
+ tempFiltered = tempFiltered && repo.stargazers_count <= searchFilters.maxStars;
+ }
+
+ return tempFiltered;
+ });
+
+ // 如果去掉分类锁定筛选后有结果,说明是分类锁定导致的结果为空,自动清除
+ if (filteredWithoutCategoryLock.length > 0) {
+ console.log('分类锁定筛选导致结果为空,自动清除该筛选条件');
+ setSearchFilters({ isCategoryLocked: undefined });
+ // 返回去掉分类锁定筛选的结果
+ return filteredWithoutCategoryLock.sort((a, b) => {
+ const aValue = getSortValue(a);
+ const bValue = getSortValue(b);
+ if (aValue < bValue) return searchFilters.sortOrder === 'desc' ? 1 : -1;
+ if (aValue > bValue) return searchFilters.sortOrder === 'desc' ? -1 : 1;
+ return 0;
+ });
+ }
+ }
+
return filtered;
};
@@ -396,6 +568,10 @@ export const SearchBar: React.FC = () => {
setIsRealTimeSearch(false);
setSearchFilters({ query: historyQuery });
setShowSearchHistory(false);
+
+ const textResults = performBasicTextSearch(repositories, historyQuery);
+ const finalFiltered = applyFilters(textResults);
+ setSearchResults(finalFiltered);
};
const handleSuggestionClick = (suggestion: string) => {
@@ -412,8 +588,8 @@ export const SearchBar: React.FC = () => {
- const handleKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
handleAISearch();
}
};
@@ -453,17 +629,23 @@ export const SearchBar: React.FC = () => {
maxStars: undefined,
isAnalyzed: undefined,
isSubscribed: undefined,
+ isEdited: undefined,
+ isCategoryLocked: undefined,
+ analysisFailed: undefined,
});
};
- const activeFiltersCount =
- searchFilters.languages.length +
- searchFilters.tags.length +
+ const activeFiltersCount =
+ searchFilters.languages.length +
+ searchFilters.tags.length +
searchFilters.platforms.length +
(searchFilters.minStars !== undefined ? 1 : 0) +
(searchFilters.maxStars !== undefined ? 1 : 0) +
(searchFilters.isAnalyzed !== undefined ? 1 : 0) +
- (searchFilters.isSubscribed !== undefined ? 1 : 0);
+ (searchFilters.isSubscribed !== undefined ? 1 : 0) +
+ (searchFilters.isEdited !== undefined ? 1 : 0) +
+ (searchFilters.isCategoryLocked !== undefined ? 1 : 0) +
+ (searchFilters.analysisFailed !== undefined ? 1 : 0);
const getPlatformIcon = (platform: string) => {
const platformLower = platform.toLowerCase();
@@ -541,7 +723,7 @@ export const SearchBar: React.FC = () => {
)}
value={searchQuery}
onChange={handleInputChange}
- onKeyPress={handleKeyPress}
+ onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onCompositionStart={handleCompositionStart}
@@ -716,58 +898,150 @@ export const SearchBar: React.FC = () => {
{t('状态过滤', 'Status Filters')}
-
setSearchFilters({
- isAnalyzed: searchFilters.isAnalyzed === true ? undefined : true
- })}
- className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
- searchFilters.isAnalyzed === true
- ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
- : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
- }`}
- >
-
- {t('已AI分析', 'AI Analyzed')}
-
-
setSearchFilters({
- isAnalyzed: searchFilters.isAnalyzed === false ? undefined : false
- })}
- className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
- searchFilters.isAnalyzed === false
- ? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'
- : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
- }`}
- >
-
- {t('未AI分析', 'Not Analyzed')}
-
-
setSearchFilters({
- isSubscribed: searchFilters.isSubscribed === true ? undefined : true
- })}
- className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
- searchFilters.isSubscribed === true
- ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
- : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
- }`}
- >
-
- {t('已订阅Release', 'Subscribed to Releases')}
-
-
setSearchFilters({
- isSubscribed: searchFilters.isSubscribed === false ? undefined : false
- })}
- className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
- searchFilters.isSubscribed === false
- ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
- : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
- }`}
- >
-
- {t('未订阅Release', 'Not Subscribed to Releases')}
-
+ {/* 已AI分析 - 仅在存在已分析仓库或当前已选择时显示,且与"分析失败"互斥 */}
+ {(statusStats.analyzed > 0 || searchFilters.isAnalyzed === true) && searchFilters.analysisFailed !== true && (
+
setSearchFilters({
+ isAnalyzed: searchFilters.isAnalyzed === true ? undefined : true
+ })}
+ title={t('显示已完成AI分析的仓库', 'Show repositories with AI analysis completed')}
+ className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ searchFilters.isAnalyzed === true
+ ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+
+ {t('已AI分析', 'AI Analyzed')}
+ ({statusStats.analyzed})
+
+ )}
+ {/* 未AI分析 - 仅在存在未分析仓库时显示 */}
+ {statusStats.notAnalyzed > 0 && (
+
setSearchFilters({
+ isAnalyzed: searchFilters.isAnalyzed === false ? undefined : false
+ })}
+ title={t('显示尚未进行AI分析的仓库', 'Show repositories without AI analysis')}
+ className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ searchFilters.isAnalyzed === false
+ ? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+
+ {t('未AI分析', 'Not Analyzed')}
+ ({statusStats.notAnalyzed})
+
+ )}
+ {/* 分析失败 - 仅在存在失败仓库或当前已选择时显示,且与"已AI分析"互斥 */}
+ {(statusStats.failed > 0 || searchFilters.analysisFailed === true) && searchFilters.isAnalyzed !== true && (
+
setSearchFilters({
+ analysisFailed: searchFilters.analysisFailed === true ? undefined : true
+ })}
+ title={t('显示AI分析失败的仓库', 'Show repositories with failed AI analysis')}
+ className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ searchFilters.analysisFailed === true
+ ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+
+ {t('分析失败', 'Analysis Failed')}
+ ({statusStats.failed})
+
+ )}
+ {/* 已订阅Release - 仅在存在已订阅仓库或当前已选择时显示 */}
+ {(statusStats.subscribed > 0 || searchFilters.isSubscribed === true) && (
+
setSearchFilters({
+ isSubscribed: searchFilters.isSubscribed === true ? undefined : true
+ })}
+ title={t('显示已订阅Release通知的仓库', 'Show repositories subscribed to release notifications')}
+ className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ searchFilters.isSubscribed === true
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+
+ {t('已订阅Release', 'Subscribed to Releases')}
+ ({statusStats.subscribed})
+
+ )}
+ {/* 未订阅Release - 仅在存在未订阅仓库时显示 */}
+ {statusStats.notSubscribed > 0 && (
+
setSearchFilters({
+ isSubscribed: searchFilters.isSubscribed === false ? undefined : false
+ })}
+ title={t('显示未订阅Release通知的仓库', 'Show repositories not subscribed to releases')}
+ className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ searchFilters.isSubscribed === false
+ ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+
+ {t('未订阅Release', 'Not Subscribed to Releases')}
+ ({statusStats.notSubscribed})
+
+ )}
+ {/* 已自定义 - 仅在存在已自定义仓库或当前已选择时显示 */}
+ {(statusStats.edited > 0 || searchFilters.isEdited === true) && (
+
setSearchFilters({
+ isEdited: searchFilters.isEdited === true ? undefined : true
+ })}
+ title={t('显示已自定义的仓库(包括自定义描述、标签、分类)', 'Show customized repositories (including custom description, tags, category)')}
+ className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ searchFilters.isEdited === true
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+
+ {t('已自定义', 'Customized')}
+ ({statusStats.edited})
+
+ )}
+ {/* 分类已锁定 - 仅在存在已锁定仓库或当前已选择时显示 */}
+ {(statusStats.locked > 0 || searchFilters.isCategoryLocked === true) && (
+
setSearchFilters({
+ isCategoryLocked: searchFilters.isCategoryLocked === true ? undefined : true
+ })}
+ title={t('显示分类已锁定的仓库(同步时不会自动更改分类)', 'Show repositories with locked category (won\'t auto-change during sync)')}
+ className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ searchFilters.isCategoryLocked === true
+ ? 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+
+ {t('分类已锁定', 'Category Locked')}
+ ({statusStats.locked})
+
+ )}
+ {/* 分类未锁定 - 仅在存在未锁定仓库或当前已选择时显示 */}
+ {(statusStats.notLocked > 0 || searchFilters.isCategoryLocked === false) && (
+
setSearchFilters({
+ isCategoryLocked: searchFilters.isCategoryLocked === false ? undefined : false
+ })}
+ title={t('显示分类未锁定的仓库(同步时可能会被自动更改分类)', 'Show repositories with unlocked category (may be auto-changed during sync)')}
+ className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ searchFilters.isCategoryLocked === false
+ ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+
+ {t('分类未锁定', 'Category Unlocked')}
+ ({statusStats.notLocked})
+
+ )}
@@ -857,7 +1131,7 @@ export const SearchBar: React.FC = () => {
setSearchFilters({
minStars: e.target.value ? parseInt(e.target.value) : undefined
})}
@@ -871,7 +1145,7 @@ export const SearchBar: React.FC = () => {
setSearchFilters({
maxStars: e.target.value ? parseInt(e.target.value) : undefined
})}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
index 55eb1ca0..a8b73dc5 100644
--- a/src/components/SettingsPanel.tsx
+++ b/src/components/SettingsPanel.tsx
@@ -1,1394 +1,455 @@
-import React, { useState, useEffect } from 'react';
-import {
- Bot,
- Plus,
- Edit3,
- Trash2,
- Save,
- X,
- TestTube,
- CheckCircle,
- AlertCircle,
- Cloud,
- Download,
- Upload,
- RefreshCw,
+import React, { useState, useRef, useEffect, useCallback } from 'react';
+import {
+ Settings,
Globe,
- MessageSquare,
- Package,
- ExternalLink,
- Mail,
- Github,
- Twitter,
+ Bot,
+ Cloud,
+ Database,
Server,
+ Package,
+ X,
+ Trash2,
} from 'lucide-react';
-import { AIConfig, WebDAVConfig, AIApiType, AIReasoningEffort } from '../types';
-import { useAppStore, getAllCategories } from '../store/useAppStore';
-import { AIService } from '../services/aiService';
-import { WebDAVService } from '../services/webdavService';
-import { UpdateChecker } from './UpdateChecker';
-import { backend } from '../services/backendAdapter';
-
-export const SettingsPanel: React.FC = () => {
- const {
- aiConfigs,
- activeAIConfig,
- webdavConfigs,
- activeWebDAVConfig,
- lastBackup,
- repositories,
- releases,
- customCategories,
- hiddenDefaultCategoryIds,
- theme,
- language,
- addAIConfig,
- updateAIConfig,
- deleteAIConfig,
- setActiveAIConfig,
- addWebDAVConfig,
- updateWebDAVConfig,
- deleteWebDAVConfig,
- setActiveWebDAVConfig,
- setLastBackup,
- setLanguage,
- setRepositories,
- setReleases,
- addCustomCategory,
- deleteCustomCategory,
- hideDefaultCategory,
- showDefaultCategory,
- backendApiSecret,
- setBackendApiSecret,
- setAIConfigs,
- setWebDAVConfigs,
- } = useAppStore();
-
- const [showAIForm, setShowAIForm] = useState(false);
- const [showWebDAVForm, setShowWebDAVForm] = useState(false);
- const [editingAIId, setEditingAIId] = useState
(null);
- const [editingWebDAVId, setEditingWebDAVId] = useState(null);
- const [testingAIId, setTestingAIId] = useState(null);
- const [testingWebDAVId, setTestingWebDAVId] = useState(null);
- const [isBackingUp, setIsBackingUp] = useState(false);
- const [isRestoring, setIsRestoring] = useState(false);
- const [showCustomPrompt, setShowCustomPrompt] = useState(false);
- const [backendStatus, setBackendStatus] = useState<'connected' | 'disconnected' | 'checking'>('disconnected');
- const [backendHealth, setBackendHealth] = useState<{ version: string; timestamp: string } | null>(null);
- const [isSyncingToBackend, setIsSyncingToBackend] = useState(false);
- const [isSyncingFromBackend, setIsSyncingFromBackend] = useState(false);
- const [backendSecretInput, setBackendSecretInput] = useState(backendApiSecret || '');
-
- // Check backend status on mount
- useEffect(() => {
- const checkBackend = async () => {
- setBackendStatus('checking');
- const health = await backend.checkHealth();
- if (health) {
- setBackendStatus('connected');
- setBackendHealth({ version: health.version, timestamp: health.timestamp });
- } else {
- setBackendStatus('disconnected');
- setBackendHealth(null);
- }
- };
- checkBackend();
- }, []);
-
- type AIFormState = {
- name: string;
- apiType: AIApiType;
- baseUrl: string;
- apiKey: string;
- model: string;
- customPrompt: string;
- useCustomPrompt: boolean;
- concurrency: number;
- reasoningEffort: '' | AIReasoningEffort;
- };
-
- const [aiForm, setAIForm] = useState({
- name: '',
- apiType: 'openai',
- baseUrl: '',
- apiKey: '',
- model: '',
- customPrompt: '',
- useCustomPrompt: false,
- concurrency: 1,
- reasoningEffort: '',
- });
-
- const [webdavForm, setWebDAVForm] = useState({
- name: '',
- url: '',
- username: '',
- password: '',
- path: '/',
- });
-
- const resetAIForm = () => {
- setAIForm({
- name: '',
- apiType: 'openai',
- baseUrl: '',
- apiKey: '',
- model: '',
- customPrompt: '',
- useCustomPrompt: false,
- concurrency: 1,
- reasoningEffort: '',
- });
- setShowAIForm(false);
- setEditingAIId(null);
- setShowCustomPrompt(false);
- };
-
- const resetWebDAVForm = () => {
- setWebDAVForm({
- name: '',
- url: '',
- username: '',
- password: '',
- path: '/',
- });
- setShowWebDAVForm(false);
- setEditingWebDAVId(null);
- };
+import { useAppStore } from '../store/useAppStore';
+import {
+ GeneralPanel,
+ AIConfigPanel,
+ WebDAVPanel,
+ BackupPanel,
+ BackendPanel,
+ CategoryPanel,
+ DataManagementPanel,
+} from './settings';
+
+type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'data';
+
+interface SettingsTabItem {
+ id: SettingsTab;
+ label: string;
+ icon: React.ReactNode;
+}
- const handleSaveAI = () => {
- if (!aiForm.name || !aiForm.baseUrl || !aiForm.apiKey || !aiForm.model) {
- alert(t('请填写所有必填字段', 'Please fill in all required fields'));
- return;
- }
+interface SettingsPanelProps {
+ isOpen?: boolean;
+ onClose?: () => void;
+ isModal?: boolean;
+}
- const config: AIConfig = {
- id: editingAIId || Date.now().toString(),
- name: aiForm.name,
- apiType: aiForm.apiType,
- baseUrl: aiForm.baseUrl.replace(/\/$/, ''), // Remove trailing slash
- apiKey: aiForm.apiKey,
- model: aiForm.model,
- isActive: false,
- customPrompt: aiForm.customPrompt || undefined,
- useCustomPrompt: aiForm.useCustomPrompt,
- concurrency: aiForm.concurrency,
- reasoningEffort: aiForm.reasoningEffort || undefined,
- };
+// 移动端标签导航组件
+interface MobileTabNavProps {
+ tabs: SettingsTabItem[];
+ activeTab: SettingsTab;
+ onTabChange: (tab: SettingsTab) => void;
+}
- if (editingAIId) {
- updateAIConfig(editingAIId, config);
- } else {
- addAIConfig(config);
+const MobileTabNav: React.FC = ({ tabs, activeTab, onTabChange }) => {
+ const scrollContainerRef = useRef(null);
+ const tabRefs = useRef>(new Map());
+ const [indicatorStyle, setIndicatorStyle] = useState({ translateX: 0, width: 0 });
+ const isScrollingRef = useRef(false);
+ const scrollTimeoutRef = useRef | null>(null);
+ const rafRef = useRef(null);
+
+ // 使用 requestAnimationFrame 更新指示器,避免闪烁
+ const updateIndicator = useCallback(() => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
}
- resetAIForm();
- };
+ rafRef.current = requestAnimationFrame(() => {
+ const activeButton = tabRefs.current.get(activeTab);
+ if (activeButton && scrollContainerRef.current) {
+ // 使用 offsetLeft 代替 getBoundingClientRect,避免重排导致的闪烁
+ const container = scrollContainerRef.current;
+ const translateX = activeButton.offsetLeft - container.scrollLeft;
+ const width = activeButton.offsetWidth;
- const handleEditAI = (config: AIConfig) => {
- setAIForm({
- name: config.name,
- apiType: config.apiType || 'openai',
- baseUrl: config.baseUrl,
- apiKey: config.apiKey,
- model: config.model,
- customPrompt: config.customPrompt || '',
- useCustomPrompt: config.useCustomPrompt || false,
- concurrency: config.concurrency || 1,
- reasoningEffort: (config.reasoningEffort === 'minimal' ? 'low' : config.reasoningEffort) || '',
- });
- setEditingAIId(config.id);
- setShowAIForm(true);
- setShowCustomPrompt(config.useCustomPrompt || false);
- };
-
- const handleTestAI = async (config: AIConfig) => {
- setTestingAIId(config.id);
- try {
- const aiService = new AIService(config, language);
- const isConnected = await aiService.testConnection();
-
- if (isConnected) {
- alert(t('AI服务连接成功!', 'AI service connection successful!'));
- } else {
- alert(t('AI服务连接失败,请检查配置。', 'AI service connection failed. Please check configuration.'));
+ setIndicatorStyle({ translateX, width });
}
- } catch (error) {
- console.error('AI test failed:', error);
- alert(t('AI服务测试失败,请检查网络连接和配置。', 'AI service test failed. Please check network connection and configuration.'));
- } finally {
- setTestingAIId(null);
- }
- };
-
- const handleSaveWebDAV = () => {
- const errors = WebDAVService.validateConfig(webdavForm);
- if (errors.length > 0) {
- alert(errors.join('\n'));
- return;
- }
-
- const config: WebDAVConfig = {
- id: editingWebDAVId || Date.now().toString(),
- name: webdavForm.name,
- url: webdavForm.url.replace(/\/$/, ''), // Remove trailing slash
- username: webdavForm.username,
- password: webdavForm.password,
- path: webdavForm.path,
- isActive: false,
- };
-
- if (editingWebDAVId) {
- updateWebDAVConfig(editingWebDAVId, config);
- } else {
- addWebDAVConfig(config);
- }
-
- resetWebDAVForm();
- };
-
- const handleEditWebDAV = (config: WebDAVConfig) => {
- setWebDAVForm({
- name: config.name,
- url: config.url,
- username: config.username,
- password: config.password,
- path: config.path,
});
- setEditingWebDAVId(config.id);
- setShowWebDAVForm(true);
- };
-
- const handleTestWebDAV = async (config: WebDAVConfig) => {
- setTestingWebDAVId(config.id);
- try {
- const webdavService = new WebDAVService(config);
- const isConnected = await webdavService.testConnection();
+ }, [activeTab]);
+
+ // 滚动到活动标签
+ const scrollToActiveTab = useCallback(() => {
+ const activeButton = tabRefs.current.get(activeTab);
+ if (activeButton && scrollContainerRef.current) {
+ const container = scrollContainerRef.current;
+ const scrollLeft = activeButton.offsetLeft - (container.offsetWidth / 2) + (activeButton.offsetWidth / 2);
- if (isConnected) {
- alert(t('WebDAV连接成功!', 'WebDAV connection successful!'));
- } else {
- alert(t('WebDAV连接失败,请检查配置。', 'WebDAV connection failed. Please check configuration.'));
- }
- } catch (error) {
- console.error('WebDAV test failed:', error);
- alert(`${t('WebDAV测试失败', 'WebDAV test failed')}: ${error.message}`);
- } finally {
- setTestingWebDAVId(null);
+ container.scrollTo({
+ left: Math.max(0, scrollLeft),
+ behavior: 'smooth',
+ });
}
- };
+ }, [activeTab]);
- const handleBackup = async () => {
- const activeConfig = webdavConfigs.find(config => config.id === activeWebDAVConfig);
- if (!activeConfig) {
- alert(t('请先配置并激活WebDAV服务。', 'Please configure and activate WebDAV service first.'));
- return;
- }
-
- setIsBackingUp(true);
- try {
- const webdavService = new WebDAVService(activeConfig);
-
- const backupData = {
- repositories,
- releases,
- customCategories,
- hiddenDefaultCategoryIds,
- aiConfigs: aiConfigs.map(config => ({
- ...config,
- apiKey: '***' // Don't backup API keys for security
- })),
- webdavConfigs: webdavConfigs.map(config => ({
- ...config,
- password: '***' // Don't backup passwords for security
- })),
- exportedAt: new Date().toISOString(),
- version: '1.0'
- };
+ // 分离 useEffect:初始化和标签切换时更新指示器
+ useEffect(() => {
+ // 初始计算
+ updateIndicator();
+ }, [updateIndicator]);
- const filename = `github-stars-backup-${new Date().toISOString().split('T')[0]}.json`;
- const success = await webdavService.uploadFile(filename, JSON.stringify(backupData, null, 2));
-
- if (success) {
- setLastBackup(new Date().toISOString());
- alert(t('数据备份成功!', 'Data backup successful!'));
- }
- } catch (error) {
- console.error('Backup failed:', error);
- alert(`${t('备份失败', 'Backup failed')}: ${error.message}`);
- } finally {
- setIsBackingUp(false);
+ // 标签切换时先滚动再更新指示器
+ useEffect(() => {
+ scrollToActiveTab();
+ // 延迟更新指示器,等待滚动完成
+ const timer = setTimeout(() => {
+ updateIndicator();
+ }, 350);
+ return () => clearTimeout(timer);
+ }, [activeTab, scrollToActiveTab]);
+
+ // 处理滚动状态 - 使用 ref 避免重新创建函数
+ const handleScroll = useCallback(() => {
+ if (!isScrollingRef.current) {
+ isScrollingRef.current = true;
}
- };
-
- const handleRestore = async () => {
- const activeConfig = webdavConfigs.find(config => config.id === activeWebDAVConfig);
- if (!activeConfig) {
- alert(t('请先配置并激活WebDAV服务。', 'Please configure and activate WebDAV service first.'));
- return;
+
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
}
-
- const confirmMessage = t(
- '恢复数据将覆盖当前所有数据,是否继续?',
- 'Restoring data will overwrite all current data. Continue?'
- );
- if (!confirm(confirmMessage)) return;
+ scrollTimeoutRef.current = setTimeout(() => {
+ isScrollingRef.current = false;
+ updateIndicator();
+ }, 150);
+ }, [updateIndicator]);
- setIsRestoring(true);
- try {
- const webdavService = new WebDAVService(activeConfig);
- const files = await webdavService.listFiles();
-
- const backupFiles = files.filter(file => file.startsWith('github-stars-backup-'));
- if (backupFiles.length === 0) {
- alert(t('未找到备份文件。', 'No backup files found.'));
- return;
+ useEffect(() => {
+ return () => {
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
}
-
- // Use the most recent backup file
- const latestBackup = backupFiles.sort().reverse()[0];
- const backupContent = await webdavService.downloadFile(latestBackup);
-
- if (backupContent) {
- const backupData = JSON.parse(backupContent);
-
- // 1) 恢复仓库与发布
- if (Array.isArray(backupData.repositories)) {
- setRepositories(backupData.repositories);
- }
- if (Array.isArray(backupData.releases)) {
- setReleases(backupData.releases);
- }
-
- // 2) 恢复自定义分类(全部替换)
- try {
- // 先清空现有自定义分类
- if (Array.isArray(customCategories)) {
- for (const cat of customCategories) {
- if (cat && cat.id) {
- deleteCustomCategory(cat.id);
- }
- }
- }
- // 再添加备份中的自定义分类
- if (Array.isArray(backupData.customCategories)) {
- for (const cat of backupData.customCategories) {
- if (cat && cat.id && cat.name) {
- addCustomCategory({ ...cat, isCustom: true });
- }
- }
- }
- if (Array.isArray(hiddenDefaultCategoryIds)) {
- for (const categoryId of hiddenDefaultCategoryIds) {
- if (typeof categoryId === 'string') {
- showDefaultCategory(categoryId);
- }
- }
- }
- if (Array.isArray(backupData.hiddenDefaultCategoryIds)) {
- for (const categoryId of backupData.hiddenDefaultCategoryIds) {
- if (typeof categoryId === 'string') {
- hideDefaultCategory(categoryId);
- }
- }
- }
- } catch (e) {
- console.warn('恢复自定义分类时发生问题:', e);
- }
-
- // 3) 合并 AI 配置(保留现有密钥;备份中密钥为***时不覆盖)
- try {
- if (Array.isArray(backupData.aiConfigs)) {
- const currentMap = new Map(aiConfigs.map((c: AIConfig) => [c.id, c]));
- for (const cfg of backupData.aiConfigs as AIConfig[]) {
- if (!cfg || !cfg.id) continue;
- const existing = currentMap.get(cfg.id);
- const isMasked = cfg.apiKey === '***';
- if (existing) {
- updateAIConfig(cfg.id, {
- name: cfg.name,
- baseUrl: cfg.baseUrl,
- model: cfg.model,
- customPrompt: cfg.customPrompt,
- useCustomPrompt: cfg.useCustomPrompt,
- concurrency: cfg.concurrency,
- reasoningEffort: cfg.reasoningEffort,
- // 仅当备份未掩码时才覆盖 apiKey
- apiKey: isMasked ? existing.apiKey : cfg.apiKey,
- // 保留现有 isActive 状态
- isActive: existing.isActive,
- });
- } else {
- addAIConfig({
- ...cfg,
- apiKey: isMasked ? '' : cfg.apiKey,
- isActive: false,
- });
- }
- }
- }
- } catch (e) {
- console.warn('恢复 AI 配置时发生问题:', e);
- }
-
- // 4) 合并 WebDAV 配置(保留现有密码;备份中密码为***时不覆盖)
- try {
- if (Array.isArray(backupData.webdavConfigs)) {
- const currentMap = new Map(webdavConfigs.map((c: WebDAVConfig) => [c.id, c]));
- for (const cfg of backupData.webdavConfigs as WebDAVConfig[]) {
- if (!cfg || !cfg.id) continue;
- const existing = currentMap.get(cfg.id);
- const isMasked = cfg.password === '***';
- if (existing) {
- updateWebDAVConfig(cfg.id, {
- name: cfg.name,
- url: cfg.url,
- username: cfg.username,
- path: cfg.path,
- // 仅当备份未掩码时才覆盖密码
- password: isMasked ? existing.password : cfg.password,
- // 保留现有 isActive 状态
- isActive: existing.isActive,
- });
- } else {
- addWebDAVConfig({
- ...cfg,
- password: isMasked ? '' : cfg.password,
- isActive: false,
- });
- }
- }
- }
- } catch (e) {
- console.warn('恢复 WebDAV 配置时发生问题:', e);
- }
-
- alert(t(
- `已从备份恢复数据:仓库 ${backupData.repositories?.length ?? 0},发布 ${backupData.releases?.length ?? 0},自定义分类 ${backupData.customCategories?.length ?? 0}。`,
- `Data restored from backup: repositories ${backupData.repositories?.length ?? 0}, releases ${backupData.releases?.length ?? 0}, custom categories ${backupData.customCategories?.length ?? 0}.`
- ));
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
}
- } catch (error) {
- console.error('Restore failed:', error);
- alert(`${t('恢复失败', 'Restore failed')}: ${error.message}`);
- } finally {
- setIsRestoring(false);
- }
- };
-
- const getDefaultPrompt = () => {
- if (language === 'zh') {
- return `请分析这个GitHub仓库并提供:
-
-1. 一个简洁的中文概述(不超过50字),说明这个仓库的主要功能和用途
-2. 3-5个相关的应用类型标签(用中文,类似应用商店的分类,如:开发工具、Web应用、移动应用、数据库、AI工具等{CATEGORIES_INFO ? ',请优先从提供的分类中选择' : ''})
-3. 支持的平台类型(从以下选择:mac、windows、linux、ios、android、docker、web、cli)
-
-重要:请严格使用中文进行分析和回复,无论原始README是什么语言。
-
-请以JSON格式回复:
-{
- "summary": "你的中文概述",
- "tags": ["标签1", "标签2", "标签3", "标签4", "标签5"],
- "platforms": ["platform1", "platform2", "platform3"]
-}
+ };
+ }, []);
-仓库信息:
-{REPO_INFO}{CATEGORIES_INFO}
+ return (
+
+ {/* 滚动容器 */}
+
+ {tabs.map((tab) => (
+ {
+ if (el) tabRefs.current.set(tab.id, el);
+ }}
+ onClick={() => onTabChange(tab.id)}
+ role="tab"
+ id={`settings-tab-${tab.id}`}
+ aria-selected={activeTab === tab.id}
+ aria-controls={`settings-tabpanel-${tab.id}`}
+ className={`
+ flex-shrink-0 flex items-center space-x-1.5 px-3 py-2 rounded-full
+ transition-all duration-150 ease-out snap-center
+ min-h-[36px] touch-manipulation
+ ${activeTab === tab.id
+ ? 'text-blue-600 dark:text-blue-400'
+ : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700/50'
+ }
+ `}
+ style={{
+ WebkitTapHighlightColor: 'transparent',
+ }}
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ {/* 底部活动指示器 */}
+
+
+ {/* 左右渐变遮罩 */}
+
+
+
+ );
+};
-重点关注实用性和准确的分类,帮助用户快速理解仓库的用途和支持的平台。`;
+export const SettingsPanel: React.FC = ({
+ isOpen = true,
+ onClose,
+ isModal = false
+}) => {
+ const { language, setCurrentView } = useAppStore();
+ const [activeTab, setActiveTab] = useState('general');
+ const [displayTab, setDisplayTab] = useState('general');
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const tabChangeTimeoutRef = useRef | null>(null);
+ const tabResetTimeoutRef = useRef | null>(null);
+
+ const t = (zh: string, en: string) => (language === 'zh' ? zh : en);
+
+ const handleClose = () => {
+ if (onClose) {
+ onClose();
} else {
- return `Please analyze this GitHub repository and provide:
-
-1. A concise English overview (no more than 50 words) explaining the main functionality and purpose of this repository
-2. 3-5 relevant application type tags (in English, similar to app store categories, such as: development tools, web apps, mobile apps, database, AI tools, etc.{CATEGORIES_INFO ? ', please prioritize from the provided categories' : ''})
-3. Supported platform types (choose from: mac, windows, linux, ios, android, docker, web, cli)
-
-Important: Please strictly use English for analysis and response, regardless of the original README language.
-
-Please reply in JSON format:
-{
- "summary": "Your English overview",
- "tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],
- "platforms": ["platform1", "platform2", "platform3"]
-}
-
-Repository information:
-{REPO_INFO}{CATEGORIES_INFO}
-
-Focus on practicality and accurate categorization to help users quickly understand the repository's purpose and supported platforms.`;
+ setCurrentView('repositories');
}
};
- const t = (zh: string, en: string) => language === 'zh' ? zh : en;
- const hiddenDefaultCategories = getAllCategories([], language, []).filter(category => hiddenDefaultCategoryIds.includes(category.id));
+ // 处理标签切换,添加过渡动画
+ // 动画顺序:1.淡出当前内容 2.切换标签 3.淡入新内容
+ const handleTabChange = useCallback((tabId: SettingsTab) => {
+ if (tabId === activeTab || isTransitioning) return;
-
- const handleTestBackendConnection = async () => {
- setBackendStatus('checking');
- // Save the secret first
- setBackendApiSecret(backendSecretInput || null);
- // Re-init and check
- await backend.init();
- const health = await backend.checkHealth();
- const authOk = backendSecretInput ? await backend.verifyAuth() : true;
- if (health && authOk) {
- setBackendStatus('connected');
- setBackendHealth({ version: health.version, timestamp: health.timestamp });
- alert(t('后端连接成功!', 'Backend connection successful!'));
- } else {
- setBackendStatus('disconnected');
- setBackendHealth(null);
- alert(t(
- '后端连接失败,请检查服务器状态或 API Secret 是否正确。',
- 'Backend connection failed. Please check the server status or whether the API Secret is correct.'
- ));
+ if (tabChangeTimeoutRef.current) {
+ clearTimeout(tabChangeTimeoutRef.current);
}
- };
-
- const handleSyncToBackend = async () => {
- if (!backend.isAvailable) {
- alert(t('后端不可用', 'Backend not available'));
- return;
- }
- setIsSyncingToBackend(true);
- try {
- await backend.syncRepositories(repositories);
- await backend.syncReleases(releases);
- await backend.syncAIConfigs(aiConfigs);
- await backend.syncWebDAVConfigs(webdavConfigs);
- await backend.syncSettings({ hiddenDefaultCategoryIds });
- alert(t(
- `已同步到后端:仓库 ${repositories.length},发布 ${releases.length},AI配置 ${aiConfigs.length},WebDAV配置 ${webdavConfigs.length}`,
- `Synced to backend: repos ${repositories.length}, releases ${releases.length}, AI configs ${aiConfigs.length}, WebDAV configs ${webdavConfigs.length}`
- ));
- } catch (error) {
- console.error('Sync to backend failed:', error);
- alert(`${t('同步失败', 'Sync failed')}: ${(error as Error).message}`);
- } finally {
- setIsSyncingToBackend(false);
+ if (tabResetTimeoutRef.current) {
+ clearTimeout(tabResetTimeoutRef.current);
}
- };
- const handleSyncFromBackend = async () => {
- if (!backend.isAvailable) {
- alert(t('后端不可用', 'Backend not available'));
- return;
- }
-
- if (!confirm(t(
- '从后端同步将覆盖本地数据,是否继续?',
- 'Syncing from backend will overwrite local data. Continue?'
- ))) return;
+ setIsTransitioning(true);
- setIsSyncingFromBackend(true);
- try {
- const repoData = await backend.fetchRepositories();
- const releaseData = await backend.fetchReleases();
- const aiConfigData = await backend.fetchAIConfigs();
- const webdavConfigData = await backend.fetchWebDAVConfigs();
- const settingsData = await backend.fetchSettings();
-
- if (repoData.repositories.length > 0) {
- setRepositories(repoData.repositories);
- }
- if (releaseData.releases.length > 0) {
- setReleases(releaseData.releases);
- }
- if (aiConfigData.length > 0) {
- setAIConfigs(aiConfigData);
- }
- if (webdavConfigData.length > 0) {
- setWebDAVConfigs(webdavConfigData);
- }
- if (Array.isArray(hiddenDefaultCategoryIds)) {
- for (const categoryId of hiddenDefaultCategoryIds) {
- if (typeof categoryId === 'string') showDefaultCategory(categoryId);
- }
- }
- if (Array.isArray(settingsData.hiddenDefaultCategoryIds)) {
- for (const categoryId of settingsData.hiddenDefaultCategoryIds) {
- if (typeof categoryId === 'string') hideDefaultCategory(categoryId);
- }
- }
-
- alert(t(
- `已从后端同步:仓库 ${repoData.repositories.length},发布 ${releaseData.releases.length},AI配置 ${aiConfigData.length},WebDAV配置 ${webdavConfigData.length}`,
- `Synced from backend: repos ${repoData.repositories.length}, releases ${releaseData.releases.length}, AI configs ${aiConfigData.length}, WebDAV configs ${webdavConfigData.length}`
- ));
- } catch (error) {
- console.error('Sync from backend failed:', error);
- alert(`${t('同步失败', 'Sync failed')}: ${(error as Error).message}`);
- } finally {
- setIsSyncingFromBackend(false);
- }
- };
+ tabChangeTimeoutRef.current = setTimeout(() => {
+ setActiveTab(tabId);
+ setDisplayTab(tabId);
+ tabResetTimeoutRef.current = setTimeout(() => {
+ setIsTransitioning(false);
+ }, 120);
+ }, 100);
+ }, [activeTab, isTransitioning]);
- return (
-
- {/* Update Check */}
-
-
-
-
- {t('检查更新', 'Check for Updates')}
-
-
-
-
-
-
- {t('当前版本: v0.3.0', 'Current Version: v0.3.0')}
-
-
- {t('检查是否有新版本可用', 'Check if a new version is available')}
-
-
-
-
-
+ // 清理定时器
+ useEffect(() => {
+ return () => {
+ if (tabChangeTimeoutRef.current) {
+ clearTimeout(tabChangeTimeoutRef.current);
+ }
+ if (tabResetTimeoutRef.current) {
+ clearTimeout(tabResetTimeoutRef.current);
+ }
+ };
+ }, []);
- {/* Language Settings */}
-
-
-
-
- {t('语言设置', 'Language Settings')}
-
-
-
-
-
- setLanguage(e.target.value as 'zh' | 'en')}
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
- />
-
- 中文
-
-
-
- setLanguage(e.target.value as 'zh' | 'en')}
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
- />
-
- English
-
-
-
+ const tabs: SettingsTabItem[] = [
+ {
+ id: 'general',
+ label: t('通用', 'General'),
+ icon:
,
+ },
+ {
+ id: 'ai',
+ label: t('AI配置', 'AI Config'),
+ icon:
,
+ },
+ {
+ id: 'webdav',
+ label: t('WebDAV', 'WebDAV'),
+ icon:
,
+ },
+ {
+ id: 'backup',
+ label: t('备份恢复', 'Backup'),
+ icon:
,
+ },
+ {
+ id: 'backend',
+ label: t('后端同步', 'Backend'),
+ icon:
,
+ },
+ {
+ id: 'category',
+ label: t('分类管理', 'Categories'),
+ icon:
,
+ },
+ {
+ id: 'data',
+ label: t('数据管理', 'Data Management'),
+ icon:
,
+ },
+ ];
+
+ const renderTabContent = () => {
+ const content = (() => {
+ switch (displayTab) {
+ case 'general':
+ return
;
+ case 'ai':
+ return
;
+ case 'webdav':
+ return
;
+ case 'backup':
+ return
;
+ case 'backend':
+ return
;
+ case 'category':
+ return
;
+ case 'data':
+ return
;
+ default:
+ return null;
+ }
+ })();
+
+ return (
+
+ {content}
+ );
+ };
- {/* Language Settings */}
-
-
-
-
- {t('分类显示管理', 'Category Visibility')}
-
-
-
- {hiddenDefaultCategoryIds.length === 0 ? (
-
- {t('当前没有隐藏的默认分类。', 'No default categories are hidden right now.')}
-
- ) : (
-
-
- {t('以下默认分类已被隐藏,你可以在这里恢复显示。', 'The following built-in categories are hidden. You can restore them here.')}
-
-
- {hiddenDefaultCategories.map((category) => (
-
showDefaultCategory(category.id)}
- className="px-3 py-1.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors text-sm"
- >
- {t('恢复', 'Restore')} {category.icon} {category.name}
-
- ))}
+ if (!isOpen) return null;
+
+ // 模态框模式
+ if (isModal) {
+ return (
+
+
+
+
+
+
+ {t('设置', 'Settings')}
+
-
- )}
-
-
- {/* Contact Information */}
-
-
-
-
- {t('联系方式', 'Contact Information')}
-
-
-
-
-
- {t('如果您在使用过程中遇到任何问题或有建议,欢迎通过以下方式联系我:', 'If you encounter any issues or have suggestions while using the app, feel free to contact me through:')}
-
-
-
window.open('https://x.com/GoodMan_Lee', '_blank')}
- className="flex items-center justify-center space-x-2 px-4 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
+ onClick={handleClose}
+ className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-150"
+ aria-label={t('关闭设置', 'Close settings')}
>
-
- Twitter
-
-
-
- window.open('https://github.com/AmintaCCCP/GithubStarsManager', '_blank')}
- className="flex items-center justify-center space-x-2 px-4 py-3 bg-gray-800 hover:bg-gray-900 text-white rounded-lg transition-colors"
- >
-
- GitHub
-
+
-
-
-
- {/* AI Configuration */}
-
-
-
-
-
- {t('AI服务配置', 'AI Service Configuration')}
-
-
-
setShowAIForm(true)}
- className="flex items-center space-x-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
- >
-
- {t('添加AI配置', 'Add AI Config')}
-
-
- {/* AI Config Form */}
- {showAIForm && (
-
-
- {editingAIId ? t('编辑AI配置', 'Edit AI Configuration') : t('添加AI配置', 'Add AI Configuration')}
-
-
-
-
-
- {t('配置名称', 'Configuration Name')} *
-
- setAIForm(prev => ({ ...prev, name: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder={t('例如: OpenAI GPT-4', 'e.g., OpenAI GPT-4')}
- />
-
-
-
-
- {t('接口格式', 'API Format')} *
-
- setAIForm(prev => ({ ...prev, apiType: e.target.value as AIApiType }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- >
- OpenAI (Chat Completions)
- OpenAI (Responses)
- Claude
- Gemini
-
-
-
-
-
- {t('API端点', 'API Endpoint')} *
-
-
setAIForm(prev => ({ ...prev, baseUrl: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder={
- aiForm.apiType === 'openai' || aiForm.apiType === 'openai-responses'
- ? 'https://api.openai.com/v1'
- : aiForm.apiType === 'claude'
- ? 'https://api.anthropic.com/v1'
- : 'https://generativelanguage.googleapis.com/v1beta'
- }
- />
-
- {t(
- '只填到版本号即可(如 .../v1 或 .../v1beta),不要包含 /chat/completions、/responses、/messages 或 :generateContent',
- 'Only include the version prefix (e.g. .../v1 or .../v1beta). Do not include /chat/completions, /responses, /messages, or :generateContent.'
- )}
-
-
-
-
-
- {t('API密钥', 'API Key')} *
-
- setAIForm(prev => ({ ...prev, apiKey: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder={t('输入API密钥', 'Enter API key')}
- />
-
-
-
-
- {t('模型名称', 'Model Name')} *
-
- setAIForm(prev => ({ ...prev, model: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder="gpt-4"
- />
-
-
-
-
- {t('并发数', 'Concurrency')}
-
-
setAIForm(prev => ({ ...prev, concurrency: Math.max(1, Math.min(10, parseInt(e.target.value) || 1)) }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder="1"
- />
-
- {t('同时进行AI分析的仓库数量 (1-10)', 'Number of repositories to analyze simultaneously (1-10)')}
-
-
-
-
-
- {t('推理强度', 'Reasoning Effort')}
-
-
setAIForm(prev => ({ ...prev, reasoningEffort: e.target.value as '' | AIReasoningEffort }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- >
- {t('默认 / 不传', 'Default / Do not send')}
- none
- low
- medium
- high
- xhigh
-
-
- {t(
- '仅对 OpenAI 兼容接口生效。留空时保持旧模式兼容,不额外传 reasoning。',
- 'Only applies to OpenAI-compatible APIs. Leave empty to preserve legacy behavior and omit reasoning.'
- )}
-
-
-
-
- {/* Custom Prompt Section */}
-
-
-
- {
- setAIForm(prev => ({ ...prev, useCustomPrompt: e.target.checked }));
- setShowCustomPrompt(e.target.checked);
- }}
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
- />
-
- {t('使用自定义提示词', 'Use Custom Prompt')}
-
-
- {showCustomPrompt && (
+
+ {/* 侧边栏 - 桌面端 */}
+
+
+ {tabs.map((tab) => (
setAIForm(prev => ({ ...prev, customPrompt: getDefaultPrompt() }))}
- className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
+ key={tab.id}
+ onClick={() => handleTabChange(tab.id)}
+ role="tab"
+ id={`settings-tab-${tab.id}`}
+ aria-selected={activeTab === tab.id}
+ aria-controls={`settings-tabpanel-${tab.id}`}
+ className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors text-left ${
+ activeTab === tab.id
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
+ : 'text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
+ }`}
>
- {t('使用默认模板', 'Use Default Template')}
+ {tab.icon}
+ {tab.label}
- )}
-
-
- {showCustomPrompt && (
-
- )}
+ ))}
+
-
-
-
- {t('保存', 'Save')}
-
-
-
- {t('取消', 'Cancel')}
-
+ {/* 移动端标签选择器 */}
+
+
-
- )}
- {/* AI Configs List */}
-
- {aiConfigs.map(config => (
-
-
-
-
setActiveAIConfig(config.id)}
- className="w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 focus:ring-purple-500 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
- />
-
-
- {config.name}
- {config.useCustomPrompt && (
-
-
- {t('自定义提示词', 'Custom Prompt')}
-
- )}
-
-
- {(config.apiType || 'openai').toUpperCase()} • {config.baseUrl} • {config.model} • {t('并发数', 'Concurrency')}: {config.concurrency || 1}
- {config.reasoningEffort ? ` • reasoning: ${config.reasoningEffort}` : ''}
-
- {config.apiKeyStatus === 'decrypt_failed' && (
-
- {t(
- '存储的 API Key 无法解密,请重新输入并保存该配置。',
- 'The stored API key could not be decrypted. Please re-enter and save this configuration.'
- )}
-
- )}
-
-
-
-
- handleTestAI(config)}
- disabled={testingAIId === config.id}
- className="p-2 rounded-lg bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors disabled:opacity-50"
- title={t('测试连接', 'Test Connection')}
- >
- {testingAIId === config.id ? (
-
- ) : (
-
- )}
-
- handleEditAI(config)}
- className="p-2 rounded-lg bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-800 transition-colors"
- title={t('编辑', 'Edit')}
- >
-
-
- {
- if (confirm(t('确定要删除这个AI配置吗?', 'Are you sure you want to delete this AI configuration?'))) {
- deleteAIConfig(config.id);
- }
- }}
- className="p-2 rounded-lg bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-800 transition-colors"
- title={t('删除', 'Delete')}
- >
-
-
-
+ {/* 内容区域 */}
+
- ))}
-
- {aiConfigs.length === 0 && (
-
-
-
{t('还没有配置AI服务', 'No AI services configured yet')}
-
{t('点击上方按钮添加AI配置', 'Click the button above to add AI configuration')}
-
- )}
-
-
-
- {/* WebDAV Configuration */}
-
-
-
-
-
- {t('WebDAV备份配置', 'WebDAV Backup Configuration')}
-
-
-
- {lastBackup && (
-
- {t('上次备份:', 'Last backup:')} {new Date(lastBackup).toLocaleString()}
-
- )}
-
setShowWebDAVForm(true)}
- className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
- >
-
- {t('添加WebDAV', 'Add WebDAV')}
-
+
+ );
+ }
- {/* WebDAV Config Form */}
- {showWebDAVForm && (
-
-
- {editingWebDAVId ? t('编辑WebDAV配置', 'Edit WebDAV Configuration') : t('添加WebDAV配置', 'Add WebDAV Configuration')}
-
-
-
-
-
- {t('配置名称', 'Configuration Name')} *
-
- setWebDAVForm(prev => ({ ...prev, name: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder={t('例如: 坚果云', 'e.g., Nutstore')}
- />
-
-
-
-
- {t('WebDAV URL', 'WebDAV URL')} *
-
- setWebDAVForm(prev => ({ ...prev, url: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder="https://dav.jianguoyun.com/dav/"
- />
-
-
-
-
- {t('用户名', 'Username')} *
-
- setWebDAVForm(prev => ({ ...prev, username: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder={t('WebDAV用户名', 'WebDAV username')}
- />
-
-
-
-
- {t('密码', 'Password')} *
-
- setWebDAVForm(prev => ({ ...prev, password: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder={t('WebDAV密码', 'WebDAV password')}
- />
-
-
-
-
- {t('路径', 'Path')} *
-
- setWebDAVForm(prev => ({ ...prev, path: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder="/github-stars-manager/"
- />
-
-
+ // 独立页面模式(兼容原有代码)
+ return (
+
+
+
+
+ {t('设置', 'Settings')}
+
+
-
-
-
- {t('保存', 'Save')}
-
-
-
- {t('取消', 'Cancel')}
-
-
+
+ {/* 桌面端侧边栏 */}
+
+
+
+ {tabs.map((tab) => (
+ handleTabChange(tab.id)}
+ role="tab"
+ id={`settings-tab-${tab.id}`}
+ aria-selected={activeTab === tab.id}
+ aria-controls={`settings-tabpanel-${tab.id}`}
+ className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-150 text-left ${
+ activeTab === tab.id
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
+ : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
+ }`}
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
- )}
-
- {/* WebDAV Configs List */}
-
- {webdavConfigs.map(config => (
-
-
-
-
setActiveWebDAVConfig(config.id)}
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
- />
-
-
{config.name}
-
- {config.url} • {config.path}
-
- {config.passwordStatus === 'decrypt_failed' && (
-
- {t(
- '存储的 WebDAV 密码无法解密,请重新输入并保存该配置。',
- 'The stored WebDAV password could not be decrypted. Please re-enter and save this configuration.'
- )}
-
- )}
-
-
-
-
- handleTestWebDAV(config)}
- disabled={testingWebDAVId === config.id}
- className="p-2 rounded-lg bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors disabled:opacity-50"
- title={t('测试连接', 'Test Connection')}
- >
- {testingWebDAVId === config.id ? (
-
- ) : (
-
- )}
-
- handleEditWebDAV(config)}
- className="p-2 rounded-lg bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-800 transition-colors"
- title={t('编辑', 'Edit')}
- >
-
-
- {
- if (confirm(t('确定要删除这个WebDAV配置吗?', 'Are you sure you want to delete this WebDAV configuration?'))) {
- deleteWebDAVConfig(config.id);
- }
- }}
- className="p-2 rounded-lg bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-800 transition-colors"
- title={t('删除', 'Delete')}
- >
-
-
-
-
-
- ))}
-
- {webdavConfigs.length === 0 && (
-
-
-
{t('还没有配置WebDAV服务', 'No WebDAV services configured yet')}
-
{t('点击上方按钮添加WebDAV配置', 'Click the button above to add WebDAV configuration')}
-
- )}
- {/* Backup Actions */}
- {webdavConfigs.length > 0 && (
-
-
- {isBackingUp ? (
-
- ) : (
-
- )}
- {isBackingUp ? t('备份中...', 'Backing up...') : t('备份数据', 'Backup Data')}
-
-
-
- {isRestoring ? (
-
- ) : (
-
- )}
- {isRestoring ? t('恢复中...', 'Restoring...') : t('恢复数据', 'Restore Data')}
-
-
- )}
-
- {/* Backend Server Configuration */}
-
-
-
-
-
- {t('后端服务器', 'Backend Server')}
-
-
- {backendStatus === 'connected' ? '🟢 ' + t('已连接', 'Connected')
- : backendStatus === 'checking' ? '🟡 ' + t('检查中...', 'Checking...')
- : '🔴 ' + t('未连接', 'Not Connected')}
-
-
+ {/* 移动端标签导航 */}
+
+
- {backendHealth && (
-
-
{t('版本', 'Version')}: {backendHealth.version}
-
- )}
-
-
-
-
- {t('API 密钥', 'API Secret')}
-
-
- setBackendSecretInput(e.target.value)}
- className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
- placeholder={t('输入后端 API_SECRET(可选)', 'Enter backend API_SECRET (optional)')}
- />
-
- {backendStatus === 'checking' ? (
-
- ) : (
-
- )}
- {t('测试连接', 'Test Connection')}
-
-
-
- {t(
- '如果后端设置了 API_SECRET 环境变量,在此输入相同的值。未设置则留空。',
- 'If the backend has API_SECRET env var set, enter the same value here. Leave empty if not set.'
- )}
-
+ {/* 内容区域 */}
+
+
+ {renderTabContent()}
-
- {backend.isAvailable && (
-
-
- {isSyncingToBackend ? (
-
- ) : (
-
- )}
- {isSyncingToBackend ? t('同步中...', 'Syncing...') : t('同步到后端', 'Sync to Backend')}
-
-
-
- {isSyncingFromBackend ? (
-
- ) : (
-
- )}
- {isSyncingFromBackend ? t('同步中...', 'Syncing...') : t('从后端同步', 'Sync from Backend')}
-
-
- )}
-
);
};
+
+export default SettingsPanel;
diff --git a/src/components/settings/AIConfigPanel.tsx b/src/components/settings/AIConfigPanel.tsx
new file mode 100644
index 00000000..83c3c103
--- /dev/null
+++ b/src/components/settings/AIConfigPanel.tsx
@@ -0,0 +1,486 @@
+import React, { useState } from 'react';
+import { Bot, Plus, Edit3, Trash2, Save, X, TestTube, RefreshCw, MessageSquare } from 'lucide-react';
+import { AIConfig, AIApiType, AIReasoningEffort } from '../../types';
+import { useAppStore } from '../../store/useAppStore';
+import { AIService } from '../../services/aiService';
+
+interface AIConfigPanelProps {
+ t: (zh: string, en: string) => string;
+}
+
+type AIFormState = {
+ name: string;
+ apiType: AIApiType;
+ baseUrl: string;
+ apiKey: string;
+ model: string;
+ customPrompt: string;
+ useCustomPrompt: boolean;
+ concurrency: number;
+ reasoningEffort: '' | AIReasoningEffort;
+};
+
+export const AIConfigPanel: React.FC
= ({ t }) => {
+ const {
+ aiConfigs,
+ activeAIConfig,
+ language,
+ addAIConfig,
+ updateAIConfig,
+ deleteAIConfig,
+ setActiveAIConfig,
+ } = useAppStore();
+
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [testingId, setTestingId] = useState(null);
+ const [showCustomPrompt, setShowCustomPrompt] = useState(false);
+
+ const [form, setForm] = useState({
+ name: '',
+ apiType: 'openai',
+ baseUrl: '',
+ apiKey: '',
+ model: '',
+ customPrompt: '',
+ useCustomPrompt: false,
+ concurrency: 1,
+ reasoningEffort: '',
+ });
+
+ const resetForm = () => {
+ setForm({
+ name: '',
+ apiType: 'openai',
+ baseUrl: '',
+ apiKey: '',
+ model: '',
+ customPrompt: '',
+ useCustomPrompt: false,
+ concurrency: 1,
+ reasoningEffort: '',
+ });
+ setShowForm(false);
+ setEditingId(null);
+ setShowCustomPrompt(false);
+ };
+
+ const handleSave = () => {
+ if (!form.name || !form.baseUrl || !form.apiKey || !form.model) {
+ alert(t('请填写所有必填字段', 'Please fill in all required fields'));
+ return;
+ }
+
+ if (editingId) {
+ const existingConfig = aiConfigs.find(c => c.id === editingId);
+ if (existingConfig) {
+ const updates: Partial = {
+ name: form.name,
+ apiType: form.apiType,
+ baseUrl: form.baseUrl.replace(/\/$/, ''),
+ apiKey: form.apiKey,
+ model: form.model,
+ customPrompt: form.customPrompt || undefined,
+ useCustomPrompt: form.useCustomPrompt,
+ concurrency: form.concurrency,
+ reasoningEffort: form.reasoningEffort || undefined,
+ isActive: existingConfig.isActive,
+ };
+ updateAIConfig(editingId, updates);
+ }
+ } else {
+ const config: AIConfig = {
+ id: Date.now().toString(),
+ name: form.name,
+ apiType: form.apiType,
+ baseUrl: form.baseUrl.replace(/\/$/, ''),
+ apiKey: form.apiKey,
+ model: form.model,
+ isActive: false,
+ customPrompt: form.customPrompt || undefined,
+ useCustomPrompt: form.useCustomPrompt,
+ concurrency: form.concurrency,
+ reasoningEffort: form.reasoningEffort || undefined,
+ };
+ addAIConfig(config);
+ }
+
+ resetForm();
+ };
+
+ const handleEdit = (config: AIConfig) => {
+ setForm({
+ name: config.name,
+ apiType: config.apiType || 'openai',
+ baseUrl: config.baseUrl,
+ apiKey: config.apiKey,
+ model: config.model,
+ customPrompt: config.customPrompt || '',
+ useCustomPrompt: config.useCustomPrompt || false,
+ concurrency: config.concurrency || 1,
+ reasoningEffort: config.reasoningEffort || '',
+ });
+ setEditingId(config.id);
+ setShowForm(true);
+ setShowCustomPrompt(config.useCustomPrompt || false);
+ };
+
+ const handleTest = async (config: AIConfig) => {
+ setTestingId(config.id);
+ try {
+ const aiService = new AIService(config, language);
+ const isConnected = await aiService.testConnection();
+
+ if (isConnected) {
+ alert(t('AI服务连接成功!', 'AI service connection successful!'));
+ } else {
+ alert(t('AI服务连接失败,请检查配置。', 'AI service connection failed. Please check configuration.'));
+ }
+ } catch (error) {
+ console.error('AI test failed:', error);
+ alert(t('AI服务测试失败,请检查网络连接和配置。', 'AI service test failed. Please check network connection and configuration.'));
+ } finally {
+ setTestingId(null);
+ }
+ };
+
+ const getDefaultPrompt = () => {
+ if (language === 'zh') {
+ return `请分析这个GitHub仓库并提供:
+
+1. 一个简洁的中文概述(不超过50字),说明这个仓库的主要功能和用途
+2. 3-5个相关的应用类型标签(用中文,类似应用商店的分类,如:开发工具、Web应用、移动应用、数据库、AI工具等)
+3. 支持的平台类型(从以下选择:mac、windows、linux、ios、android、docker、web、cli)
+
+重要:请严格使用中文进行分析和回复,无论原始README是什么语言。
+
+请以JSON格式回复:
+{
+ "summary": "你的中文概述",
+ "tags": ["标签1", "标签2", "标签3", "标签4", "标签5"],
+ "platforms": ["platform1", "platform2", "platform3"]
+}
+
+仓库信息:
+{REPO_INFO}
+
+重点关注实用性和准确的分类,帮助用户快速理解仓库的用途和支持的平台。`;
+ } else {
+ return `Please analyze this GitHub repository and provide:
+
+1. A concise English overview (no more than 50 words) explaining the main functionality and purpose of this repository
+2. 3-5 relevant application type tags (in English, similar to app store categories, such as: development tools, web apps, mobile apps, database, AI tools, etc.)
+3. Supported platform types (choose from: mac, windows, linux, ios, android, docker, web, cli)
+
+Important: Please strictly use English for analysis and response, regardless of the original README language.
+
+Please reply in JSON format:
+{
+ "summary": "Your English overview",
+ "tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],
+ "platforms": ["platform1", "platform2", "platform3"]
+}
+
+Repository information:
+{REPO_INFO}
+
+Focus on practicality and accurate categorization to help users quickly understand the repository's purpose and supported platforms.`;
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t('AI服务配置', 'AI Service Configuration')}
+
+
+
setShowForm(true)}
+ className="flex items-center space-x-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
+ >
+
+ {t('添加AI配置', 'Add AI Config')}
+
+
+
+ {showForm && (
+
+
+ {editingId ? t('编辑AI配置', 'Edit AI Configuration') : t('添加AI配置', 'Add AI Configuration')}
+
+
+
+
+
+ {t('配置名称', 'Configuration Name')} *
+
+ setForm(prev => ({ ...prev, name: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder={t('例如: OpenAI GPT-4', 'e.g., OpenAI GPT-4')}
+ />
+
+
+
+
+ {t('接口格式', 'API Format')} *
+
+ setForm(prev => ({ ...prev, apiType: e.target.value as AIApiType }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ >
+ OpenAI (Chat Completions)
+ OpenAI (Responses)
+ Claude
+ Gemini
+
+
+
+
+
+ {t('API端点', 'API Endpoint')} *
+
+
setForm(prev => ({ ...prev, baseUrl: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder={
+ form.apiType === 'openai' || form.apiType === 'openai-responses'
+ ? 'https://api.openai.com/v1'
+ : form.apiType === 'claude'
+ ? 'https://api.anthropic.com/v1'
+ : 'https://generativelanguage.googleapis.com/v1beta'
+ }
+ />
+
+ {t(
+ '只填到版本号即可(如 .../v1 或 .../v1beta),不要包含 /chat/completions、/responses、/messages 或 :generateContent',
+ 'Only include the version prefix (e.g. .../v1 or .../v1beta). Do not include /chat/completions, /responses, /messages, or :generateContent.'
+ )}
+
+
+
+
+
+ {t('API密钥', 'API Key')} *
+
+ setForm(prev => ({ ...prev, apiKey: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder={t('输入API密钥', 'Enter API key')}
+ />
+
+
+
+
+ {t('模型名称', 'Model Name')} *
+
+ setForm(prev => ({ ...prev, model: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder="gpt-4"
+ />
+
+
+
+
+ {t('并发数', 'Concurrency')}
+
+
setForm(prev => ({ ...prev, concurrency: Math.max(1, Math.min(10, parseInt(e.target.value) || 1)) }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder="1"
+ />
+
+ {t('同时进行AI分析的仓库数量 (1-10)', 'Number of repositories to analyze simultaneously (1-10)')}
+
+
+
+
+
+ {t('推理强度', 'Reasoning Effort')}
+
+
setForm(prev => ({ ...prev, reasoningEffort: e.target.value as '' | AIReasoningEffort }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ >
+ {t('默认 / 不传', 'Default / Do not send')}
+ none
+ low
+ medium
+ high
+ xhigh
+
+
+ {t(
+ '仅对 OpenAI 兼容接口生效。留空时保持旧模式兼容,不额外传 reasoning。',
+ 'Only applies to OpenAI-compatible APIs. Leave empty to preserve legacy behavior and omit reasoning.'
+ )}
+
+
+
+
+
+
+
+ {
+ setForm(prev => ({ ...prev, useCustomPrompt: e.target.checked }));
+ setShowCustomPrompt(e.target.checked);
+ }}
+ className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
+ />
+
+ {t('使用自定义提示词', 'Use Custom Prompt')}
+
+
+ {form.useCustomPrompt && (
+ setForm(prev => ({ ...prev, customPrompt: getDefaultPrompt() }))}
+ className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+ >
+ {t('恢复默认提示词', 'Restore Default Prompt')}
+
+ )}
+
+
+ {showCustomPrompt && (
+
+
+
+
+
+ {t('保存', 'Save')}
+
+
+
+ {t('取消', 'Cancel')}
+
+
+
+ )}
+
+
+ {aiConfigs.map(config => (
+
+
+
+
setActiveAIConfig(config.id)}
+ className="w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 focus:ring-purple-500 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+ {config.name}
+ {config.useCustomPrompt && (
+
+
+ {t('自定义提示词', 'Custom Prompt')}
+
+ )}
+
+
+ {(config.apiType || 'openai').toUpperCase()} • {config.baseUrl} • {config.model} • {t('并发数', 'Concurrency')}: {config.concurrency || 1}
+ {config.reasoningEffort ? ` • reasoning: ${config.reasoningEffort}` : ''}
+
+ {config.apiKeyStatus === 'decrypt_failed' && (
+
+ {t(
+ '存储的 API Key 无法解密,请重新输入并保存该配置。',
+ 'The stored API key could not be decrypted. Please re-enter and save this configuration.'
+ )}
+
+ )}
+
+
+
+
+ handleTest(config)}
+ disabled={testingId === config.id}
+ className="p-2 rounded-lg bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors disabled:opacity-50"
+ title={t('测试连接', 'Test Connection')}
+ >
+ {testingId === config.id ? (
+
+ ) : (
+
+ )}
+
+ handleEdit(config)}
+ className="p-2 rounded-lg bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-800 transition-colors"
+ title={t('编辑', 'Edit')}
+ >
+
+
+ {
+ if (confirm(t('确定要删除这个AI配置吗?', 'Are you sure you want to delete this AI configuration?'))) {
+ deleteAIConfig(config.id);
+ }
+ }}
+ className="p-2 rounded-lg bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-800 transition-colors"
+ title={t('删除', 'Delete')}
+ >
+
+
+
+
+
+ ))}
+
+ {aiConfigs.length === 0 && (
+
+
+
{t('还没有配置AI服务', 'No AI services configured yet')}
+
{t('点击上方按钮添加AI配置', 'Click the button above to add AI configuration')}
+
+ )}
+
+
+ );
+};
diff --git a/src/components/settings/BackendPanel.tsx b/src/components/settings/BackendPanel.tsx
new file mode 100644
index 00000000..5b4530b1
--- /dev/null
+++ b/src/components/settings/BackendPanel.tsx
@@ -0,0 +1,326 @@
+import React, { useState, useEffect } from 'react';
+import { Server, TestTube, RefreshCw, Upload, Download, CheckCircle, AlertCircle } from 'lucide-react';
+import { useAppStore } from '../../store/useAppStore';
+import { backend } from '../../services/backendAdapter';
+
+interface BackendPanelProps {
+ t: (zh: string, en: string) => string;
+}
+
+export const BackendPanel: React.FC = ({ t }) => {
+ const {
+ repositories,
+ releases,
+ aiConfigs,
+ webdavConfigs,
+ hiddenDefaultCategoryIds,
+ backendApiSecret,
+ setBackendApiSecret,
+ setRepositories,
+ setReleases,
+ setAIConfigs,
+ setWebDAVConfigs,
+ showDefaultCategory,
+ hideDefaultCategory,
+ } = useAppStore();
+
+ const [status, setStatus] = useState<'connected' | 'disconnected' | 'checking'>('disconnected');
+ const [health, setHealth] = useState<{ version: string; timestamp: string } | null>(null);
+ const [isSyncingToBackend, setIsSyncingToBackend] = useState(false);
+ const [isSyncingFromBackend, setIsSyncingFromBackend] = useState(false);
+ const [secretInput, setSecretInput] = useState(backendApiSecret || '');
+
+ useEffect(() => {
+ const checkBackend = async () => {
+ setStatus('checking');
+ try {
+ await backend.init();
+ const healthData = await backend.checkHealth();
+ if (healthData) {
+ setStatus('connected');
+ setHealth({ version: healthData.version, timestamp: healthData.timestamp });
+ } else {
+ setStatus('disconnected');
+ setHealth(null);
+ }
+ } catch {
+ setStatus('disconnected');
+ setHealth(null);
+ }
+ };
+ checkBackend();
+ }, []);
+
+ const handleTestConnection = async () => {
+ setStatus('checking');
+ setBackendApiSecret(secretInput || null);
+ try {
+ await backend.init();
+ const healthData = await backend.checkHealth();
+ const authOk = secretInput ? await backend.verifyAuth() : true;
+ if (healthData && authOk) {
+ setStatus('connected');
+ setHealth({ version: healthData.version, timestamp: healthData.timestamp });
+ alert(t('后端连接成功!', 'Backend connection successful!'));
+ } else {
+ setStatus('disconnected');
+ setHealth(null);
+ alert(t(
+ '后端连接失败,请检查服务器状态或 API Secret 是否正确。',
+ 'Backend connection failed. Please check the server status or whether the API Secret is correct.'
+ ));
+ }
+ } catch {
+ setStatus('disconnected');
+ setHealth(null);
+ alert(t(
+ '后端连接失败,请检查服务器状态或 API Secret 是否正确。',
+ 'Backend connection failed. Please check the server status or whether the API Secret is correct.'
+ ));
+ }
+ };
+
+ const handleSyncToBackend = async () => {
+ if (!backend.isAvailable) {
+ alert(t('后端不可用', 'Backend not available'));
+ return;
+ }
+ setIsSyncingToBackend(true);
+ try {
+ await backend.syncRepositories(repositories);
+ await backend.syncReleases(releases);
+ await backend.syncAIConfigs(aiConfigs);
+ await backend.syncWebDAVConfigs(webdavConfigs);
+ await backend.syncSettings({ hiddenDefaultCategoryIds });
+ alert(t(
+ `已同步到后端:仓库 ${repositories.length},发布 ${releases.length},AI配置 ${aiConfigs.length},WebDAV配置 ${webdavConfigs.length}`,
+ `Synced to backend: repos ${repositories.length}, releases ${releases.length}, AI configs ${aiConfigs.length}, WebDAV configs ${webdavConfigs.length}`
+ ));
+ } catch (error) {
+ console.error('Sync to backend failed:', error);
+ alert(`${t('同步失败', 'Sync failed')}: ${(error as Error).message}`);
+ } finally {
+ setIsSyncingToBackend(false);
+ }
+ };
+
+ const handleSyncFromBackend = async () => {
+ if (!backend.isAvailable) {
+ alert(t('后端不可用', 'Backend not available'));
+ return;
+ }
+
+ if (!confirm(t(
+ '从后端同步将覆盖本地数据,是否继续?',
+ 'Syncing from backend will overwrite local data. Continue?'
+ ))) return;
+
+ setIsSyncingFromBackend(true);
+ try {
+ const repoData = await backend.fetchRepositories();
+ const releaseData = await backend.fetchReleases();
+ const aiConfigData = await backend.fetchAIConfigs();
+ const webdavConfigData = await backend.fetchWebDAVConfigs();
+ const settingsData = await backend.fetchSettings();
+
+ // Always apply backend snapshot to state (empty array allowed)
+ setRepositories(repoData.repositories);
+ setReleases(releaseData.releases);
+ setAIConfigs(aiConfigData);
+ setWebDAVConfigs(webdavConfigData);
+ // 从服务端数据中隐藏所有应隐藏的分类
+ if (Array.isArray(settingsData.hiddenDefaultCategoryIds)) {
+ for (const categoryId of settingsData.hiddenDefaultCategoryIds) {
+ if (typeof categoryId === 'string') hideDefaultCategory(categoryId);
+ }
+ }
+ // 显示本地隐藏列表中但服务端没有隐藏的分类(即本地手动显示的)
+ if (Array.isArray(hiddenDefaultCategoryIds)) {
+ const hiddenIdsFromServer = Array.isArray(settingsData.hiddenDefaultCategoryIds)
+ ? settingsData.hiddenDefaultCategoryIds
+ : [];
+ for (const categoryId of hiddenDefaultCategoryIds) {
+ if (typeof categoryId === 'string' && !hiddenIdsFromServer.includes(categoryId)) {
+ showDefaultCategory(categoryId);
+ }
+ }
+ }
+
+ alert(t(
+ `已从后端同步:仓库 ${repoData.repositories.length},发布 ${releaseData.releases.length},AI配置 ${aiConfigData.length},WebDAV配置 ${webdavConfigData.length}`,
+ `Synced from backend: repos ${repoData.repositories.length}, releases ${releaseData.releases.length}, AI configs ${aiConfigData.length}, WebDAV configs ${webdavConfigData.length}`
+ ));
+ } catch (error) {
+ console.error('Sync from backend failed:', error);
+ alert(`${t('同步失败', 'Sync failed')}: ${(error as Error).message}`);
+ } finally {
+ setIsSyncingFromBackend(false);
+ }
+ };
+
+ const getStatusIcon = () => {
+ switch (status) {
+ case 'connected':
+ return ;
+ case 'checking':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusText = () => {
+ switch (status) {
+ case 'connected':
+ return t('已连接', 'Connected');
+ case 'checking':
+ return t('检查中...', 'Checking...');
+ default:
+ return t('未连接', 'Not Connected');
+ }
+ };
+
+ const getStatusClass = () => {
+ switch (status) {
+ case 'connected':
+ return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
+ case 'checking':
+ return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
+ default:
+ return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t('后端服务器', 'Backend Server')}
+
+
+
+ {getStatusIcon()}
+ {getStatusText()}
+
+
+
+ {health && (
+
+
+
+
+ {t('连接正常', 'Connection OK')}
+
+
+
+ {t('版本', 'Version')}: {health.version}
+
+
+ )}
+
+
+
+ {t('API 密钥', 'API Secret')}
+
+
+ setSecretInput(e.target.value)}
+ className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder={t('输入后端 API_SECRET(可选)', 'Enter backend API_SECRET (optional)')}
+ />
+
+ {status === 'checking' ? (
+
+ ) : (
+
+ )}
+ {t('测试连接', 'Test Connection')}
+
+
+
+ {t(
+ '如果后端设置了 API_SECRET 环境变量,在此输入相同的值。未设置则留空。',
+ 'If the backend has API_SECRET env var set, enter the same value here. Leave empty if not set.'
+ )}
+
+
+
+ {backend.isAvailable && (
+
+
+
+
+
+
+ {t('同步到后端', 'Sync to Backend')}
+
+
+ {t('将本地数据上传到后端', 'Upload local data to backend')}
+
+
+
+
+ {isSyncingToBackend ? (
+
+ ) : (
+
+ )}
+ {isSyncingToBackend ? t('同步中...', 'Syncing...') : t('开始同步', 'Start Sync')}
+
+
+
+
+
+
+
+
+ {t('从后端同步', 'Sync from Backend')}
+
+
+ {t('从后端下载数据到本地', 'Download data from backend to local')}
+
+
+
+
+ {isSyncingFromBackend ? (
+
+ ) : (
+
+ )}
+ {isSyncingFromBackend ? t('同步中...', 'Syncing...') : t('开始同步', 'Start Sync')}
+
+
+
+ )}
+
+
+
+ {t('同步内容包括:', 'Sync includes:')}
+
+
+ • {t('GitHub Stars 仓库列表', 'GitHub Stars repository list')}
+ • {t('Release 发布信息', 'Release information')}
+ • {t('AI 服务配置', 'AI service configurations')}
+ • {t('WebDAV 配置', 'WebDAV configurations')}
+ • {t('分类显示设置', 'Category visibility settings')}
+
+
+
+ );
+};
diff --git a/src/components/settings/BackupPanel.tsx b/src/components/settings/BackupPanel.tsx
new file mode 100644
index 00000000..1c5bb94c
--- /dev/null
+++ b/src/components/settings/BackupPanel.tsx
@@ -0,0 +1,358 @@
+import React, { useState } from 'react';
+import { Download, Upload, RefreshCw, Cloud, AlertCircle } from 'lucide-react';
+import { AIConfig, WebDAVConfig } from '../../types';
+import { useAppStore } from '../../store/useAppStore';
+import { WebDAVService } from '../../services/webdavService';
+
+interface BackupPanelProps {
+ t: (zh: string, en: string) => string;
+}
+
+export const BackupPanel: React.FC = ({ t }) => {
+ const {
+ repositories,
+ releases,
+ customCategories,
+ hiddenDefaultCategoryIds,
+ aiConfigs,
+ webdavConfigs,
+ activeWebDAVConfig,
+ lastBackup,
+ setLastBackup,
+ setRepositories,
+ setReleases,
+ addCustomCategory,
+ deleteCustomCategory,
+ hideDefaultCategory,
+ showDefaultCategory,
+ addAIConfig,
+ updateAIConfig,
+ deleteAIConfig,
+ addWebDAVConfig,
+ updateWebDAVConfig,
+ deleteWebDAVConfig,
+ } = useAppStore();
+
+ const [isBackingUp, setIsBackingUp] = useState(false);
+ const [isRestoring, setIsRestoring] = useState(false);
+
+ const activeConfig = webdavConfigs.find(config => config.id === activeWebDAVConfig);
+
+ const handleBackup = async () => {
+ if (!activeConfig) {
+ alert(t('请先配置并激活WebDAV服务。', 'Please configure and activate WebDAV service first.'));
+ return;
+ }
+
+ setIsBackingUp(true);
+ try {
+ const webdavService = new WebDAVService(activeConfig);
+
+ const backupData = {
+ repositories,
+ releases,
+ customCategories,
+ hiddenDefaultCategoryIds,
+ aiConfigs: aiConfigs.map(config => ({
+ ...config,
+ apiKey: config.apiKey ? '***' : ''
+ })),
+ webdavConfigs: webdavConfigs.map(config => ({
+ ...config,
+ password: config.password ? '***' : ''
+ })),
+ exportedAt: new Date().toISOString(),
+ version: '1.0'
+ };
+
+ const filename = `github-stars-backup-${new Date().toISOString().split('T')[0]}.json`;
+ const success = await webdavService.uploadFile(filename, JSON.stringify(backupData, null, 2));
+
+ if (success) {
+ setLastBackup(new Date().toISOString());
+ alert(t('数据备份成功!', 'Data backup successful!'));
+ } else {
+ console.error('Backup failed: uploadFile returned falsy');
+ alert(t('数据备份失败!', 'Data backup failed!'));
+ }
+ } catch (error) {
+ console.error('Backup failed:', error);
+ alert(`${t('备份失败', 'Backup failed')}: ${(error as Error).message}`);
+ } finally {
+ setIsBackingUp(false);
+ }
+ };
+
+ const handleRestore = async () => {
+ if (!activeConfig) {
+ alert(t('请先配置并激活WebDAV服务。', 'Please configure and activate WebDAV service first.'));
+ return;
+ }
+
+ const confirmMessage = t(
+ '恢复数据将覆盖当前所有数据,是否继续?',
+ 'Restoring data will overwrite all current data. Continue?'
+ );
+
+ if (!confirm(confirmMessage)) return;
+
+ setIsRestoring(true);
+ try {
+ const webdavService = new WebDAVService(activeConfig);
+ const files = await webdavService.listFiles();
+
+ const backupFiles = files.filter(file => file.startsWith('github-stars-backup-'));
+ if (backupFiles.length === 0) {
+ alert(t('未找到备份文件。', 'No backup files found.'));
+ return;
+ }
+
+ const latestBackup = backupFiles.sort().reverse()[0];
+ const backupContent = await webdavService.downloadFile(latestBackup);
+
+ if (!backupContent) {
+ setIsRestoring(false);
+ alert(t('备份文件内容为空,无法恢复。', 'Backup file is empty, cannot restore.'));
+ return;
+ }
+
+ try {
+ const backupData = JSON.parse(backupContent);
+
+ if (Array.isArray(backupData.repositories)) {
+ setRepositories(backupData.repositories);
+ }
+ if (Array.isArray(backupData.releases)) {
+ setReleases(backupData.releases);
+ }
+
+ try {
+ // 先获取当前所有自定义分类并删除
+ const currentCategories = useAppStore.getState().customCategories;
+ if (Array.isArray(currentCategories)) {
+ for (const cat of currentCategories) {
+ if (cat && cat.id) {
+ deleteCustomCategory(cat.id);
+ }
+ }
+ }
+ // 添加备份中的分类
+ if (Array.isArray(backupData.customCategories)) {
+ for (const cat of backupData.customCategories) {
+ if (cat && cat.id && cat.name) {
+ addCustomCategory({ ...cat, isCustom: true });
+ }
+ }
+ }
+ // 在 restore 回调中,先获取最新状态
+ const currentHidden = useAppStore.getState().hiddenDefaultCategoryIds;
+ // 显示当前隐藏的分类(恢复前它们被隐藏,需要显示)
+ if (Array.isArray(currentHidden)) {
+ for (const categoryId of currentHidden) {
+ if (typeof categoryId === 'string') {
+ showDefaultCategory(categoryId);
+ }
+ }
+ }
+ // 再隐藏备份数据中标记为隐藏的分类
+ if (Array.isArray(backupData.hiddenDefaultCategoryIds)) {
+ for (const categoryId of backupData.hiddenDefaultCategoryIds) {
+ if (typeof categoryId === 'string') {
+ hideDefaultCategory(categoryId);
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('恢复自定义分类时发生问题:', e);
+ }
+
+ try {
+ if (Array.isArray(backupData.aiConfigs)) {
+ const latestAIConfigs = useAppStore.getState().aiConfigs;
+ const currentMap = new Map(latestAIConfigs.map((c: AIConfig) => [c.id, c]));
+ const backupIdSet = new Set((backupData.aiConfigs as AIConfig[]).map(cfg => cfg.id).filter(Boolean));
+ for (const [id] of currentMap) {
+ if (!backupIdSet.has(id)) {
+ deleteAIConfig(id);
+ }
+ }
+ for (const cfg of backupData.aiConfigs as AIConfig[]) {
+ if (!cfg || !cfg.id) continue;
+ const existing = currentMap.get(cfg.id);
+ if (existing) {
+ updateAIConfig(cfg.id, {
+ name: cfg.name,
+ apiType: cfg.apiType,
+ baseUrl: cfg.baseUrl,
+ model: cfg.model,
+ customPrompt: cfg.customPrompt,
+ useCustomPrompt: cfg.useCustomPrompt,
+ concurrency: cfg.concurrency,
+ reasoningEffort: cfg.reasoningEffort,
+ apiKey: cfg.apiKey || existing.apiKey,
+ isActive: existing.isActive,
+ });
+ } else {
+ addAIConfig({
+ ...cfg,
+ apiKey: cfg.apiKey || '',
+ isActive: cfg.isActive,
+ });
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('恢复 AI 配置时发生问题:', e);
+ }
+
+ try {
+ if (Array.isArray(backupData.webdavConfigs)) {
+ const latestWebDAVConfigs = useAppStore.getState().webdavConfigs;
+ const currentMap = new Map(latestWebDAVConfigs.map((c: WebDAVConfig) => [c.id, c]));
+ const backupIdSet = new Set((backupData.webdavConfigs as WebDAVConfig[]).map(cfg => cfg.id).filter(Boolean));
+ for (const [id] of currentMap) {
+ if (!backupIdSet.has(id)) {
+ deleteWebDAVConfig(id);
+ }
+ }
+ for (const cfg of backupData.webdavConfigs as WebDAVConfig[]) {
+ if (!cfg || !cfg.id) continue;
+ const existing = currentMap.get(cfg.id);
+ if (existing) {
+ updateWebDAVConfig(cfg.id, {
+ name: cfg.name,
+ url: cfg.url,
+ username: cfg.username,
+ path: cfg.path,
+ password: cfg.password || existing.password,
+ isActive: existing.isActive,
+ });
+ } else {
+ addWebDAVConfig({
+ ...cfg,
+ password: cfg.password || '',
+ isActive: false,
+ });
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('恢复 WebDAV 配置时发生问题:', e);
+ }
+
+ alert(t(
+ `已从备份恢复数据:仓库 ${backupData.repositories?.length ?? 0},发布 ${backupData.releases?.length ?? 0},自定义分类 ${backupData.customCategories?.length ?? 0}。`,
+ `Data restored from backup: repositories ${backupData.repositories?.length ?? 0}, releases ${backupData.releases?.length ?? 0}, custom categories ${backupData.customCategories?.length ?? 0}.`
+ ));
+ } catch (error) {
+ console.error('Restore failed:', error);
+ alert(`${t('恢复失败', 'Restore failed')}: ${(error as Error).message}`);
+ }
+ } finally {
+ setIsRestoring(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {t('备份与恢复', 'Backup & Restore')}
+
+
+
+ {!activeConfig && (
+
+
+
+
+
+ {t('请先配置并激活WebDAV服务', 'Please configure and activate WebDAV service first')}
+
+
+ {t('备份和恢复功能需要WebDAV服务支持', 'Backup and restore features require WebDAV service')}
+
+
+
+
+ )}
+
+ {lastBackup && (
+
+
+ {t('上次备份:', 'Last backup:')} {' '}
+ {new Date(lastBackup).toLocaleString()}
+
+
+ )}
+
+
+
+
+
+
+
+ {t('备份数据', 'Backup Data')}
+
+
+ {t('将数据备份到WebDAV', 'Backup data to WebDAV')}
+
+
+
+
+ {isBackingUp ? (
+
+ ) : (
+
+ )}
+ {isBackingUp ? t('备份中...', 'Backing up...') : t('开始备份', 'Start Backup')}
+
+
+
+
+
+
+
+
+ {t('恢复数据', 'Restore Data')}
+
+
+ {t('从WebDAV恢复数据', 'Restore data from WebDAV')}
+
+
+
+
+ {isRestoring ? (
+
+ ) : (
+
+ )}
+ {isRestoring ? t('恢复中...', 'Restoring...') : t('开始恢复', 'Start Restore')}
+
+
+
+
+
+
+ {t('备份内容包括:', 'Backup includes:')}
+
+
+ • {t('GitHub Stars 仓库列表', 'GitHub Stars repository list')}
+ • {t('Release 发布信息', 'Release information')}
+ • {t('自定义分类', 'Custom categories')}
+ • {t('AI 服务配置', 'AI service configurations')}
+ • {t('WebDAV 配置', 'WebDAV configurations')}
+
+
+
+ );
+};
diff --git a/src/components/settings/CategoryPanel.tsx b/src/components/settings/CategoryPanel.tsx
new file mode 100644
index 00000000..26245ffc
--- /dev/null
+++ b/src/components/settings/CategoryPanel.tsx
@@ -0,0 +1,548 @@
+import React, { useState, useMemo, useRef, useCallback } from 'react';
+import { Package, Plus, Trash2, Edit3, Save, X, Eye, EyeOff, GripVertical, ArrowUp, ArrowDown, ArrowUpToLine, ArrowDownToLine, LayoutGrid } from 'lucide-react';
+import { useAppStore, getAllCategories, sortCategoriesByOrder } from '../../store/useAppStore';
+
+interface CategoryPanelProps {
+ t: (zh: string, en: string) => string;
+}
+
+export const CategoryPanel: React.FC = ({ t }) => {
+ const {
+ customCategories,
+ hiddenDefaultCategoryIds,
+ categoryOrder,
+ collapsedSidebarCategoryCount,
+ language,
+ addCustomCategory,
+ deleteCustomCategory,
+ updateCustomCategory,
+ hideDefaultCategory,
+ showDefaultCategory,
+ setCategoryOrder,
+ setCollapsedSidebarCategoryCount,
+ } = useAppStore();
+
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [newCategoryName, setNewCategoryName] = useState('');
+ const [newCategoryIcon, setNewCategoryIcon] = useState('📁');
+ const [editName, setEditName] = useState('');
+ const [editIcon, setEditIcon] = useState('');
+ const [isReordering, setIsReordering] = useState(false);
+
+ // 拖拽排序状态
+ const [draggingId, setDraggingId] = useState(null);
+ const [dragOverId, setDragOverId] = useState(null);
+ const dragItemIndex = useRef(null);
+
+ const allDefaultCategories = getAllCategories([], language, []);
+ const hiddenDefaultCategories = allDefaultCategories.filter(category =>
+ hiddenDefaultCategoryIds.includes(category.id)
+ );
+
+ // 获取所有可见分类(用于排序)
+ const allVisibleCategories = useMemo(() => {
+ const categories = getAllCategories(customCategories, language, hiddenDefaultCategoryIds);
+ return sortCategoriesByOrder(categories, categoryOrder);
+ }, [customCategories, language, hiddenDefaultCategoryIds, categoryOrder]);
+
+ const handleAddCategory = () => {
+ if (!newCategoryName.trim()) {
+ alert(t('请输入分类名称', 'Please enter category name'));
+ return;
+ }
+
+ const newCategory = {
+ id: `custom-${Date.now()}`,
+ name: newCategoryName.trim(),
+ icon: newCategoryIcon,
+ isCustom: true,
+ keywords: [],
+ };
+
+ addCustomCategory(newCategory);
+ setNewCategoryName('');
+ setNewCategoryIcon('📁');
+ setShowAddForm(false);
+ };
+
+ const handleStartEdit = (category: { id: string; name: string; icon: string }) => {
+ setEditingId(category.id);
+ setEditName(category.name);
+ setEditIcon(category.icon);
+ };
+
+ const handleSaveEdit = () => {
+ if (!editName.trim()) {
+ alert(t('分类名称不能为空', 'Category name cannot be empty'));
+ return;
+ }
+
+ if (editingId) {
+ updateCustomCategory(editingId, {
+ name: editName.trim(),
+ icon: editIcon,
+ });
+ setEditingId(null);
+ setEditName('');
+ setEditIcon('');
+ }
+ };
+
+ const handleCancelEdit = () => {
+ setEditingId(null);
+ setEditName('');
+ setEditIcon('');
+ };
+
+ const handleDeleteCategory = (categoryId: string) => {
+ if (confirm(t('确定要删除这个自定义分类吗?', 'Are you sure you want to delete this custom category?'))) {
+ deleteCustomCategory(categoryId);
+ }
+ };
+
+ // 处理分类排序 - 上下移动
+ const handleMoveCategory = (index: number, direction: 'up' | 'down') => {
+ const newIndex = direction === 'up' ? index - 1 : index + 1;
+ if (newIndex < 0 || newIndex >= allVisibleCategories.length) return;
+
+ // Compute new visible sequence and merge into existing categoryOrder preserving hidden IDs
+ const visibleIds = allVisibleCategories.map(c => c.id);
+ const [movedId] = visibleIds.splice(index, 1);
+ visibleIds.splice(newIndex, 0, movedId);
+ const hiddenIds = categoryOrder.filter(id => !visibleIds.includes(id));
+ setCategoryOrder([...visibleIds, ...hiddenIds]);
+ };
+
+ // 快速置顶
+ const handleMoveToTop = (index: number) => {
+ if (index === 0) return;
+ const visibleIds = allVisibleCategories.map(c => c.id);
+ const [movedId] = visibleIds.splice(index, 1);
+ visibleIds.unshift(movedId);
+ const hiddenIds = categoryOrder.filter(id => !visibleIds.includes(id));
+ setCategoryOrder([...visibleIds, ...hiddenIds]);
+ };
+
+ // 快速置底
+ const handleMoveToBottom = (index: number) => {
+ if (index === allVisibleCategories.length - 1) return;
+ const visibleIds = allVisibleCategories.map(c => c.id);
+ const [movedId] = visibleIds.splice(index, 1);
+ visibleIds.push(movedId);
+ const hiddenIds = categoryOrder.filter(id => !visibleIds.includes(id));
+ setCategoryOrder([...visibleIds, ...hiddenIds]);
+ };
+
+ // 重置分类排序
+ const handleResetOrder = () => {
+ if (confirm(t('确定要重置分类排序吗?这将恢复默认顺序。', 'Are you sure you want to reset category order? This will restore the default order.'))) {
+ setCategoryOrder([]);
+ }
+ };
+
+ // 拖拽开始
+ const handleDragStart = useCallback((e: React.DragEvent, index: number, categoryId: string) => {
+ dragItemIndex.current = index;
+ setDraggingId(categoryId);
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', categoryId);
+ // 设置拖拽时的透明度
+ if (e.currentTarget instanceof HTMLElement) {
+ e.currentTarget.style.opacity = '0.5';
+ }
+ }, []);
+
+ // 拖拽结束
+ const handleDragEnd = useCallback((e: React.DragEvent) => {
+ setDraggingId(null);
+ setDragOverId(null);
+ dragItemIndex.current = null;
+ if (e.currentTarget instanceof HTMLElement) {
+ e.currentTarget.style.opacity = '1';
+ }
+ }, []);
+
+ // 拖拽经过
+ const handleDragOver = useCallback((e: React.DragEvent, categoryId: string) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ setDragOverId(categoryId);
+ }, []);
+
+ // 拖拽离开
+ const handleDragLeave = useCallback(() => {
+ setDragOverId(null);
+ }, []);
+
+ // 放置
+ const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => {
+ e.preventDefault();
+ setDragOverId(null);
+
+ if (dragItemIndex.current === null || dragItemIndex.current === dropIndex) return;
+
+ const state = useAppStore.getState();
+ const currentCategories = getAllCategories(state.customCategories, state.language, state.hiddenDefaultCategoryIds);
+ const currentVisible = sortCategoriesByOrder(currentCategories, state.categoryOrder);
+ const visibleIds = currentVisible.map(c => c.id);
+ const [movedId] = visibleIds.splice(dragItemIndex.current, 1);
+ visibleIds.splice(dropIndex, 0, movedId);
+ const hiddenIds = state.categoryOrder.filter(id => !visibleIds.includes(id));
+ setCategoryOrder([...visibleIds, ...hiddenIds]);
+ dragItemIndex.current = null;
+ setDraggingId(null);
+ }, [setCategoryOrder]);
+
+ return (
+
+
+
+
+
+ {t('分类管理', 'Category Management')}
+
+
+
setShowAddForm(true)}
+ className="flex items-center space-x-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
+ >
+
+ {t('添加分类', 'Add Category')}
+
+
+
+ {/* 折叠侧边栏显示设置 */}
+
+
+
+
+
+
+ {t('折叠侧边栏显示设置', 'Collapsed Sidebar Display')}
+
+
+ {t('设置折叠状态下显示的分类个数', 'Set the number of categories to display when collapsed')}
+
+
+ {t(
+ '提示:折叠侧边栏仅影响显示,所有分类仍可在展开状态下查看。只显示分类顺序前N个分类。',
+ 'Tip: The collapsed sidebar only affects display; all categories remain accessible when expanded. Only the first N categories in the order are displayed.'
+ )}
+
+
+
+
+ {
+ const inputValue = e.target.value;
+ if (inputValue === '') return;
+ const value = parseInt(inputValue);
+ if (!isNaN(value) && value >= 1) {
+ setCollapsedSidebarCategoryCount(value);
+ }
+ }}
+ onBlur={(e) => {
+ const value = parseInt(e.target.value);
+ if (isNaN(value) || value < 1) {
+ setCollapsedSidebarCategoryCount(1);
+ }
+ }}
+ className="w-20 px-3 py-1.5 text-center border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ placeholder="≥1"
+ />
+
+
+
+
+
+
+ {showAddForm && (
+
+
+ {t('添加自定义分类', 'Add Custom Category')}
+
+
+
+
+
+ {t('保存', 'Save')}
+
+ {
+ setShowAddForm(false);
+ setNewCategoryName('');
+ setNewCategoryIcon('📁');
+ }}
+ className="flex items-center space-x-2 px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
+ >
+
+ {t('取消', 'Cancel')}
+
+
+
+ )}
+
+
+ {/* 分类排序区域 */}
+
+
+
+
+ {t('分类排序', 'Category Order')}
+
+ ({allVisibleCategories.length})
+
+
+
+ setIsReordering(!isReordering)}
+ className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
+ isReordering
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
+ : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
+ }`}
+ >
+ {isReordering ? t('完成', 'Done') : t('调整顺序', 'Reorder')}
+
+ {categoryOrder.length > 0 && (
+
+ {t('重置', 'Reset')}
+
+ )}
+
+
+
+ {isReordering && (
+
+
+ {t('提示:拖拽分类可快速调整顺序,或使用按钮进行置顶/置底操作', 'Tip: Drag categories to quickly reorder, or use buttons to move to top/bottom')}
+
+
+ )}
+
+ {allVisibleCategories.length === 0 ? (
+
+ {t('暂无可见分类', 'No visible categories')}
+
+ ) : (
+
+ {allVisibleCategories.map((category, index) => (
+
handleDragStart(e, index, category.id)}
+ onDragEnd={handleDragEnd}
+ onDragOver={(e) => handleDragOver(e, category.id)}
+ onDragLeave={handleDragLeave}
+ onDrop={(e) => handleDrop(e, index)}
+ className={`flex items-center justify-between p-3 rounded-lg border transition-all ${
+ category.isCustom
+ ? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
+ : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600'
+ } ${draggingId === category.id ? 'opacity-50' : ''} ${
+ dragOverId === category.id && draggingId !== category.id
+ ? 'border-blue-400 dark:border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800 transform scale-[1.02]'
+ : ''
+ } ${isReordering ? 'cursor-move' : ''}`}
+ >
+
+ {isReordering && (
+
+ )}
+ {category.icon}
+
+ {category.name}
+
+ {category.isCustom && (
+
+ {t('自定义', 'Custom')}
+
+ )}
+
+
+ {isReordering ? (
+
+
handleMoveToTop(index)}
+ disabled={index === 0}
+ className="p-1.5 rounded bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-400 hover:bg-purple-200 dark:hover:bg-purple-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+ title={t('置顶', 'Move to top')}
+ >
+
+
+
handleMoveCategory(index, 'up')}
+ disabled={index === 0}
+ className="p-1.5 rounded bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+ title={t('上移', 'Move up')}
+ >
+
+
+
handleMoveCategory(index, 'down')}
+ disabled={index === allVisibleCategories.length - 1}
+ className="p-1.5 rounded bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+ title={t('下移', 'Move down')}
+ >
+
+
+
handleMoveToBottom(index)}
+ disabled={index === allVisibleCategories.length - 1}
+ className="p-1.5 rounded bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-400 hover:bg-purple-200 dark:hover:bg-purple-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+ title={t('置底', 'Move to bottom')}
+ >
+
+
+
+ ) : (
+
+ {category.isCustom ? (
+ <>
+ handleStartEdit(category)}
+ className="p-1.5 rounded bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-800"
+ title={t('编辑', 'Edit')}
+ >
+
+
+ handleDeleteCategory(category.id)}
+ className="p-1.5 rounded bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-800"
+ title={t('删除', 'Delete')}
+ >
+
+
+ >
+ ) : (
+ hideDefaultCategory(category.id)}
+ className="p-1.5 rounded bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600"
+ title={t('隐藏', 'Hide')}
+ >
+
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* 编辑模态框 */}
+ {editingId && (
+
+ )}
+
+ {/* 隐藏的默认分类 */}
+ {hiddenDefaultCategories.length > 0 && (
+
+
+
+ {t('隐藏的默认分类', 'Hidden Default Categories')}
+
+ ({hiddenDefaultCategories.length})
+
+
+
+ {hiddenDefaultCategories.map((category) => (
+ showDefaultCategory(category.id)}
+ className="inline-flex items-center space-x-2 px-3 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
+ >
+
+ {category.icon}
+ {category.name}
+
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/settings/DataManagementPanel.tsx b/src/components/settings/DataManagementPanel.tsx
new file mode 100644
index 00000000..1d284cb7
--- /dev/null
+++ b/src/components/settings/DataManagementPanel.tsx
@@ -0,0 +1,701 @@
+import React, { useState, useCallback } from 'react';
+import {
+ Trash2,
+ AlertTriangle,
+ Database,
+ Github,
+ Tag,
+ Bot,
+ Cloud,
+ FolderTree,
+ CheckCircle,
+ XCircle,
+ Loader2,
+ FileWarning,
+} from 'lucide-react';
+import { useAppStore } from '../../store/useAppStore';
+import { indexedDBStorage } from '../../services/indexedDbStorage';
+
+interface DataManagementPanelProps {
+ t: (zh: string, en: string) => string;
+}
+
+type DeleteOperation =
+ | 'repositories'
+ | 'releases'
+ | 'aiConfigs'
+ | 'webdavConfigs'
+ | 'categorySettings'
+ | 'all';
+
+interface DeleteConfirmation {
+ type: DeleteOperation | null;
+ isOpen: boolean;
+ githubUsernameInput: string;
+}
+
+interface OperationLog {
+ id: string;
+ operation: string;
+ timestamp: string;
+ success: boolean;
+ details?: string;
+}
+
+export const DataManagementPanel: React.FC = ({ t }) => {
+ const {
+ user,
+ repositories,
+ releases,
+ aiConfigs,
+ webdavConfigs,
+ customCategories,
+ setRepositories,
+ setReleases,
+ deleteCustomCategory,
+ } = useAppStore();
+
+ const [confirmation, setConfirmation] = useState({
+ type: null,
+ isOpen: false,
+ githubUsernameInput: '',
+ });
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [operationLogs, setOperationLogs] = useState([]);
+ const [showSuccessMessage, setShowSuccessMessage] = useState(null);
+ const [showErrorMessage, setShowErrorMessage] = useState(null);
+
+ const addLog = useCallback((operation: string, success: boolean, details?: string) => {
+ const newLog: OperationLog = {
+ id: Date.now().toString(),
+ operation,
+ timestamp: new Date().toLocaleString(),
+ success,
+ details,
+ };
+ setOperationLogs((prev) => [newLog, ...prev].slice(0, 50));
+ }, []);
+
+ const showSuccess = useCallback((message: string) => {
+ setShowSuccessMessage(message);
+ setTimeout(() => setShowSuccessMessage(null), 3000);
+ }, []);
+
+ const showError = useCallback((message: string) => {
+ setShowErrorMessage(message);
+ setTimeout(() => setShowErrorMessage(null), 5000);
+ }, []);
+
+ const clearAllStorage = async () => {
+ // App-owned localStorage keys and prefixes
+ const APP_LOCALSTORAGE_KEYS = [
+ 'github-stars-search-history',
+ 'lastSearchTime',
+ ];
+ const APP_LOCALSTORAGE_PREFIXES = [
+ 'github-stars-manager',
+ ];
+
+ // Clear localStorage - only remove app-owned keys
+ const keysToRemove: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key) {
+ const isExactMatch = APP_LOCALSTORAGE_KEYS.includes(key);
+ const isPrefixMatch = APP_LOCALSTORAGE_PREFIXES.some(prefix => key.startsWith(prefix));
+ if (isExactMatch || isPrefixMatch) {
+ keysToRemove.push(key);
+ }
+ }
+ }
+ keysToRemove.forEach((key) => localStorage.removeItem(key));
+
+ // App-owned sessionStorage keys
+ const APP_SESSIONSTORAGE_KEYS = [
+ 'github-stars-manager-backend-secret',
+ ];
+
+ // Clear sessionStorage - only remove app-owned keys
+ APP_SESSIONSTORAGE_KEYS.forEach((key) => sessionStorage.removeItem(key));
+
+ // Clear IndexedDB - only remove the specific database used by this app
+ try {
+ await indexedDBStorage.removeItem('github-stars-manager');
+ } catch (error) {
+ console.error('Failed to clear IndexedDB', error);
+ throw new Error('IndexedDB clear failed');
+ }
+ };
+
+ const deleteRepositories = async () => {
+ try {
+ setRepositories([]);
+ addLog(t('删除GitHub Stars仓库数据', 'Delete GitHub Stars repositories'), true);
+ showSuccess(t('GitHub Stars仓库数据已删除', 'GitHub Stars repositories deleted'));
+ } catch (error) {
+ addLog(
+ t('删除GitHub Stars仓库数据', 'Delete GitHub Stars repositories'),
+ false,
+ String(error)
+ );
+ showError(t('删除失败,请重试', 'Delete failed, please try again'));
+ throw error;
+ }
+ };
+
+ const deleteReleases = async () => {
+ try {
+ setReleases([]);
+ addLog(t('删除Release发布信息数据', 'Delete Release information'), true);
+ showSuccess(t('Release发布信息数据已删除', 'Release information deleted'));
+ } catch (error) {
+ addLog(
+ t('删除Release发布信息数据', 'Delete Release information'),
+ false,
+ String(error)
+ );
+ showError(t('删除失败,请重试', 'Delete failed, please try again'));
+ throw error;
+ }
+ };
+
+ const deleteAIConfigs = async () => {
+ try {
+ const store = useAppStore.getState();
+ store.setAIConfigs([]);
+ store.setActiveAIConfig(null);
+ addLog(t('删除AI服务配置数据', 'Delete AI service configurations'), true);
+ showSuccess(t('AI服务配置数据已删除', 'AI service configurations deleted'));
+ } catch (error) {
+ addLog(
+ t('删除AI服务配置数据', 'Delete AI service configurations'),
+ false,
+ String(error)
+ );
+ showError(t('删除失败,请重试', 'Delete failed, please try again'));
+ throw error;
+ }
+ };
+
+ const deleteWebDAVConfigs = async () => {
+ try {
+ const store = useAppStore.getState();
+ store.setWebDAVConfigs([]);
+ store.setActiveWebDAVConfig(null);
+ addLog(t('删除WebDAV配置数据', 'Delete WebDAV configurations'), true);
+ showSuccess(t('WebDAV配置数据已删除', 'WebDAV configurations deleted'));
+ } catch (error) {
+ addLog(
+ t('删除WebDAV配置数据', 'Delete WebDAV configurations'),
+ false,
+ String(error)
+ );
+ showError(t('删除失败,请重试', 'Delete failed, please try again'));
+ throw error;
+ }
+ };
+
+ const deleteCategorySettings = async () => {
+ try {
+ const store = useAppStore.getState();
+ // 先复制分类数组,避免在迭代过程中修改原数组
+ const categoriesToDelete = [...store.customCategories];
+ // Reset category-related state
+ for (const cat of categoriesToDelete) {
+ store.deleteCustomCategory(cat.id);
+ }
+ // Clear hidden default categories and reset category-related settings
+ useAppStore.setState({
+ hiddenDefaultCategoryIds: [],
+ categoryOrder: [],
+ collapsedSidebarCategoryCount: 20,
+ isSidebarCollapsed: false
+ });
+ addLog(t('删除分类显示设置数据', 'Delete category display settings'), true);
+ showSuccess(t('分类显示设置数据已删除', 'Category display settings deleted'));
+ } catch (error) {
+ addLog(
+ t('删除分类显示设置数据', 'Delete category display settings'),
+ false,
+ String(error)
+ );
+ showError(t('删除失败,请重试', 'Delete failed, please try again'));
+ throw error;
+ }
+ };
+
+ const deleteAllData = async () => {
+ try {
+ // 先清除存储,确保存储清除成功后再重置状态
+ // 这样可以避免状态已重置但存储清除失败导致的数据不一致
+ await clearAllStorage();
+
+ // 存储清除成功后,重置所有状态到初始值
+ useAppStore.setState({
+ // 用户和认证
+ user: null,
+ githubToken: null,
+ isAuthenticated: false,
+
+ // 仓库数据
+ repositories: [],
+ searchResults: [],
+ lastSync: null,
+
+ // Release 数据
+ releases: [],
+ releaseSubscriptions: new Set(),
+ readReleases: new Set(),
+
+ // AI 配置
+ aiConfigs: [],
+ activeAIConfig: null,
+
+ // WebDAV 配置
+ webdavConfigs: [],
+ activeWebDAVConfig: null,
+ lastBackup: null,
+
+ // 分类设置
+ customCategories: [],
+ hiddenDefaultCategoryIds: [],
+ categoryOrder: [],
+ collapsedSidebarCategoryCount: 20,
+
+ // 资源过滤器
+ assetFilters: [],
+
+ // UI 设置
+ selectedCategory: 'all',
+ isSidebarCollapsed: false,
+ searchFilters: {
+ query: '',
+ tags: [],
+ languages: [],
+ platforms: [],
+ sortBy: 'stars',
+ sortOrder: 'desc',
+ isAnalyzed: undefined,
+ isSubscribed: undefined,
+ },
+ });
+
+ addLog(t('删除所有数据', 'Delete all data'), true);
+ showSuccess(t('所有数据已删除,应用将重新加载', 'All data deleted, app will reload'));
+
+ // Reload page after a short delay
+ setTimeout(() => {
+ window.location.reload();
+ }, 2000);
+ } catch (error) {
+ addLog(t('删除所有数据', 'Delete all data'), false, String(error));
+ showError(t('删除失败,请重试', 'Delete failed, please try again'));
+ throw error;
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!confirmation.type) return;
+
+ // Verify GitHub username for "delete all" operation
+ if (confirmation.type === 'all') {
+ if (!user || confirmation.githubUsernameInput !== user.login) {
+ showError(t('GitHub用户名验证失败', 'GitHub username verification failed'));
+ return;
+ }
+ }
+
+ setIsDeleting(true);
+ try {
+ switch (confirmation.type) {
+ case 'repositories':
+ await deleteRepositories();
+ break;
+ case 'releases':
+ await deleteReleases();
+ break;
+ case 'aiConfigs':
+ await deleteAIConfigs();
+ break;
+ case 'webdavConfigs':
+ await deleteWebDAVConfigs();
+ break;
+ case 'categorySettings':
+ await deleteCategorySettings();
+ break;
+ case 'all':
+ await deleteAllData();
+ break;
+ }
+ setConfirmation({ type: null, isOpen: false, githubUsernameInput: '' });
+ } catch {
+ // Error already handled in individual delete functions
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const openConfirmation = (type: DeleteOperation) => {
+ setConfirmation({
+ type,
+ isOpen: true,
+ githubUsernameInput: '',
+ });
+ };
+
+ const closeConfirmation = () => {
+ setConfirmation({ type: null, isOpen: false, githubUsernameInput: '' });
+ };
+
+ const getDeleteDescription = (type: DeleteOperation): string => {
+ switch (type) {
+ case 'repositories':
+ return t(
+ '这将删除所有GitHub Stars仓库列表数据,包括仓库信息、AI分析结果和自定义标签。此操作不可恢复。',
+ 'This will delete all GitHub Stars repository data, including repository info, AI analysis results, and custom tags. This action cannot be undone.'
+ );
+ case 'releases':
+ return t(
+ '这将删除所有Release发布信息数据,包括发布说明和资源文件信息。此操作不可恢复。',
+ 'This will delete all Release information data, including release notes and asset information. This action cannot be undone.'
+ );
+ case 'aiConfigs':
+ return t(
+ '这将删除所有AI服务配置数据,包括API密钥和模型设置。此操作不可恢复。',
+ 'This will delete all AI service configuration data, including API keys and model settings. This action cannot be undone.'
+ );
+ case 'webdavConfigs':
+ return t(
+ '这将删除所有WebDAV配置数据,包括服务器地址和认证信息。此操作不可恢复。',
+ 'This will delete all WebDAV configuration data, including server addresses and authentication info. This action cannot be undone.'
+ );
+ case 'categorySettings':
+ return t(
+ '这将删除所有自定义分类和分类显示设置。此操作不可恢复。',
+ 'This will delete all custom categories and category display settings. This action cannot be undone.'
+ );
+ case 'all':
+ return t(
+ '这将删除所有应用程序数据,包括所有用户数据、GitHub令牌、配置文件等。此操作将重置应用程序到初始状态,不可恢复!',
+ 'This will delete ALL application data, including all user data, GitHub tokens, configuration files, etc. This will reset the application to its initial state and cannot be undone!'
+ );
+ default:
+ return '';
+ }
+ };
+
+ const getDeleteTitle = (type: DeleteOperation): string => {
+ switch (type) {
+ case 'repositories':
+ return t('删除GitHub Stars仓库数据', 'Delete GitHub Stars Repositories');
+ case 'releases':
+ return t('删除Release发布信息数据', 'Delete Release Information');
+ case 'aiConfigs':
+ return t('删除AI服务配置数据', 'Delete AI Service Configurations');
+ case 'webdavConfigs':
+ return t('删除WebDAV配置数据', 'Delete WebDAV Configurations');
+ case 'categorySettings':
+ return t('删除分类显示设置数据', 'Delete Category Display Settings');
+ case 'all':
+ return t('删除所有数据', 'Delete All Data');
+ default:
+ return '';
+ }
+ };
+
+ const dataStats = [
+ {
+ key: 'repositories',
+ label: t('GitHub Stars仓库', 'GitHub Stars Repositories'),
+ count: repositories.length,
+ icon: ,
+ color: 'text-blue-600 dark:text-blue-400',
+ bgColor: 'bg-blue-50 dark:bg-blue-900/20',
+ },
+ {
+ key: 'releases',
+ label: t('Release发布信息', 'Release Information'),
+ count: releases.length,
+ icon: ,
+ color: 'text-green-600 dark:text-green-400',
+ bgColor: 'bg-green-50 dark:bg-green-900/20',
+ },
+ {
+ key: 'aiConfigs',
+ label: t('AI服务配置', 'AI Service Configurations'),
+ count: aiConfigs.length,
+ icon: ,
+ color: 'text-purple-600 dark:text-purple-400',
+ bgColor: 'bg-purple-50 dark:bg-purple-900/20',
+ },
+ {
+ key: 'webdavConfigs',
+ label: t('WebDAV配置', 'WebDAV Configurations'),
+ count: webdavConfigs.length,
+ icon: ,
+ color: 'text-cyan-600 dark:text-cyan-400',
+ bgColor: 'bg-cyan-50 dark:bg-cyan-900/20',
+ },
+ {
+ key: 'categorySettings',
+ label: t('自定义分类', 'Custom Categories'),
+ count: customCategories.length,
+ icon: ,
+ color: 'text-orange-600 dark:text-orange-400',
+ bgColor: 'bg-orange-50 dark:bg-orange-900/20',
+ },
+ ];
+
+ return (
+
+ {/* Success Message */}
+ {showSuccessMessage && (
+
+
+ {showSuccessMessage}
+
+ )}
+
+ {/* Error Message */}
+ {showErrorMessage && (
+
+
+ {showErrorMessage}
+
+ )}
+
+ {/* Data Statistics */}
+
+
+
+ {t('数据概览', 'Data Overview')}
+
+
+ {dataStats.map((stat) => (
+
+
+
+
+ {stat.icon}
+
+
+
{stat.label}
+
+ {stat.count}
+
+
+
+
+
+ ))}
+
+
+
+ {/* Selective Data Deletion */}
+
+
+
+ {t('选择性删除数据', 'Selective Data Deletion')}
+
+
+
+ {dataStats.map((stat) => (
+
+
+
+ {stat.icon}
+
+
+
{stat.label}
+
+ {stat.count} {t('条记录', 'records')}
+
+
+
+
openConfirmation(stat.key as DeleteOperation)}
+ disabled={stat.count === 0}
+ className="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+
+ {t('删除', 'Delete')}
+
+
+ ))}
+
+
+
+
+ {/* Delete All Data */}
+
+
+
+ {t('危险区域', 'Danger Zone')}
+
+
+
+
+
+
+
+
+ {t('删除所有数据', 'Delete All Data')}
+
+
+ {t(
+ '此操作将永久删除所有应用程序数据,包括所有用户数据、GitHub令牌、配置文件等。应用程序将重置为初始状态。此操作不可恢复!',
+ 'This will permanently delete ALL application data, including all user data, GitHub tokens, configuration files, etc. The application will be reset to its initial state. This action cannot be undone!'
+ )}
+
+
openConfirmation('all')}
+ className="mt-4 px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition-colors flex items-center space-x-2"
+ >
+
+ {t('删除所有数据', 'Delete All Data')}
+
+
+
+
+
+
+ {/* Operation Logs */}
+ {operationLogs.length > 0 && (
+
+
+ {t('操作日志', 'Operation Logs')}
+
+
+
+
+
+
+
+ {t('时间', 'Time')}
+
+
+ {t('操作', 'Operation')}
+
+
+ {t('状态', 'Status')}
+
+
+
+
+ {operationLogs.map((log) => (
+
+
+ {log.timestamp}
+
+ {log.operation}
+
+ {log.success ? (
+
+
+ {t('成功', 'Success')}
+
+ ) : (
+
+
+ {t('失败', 'Failed')}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* Confirmation Modal */}
+ {confirmation.isOpen && (
+
+
+
+
+
+
+ {getDeleteTitle(confirmation.type!)}
+
+
+
+
+
+
+
+
{getDeleteDescription(confirmation.type!)}
+
+
+ {/* GitHub Username Verification for "Delete All" */}
+ {confirmation.type === 'all' && user && (
+
+
+ {t(
+ '请输入您的GitHub用户名以确认此操作:',
+ 'Please enter your GitHub username to confirm this action:'
+ )}
+
+ {user.login}
+
+
+
+ setConfirmation((prev) => ({
+ ...prev,
+ githubUsernameInput: e.target.value,
+ }))
+ }
+ placeholder={t('输入GitHub用户名', 'Enter GitHub username')}
+ className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 dark:bg-gray-700 dark:text-white"
+ />
+
+ )}
+
+
+
+ {t('取消', 'Cancel')}
+
+
+ {isDeleting ? (
+ <>
+
+ {t('删除中...', 'Deleting...')}
+ >
+ ) : (
+ <>
+
+ {t('确认删除', 'Confirm Delete')}
+ >
+ )}
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/src/components/settings/GeneralPanel.tsx b/src/components/settings/GeneralPanel.tsx
new file mode 100644
index 00000000..9e350780
--- /dev/null
+++ b/src/components/settings/GeneralPanel.tsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import { Globe, Package, Mail, ExternalLink, Github, Twitter } from 'lucide-react';
+import { UpdateChecker } from '../UpdateChecker';
+import { useAppStore } from '../../store/useAppStore';
+import { version } from '../../../package.json';
+
+interface GeneralPanelProps {
+ t: (zh: string, en: string) => string;
+}
+
+export const GeneralPanel: React.FC = ({ t }) => {
+ const { language, setLanguage } = useAppStore();
+
+ return (
+
+
+
+
+ {t('通用设置', 'General Settings')}
+
+
+
+
+
+
+
+ {t('语言设置', 'Language Settings')}
+
+
+
+
+
+ setLanguage(e.target.value as 'zh' | 'en')}
+ className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+ 中文
+
+
+ Simplified Chinese
+
+
+
+
+ setLanguage(e.target.value as 'zh' | 'en')}
+ className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+ English
+
+
+ US English
+
+
+
+
+
+
+
+
+
+
+ {t('检查更新', 'Check for Updates')}
+
+
+
+
+
+
+ {t(`当前版本: v${version}`, `Current Version: v${version}`)}
+
+
+ {t('检查是否有新版本可用', 'Check if a new version is available')}
+
+
+
+
+
+
+
+
+
+
+ {t('联系方式', 'Contact Information')}
+
+
+
+
+ {t('如果您在使用过程中遇到任何问题或有建议,欢迎通过以下方式联系我:', 'If you encounter any issues or have suggestions while using the app, feel free to contact me through:')}
+
+
+
+ {
+ const newWindow = window.open('https://x.com/GoodMan_Lee', '_blank', 'noopener,noreferrer');
+ if (newWindow) {
+ newWindow.opener = null;
+ }
+ }}
+ className="flex items-center justify-center space-x-2 px-4 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
+ >
+
+ Twitter
+
+
+
+ {
+ const newWindow = window.open('https://github.com/AmintaCCCP/GithubStarsManager', '_blank', 'noopener,noreferrer');
+ if (newWindow) {
+ newWindow.opener = null;
+ }
+ }}
+ className="flex items-center justify-center space-x-2 px-4 py-3 bg-gray-800 hover:bg-gray-900 text-white rounded-lg transition-colors"
+ >
+
+ {t('链接到GitHub', 'Link to GitHub')}
+
+
+
+
+
+ );
+};
diff --git a/src/components/settings/WebDAVPanel.tsx b/src/components/settings/WebDAVPanel.tsx
new file mode 100644
index 00000000..e1a35007
--- /dev/null
+++ b/src/components/settings/WebDAVPanel.tsx
@@ -0,0 +1,304 @@
+import React, { useState } from 'react';
+import { Cloud, Plus, Edit3, Trash2, Save, X, TestTube, RefreshCw } from 'lucide-react';
+import { WebDAVConfig } from '../../types';
+import { useAppStore } from '../../store/useAppStore';
+import { WebDAVService } from '../../services/webdavService';
+
+interface WebDAVPanelProps {
+ t: (zh: string, en: string) => string;
+}
+
+export const WebDAVPanel: React.FC = ({ t }) => {
+ const {
+ webdavConfigs,
+ activeWebDAVConfig,
+ addWebDAVConfig,
+ updateWebDAVConfig,
+ deleteWebDAVConfig,
+ setActiveWebDAVConfig,
+ } = useAppStore();
+
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [testingId, setTestingId] = useState(null);
+
+ const [form, setForm] = useState({
+ name: '',
+ url: '',
+ username: '',
+ password: '',
+ path: '/',
+ });
+
+ const resetForm = () => {
+ setForm({
+ name: '',
+ url: '',
+ username: '',
+ password: '',
+ path: '/',
+ });
+ setShowForm(false);
+ setEditingId(null);
+ };
+
+ const handleSave = () => {
+ const errors = WebDAVService.validateConfig(form);
+ if (errors.length > 0) {
+ const translated = errors.map(err => {
+ if (err === 'WebDAV URL是必需的') return t('WebDAV URL是必需的', 'WebDAV URL is required');
+ if (err === 'WebDAV URL必须以 http:// 或 https:// 开头') return t('WebDAV URL必须以 http:// 或 https:// 开头', 'WebDAV URL must start with http:// or https://');
+ if (err === '用户名是必需的') return t('用户名是必需的', 'Username is required');
+ if (err === '密码是必需的') return t('密码是必需的', 'Password is required');
+ if (err === '路径是必需的') return t('路径是必需的', 'Path is required');
+ if (err === '路径必须以 / 开头') return t('路径必须以 / 开头', 'Path must start with /');
+ return err;
+ });
+ alert(translated.join('\n'));
+ return;
+ }
+
+ // When editing, preserve existing isActive value from current config
+ const existingConfig = editingId ? webdavConfigs.find(c => c.id === editingId) : undefined;
+ const config: WebDAVConfig = {
+ id: editingId || Date.now().toString(),
+ name: form.name,
+ url: form.url.replace(/\/$/, ''),
+ username: form.username,
+ password: form.password,
+ path: form.path,
+ isActive: existingConfig?.isActive ?? false,
+ };
+
+ if (editingId) {
+ updateWebDAVConfig(editingId, config);
+ } else {
+ addWebDAVConfig(config);
+ }
+
+ resetForm();
+ };
+
+ const handleEdit = (config: WebDAVConfig) => {
+ setForm({
+ name: config.name,
+ url: config.url,
+ username: config.username,
+ password: config.password,
+ path: config.path,
+ });
+ setEditingId(config.id);
+ setShowForm(true);
+ };
+
+ const handleTest = async (config: WebDAVConfig) => {
+ setTestingId(config.id);
+ try {
+ const webdavService = new WebDAVService(config);
+ const isConnected = await webdavService.testConnection();
+
+ if (isConnected) {
+ alert(t('WebDAV连接成功!', 'WebDAV connection successful!'));
+ } else {
+ alert(t('WebDAV连接失败,请检查配置。', 'WebDAV connection failed. Please check configuration.'));
+ }
+ } catch (error) {
+ console.error('WebDAV test failed:', error);
+ alert(`${t('WebDAV测试失败', 'WebDAV test failed')}: ${(error as Error).message}`);
+ } finally {
+ setTestingId(null);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t('WebDAV配置', 'WebDAV Configuration')}
+
+
+
setShowForm(true)}
+ className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
+ >
+
+ {t('添加WebDAV', 'Add WebDAV')}
+
+
+
+ {showForm && (
+
+
+ {editingId ? t('编辑WebDAV配置', 'Edit WebDAV Configuration') : t('添加WebDAV配置', 'Add WebDAV Configuration')}
+
+
+
+
+
+ {t('配置名称', 'Configuration Name')} *
+
+ setForm(prev => ({ ...prev, name: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder={t('例如: 坚果云', 'e.g., Nutstore')}
+ />
+
+
+
+
+ {t('WebDAV URL', 'WebDAV URL')} *
+
+ setForm(prev => ({ ...prev, url: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder="https://dav.jianguoyun.com/dav/"
+ />
+
+
+
+
+ {t('用户名', 'Username')} *
+
+ setForm(prev => ({ ...prev, username: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder={t('WebDAV用户名', 'WebDAV username')}
+ />
+
+
+
+
+ {t('密码', 'Password')} *
+
+ setForm(prev => ({ ...prev, password: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder={t('WebDAV密码', 'WebDAV password')}
+ />
+
+
+
+
+ {t('路径', 'Path')} *
+
+ setForm(prev => ({ ...prev, path: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
+ placeholder="/github-stars-manager/"
+ />
+
+
+
+
+
+
+ {t('保存', 'Save')}
+
+
+
+ {t('取消', 'Cancel')}
+
+
+
+ )}
+
+
+ {webdavConfigs.map(config => (
+
+
+
+
setActiveWebDAVConfig(config.id)}
+ className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
{config.name}
+
+ {config.url} • {config.path}
+
+ {config.passwordStatus === 'decrypt_failed' && (
+
+ {t(
+ '存储的 WebDAV 密码无法解密,请重新输入并保存该配置。',
+ 'The stored WebDAV password could not be decrypted. Please re-enter and save this configuration.'
+ )}
+
+ )}
+
+
+
+
+ handleTest(config)}
+ disabled={testingId === config.id}
+ className="p-2 rounded-lg bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors disabled:opacity-50"
+ title={t('测试连接', 'Test Connection')}
+ >
+ {testingId === config.id ? (
+
+ ) : (
+
+ )}
+
+ handleEdit(config)}
+ className="p-2 rounded-lg bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-800 transition-colors"
+ title={t('编辑', 'Edit')}
+ >
+
+
+ {
+ if (confirm(t('确定要删除这个WebDAV配置吗?', 'Are you sure you want to delete this WebDAV configuration?'))) {
+ deleteWebDAVConfig(config.id);
+ }
+ }}
+ className="p-2 rounded-lg bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-800 transition-colors"
+ title={t('删除', 'Delete')}
+ >
+
+
+
+
+
+ ))}
+
+ {webdavConfigs.length === 0 && (
+
+
+
{t('还没有配置WebDAV服务', 'No WebDAV services configured yet')}
+
{t('点击上方按钮添加WebDAV配置', 'Click the button above to add WebDAV configuration')}
+
+ )}
+
+
+ );
+};
diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts
new file mode 100644
index 00000000..d7e71ff4
--- /dev/null
+++ b/src/components/settings/index.ts
@@ -0,0 +1,7 @@
+export { AIConfigPanel } from './AIConfigPanel';
+export { WebDAVPanel } from './WebDAVPanel';
+export { BackupPanel } from './BackupPanel';
+export { BackendPanel } from './BackendPanel';
+export { CategoryPanel } from './CategoryPanel';
+export { GeneralPanel } from './GeneralPanel';
+export { DataManagementPanel } from './DataManagementPanel';
diff --git a/src/constants/presetFilters.ts b/src/constants/presetFilters.ts
new file mode 100644
index 00000000..2c794efb
--- /dev/null
+++ b/src/constants/presetFilters.ts
@@ -0,0 +1,18 @@
+/**
+ * 预设资源筛选器常量
+ * 用于 ReleaseTimeline 和 AssetFilterManager 组件
+ */
+
+export interface PresetFilter {
+ id: string;
+ name: string;
+ keywords: string[];
+}
+
+export const PRESET_FILTERS: PresetFilter[] = [
+ { id: 'preset-windows', name: 'Windows', keywords: ['windows', 'win', 'exe', 'msi'] },
+ { id: 'preset-macos', name: 'macOS', keywords: ['mac', 'macos', 'darwin', 'dmg', 'pkg'] },
+ { id: 'preset-linux', name: 'Linux', keywords: ['linux', 'appimage', 'deb', 'rpm'] },
+ { id: 'preset-android', name: 'Android', keywords: ['android', 'apk'] },
+ { id: 'preset-source', name: 'Source', keywords: ['source', 'src', 'tar.gz', 'tar.xz', 'zip'] },
+];
diff --git a/src/index.css b/src/index.css
index 3a422e08..24920097 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,14 +1,86 @@
+/* stylelint-disable */
@tailwind base;
@tailwind components;
@tailwind utilities;
+/* stylelint-enable */
@layer utilities {
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
+ line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
+
+ /* 隐藏滚动条但保持滚动功能 */
+ .scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ .scrollbar-hide::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* 滚动时显示滚动条,默认隐藏 - macOS 风格 */
+ .scrollbar-auto {
+ scrollbar-width: thin;
+ scrollbar-color: transparent transparent;
+ overflow: auto;
+ }
+
+ .scrollbar-auto::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+
+ .scrollbar-auto::-webkit-scrollbar-track {
+ background: transparent;
+ margin: 4px 0;
+ }
+
+ .scrollbar-auto::-webkit-scrollbar-thumb {
+ background: transparent;
+ border-radius: 4px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+ min-height: 40px;
+ }
+
+ /* 悬停、滚动中、聚焦时显示滚动条 */
+ .scrollbar-auto:hover::-webkit-scrollbar-thumb,
+ .scrollbar-auto.scrolling::-webkit-scrollbar-thumb,
+ .scrollbar-auto:focus-within::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.25);
+ }
+
+ .dark .scrollbar-auto:hover::-webkit-scrollbar-thumb,
+ .dark .scrollbar-auto.scrolling::-webkit-scrollbar-thumb,
+ .dark .scrollbar-auto:focus-within::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.25);
+ }
+
+ /* 滚动条悬停时加粗 */
+ .scrollbar-auto::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.4) !important;
+ border: 1px solid transparent;
+ }
+
+ .dark .scrollbar-auto::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.4) !important;
+ border: 1px solid transparent;
+ }
+
+ /* 滚动时添加 scrolling 类的动画 */
+ .scrollbar-auto.scrolling::-webkit-scrollbar-thumb {
+ transition: background 0.3s ease;
+ }
+
+ /* 触摸优化 */
+ .touch-manipulation {
+ touch-action: manipulation;
+ }
}
.hd-drag {
@@ -26,3 +98,52 @@
animation: none !important;
transition: none !important;
}
+
+/* 防止多选时选中卡片导致页面滚动 */
+.repository-card {
+ scroll-margin: 20px;
+}
+
+/* 多选模式下防止焦点导致的滚动 */
+.repository-card[data-selection-mode="true"] {
+ scroll-margin: 0;
+}
+
+/* 分类侧栏自定义滚动条样式 */
+.category-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
+}
+
+.category-scrollbar::-webkit-scrollbar {
+ width: 6px;
+}
+
+.category-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+ border-radius: 3px;
+}
+
+.category-scrollbar::-webkit-scrollbar-thumb {
+ background: rgba(156, 163, 175, 0.5);
+ border-radius: 3px;
+ transition: background 0.2s ease;
+}
+
+.category-scrollbar::-webkit-scrollbar-thumb:hover,
+.category-scrollbar.is-scrolling::-webkit-scrollbar-thumb {
+ background: rgba(156, 163, 175, 0.8);
+}
+
+.dark .category-scrollbar {
+ scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
+}
+
+.dark .category-scrollbar::-webkit-scrollbar-thumb {
+ background: rgba(75, 85, 99, 0.5);
+}
+
+.dark .category-scrollbar::-webkit-scrollbar-thumb:hover,
+.dark .category-scrollbar.is-scrolling::-webkit-scrollbar-thumb {
+ background: rgba(75, 85, 99, 0.8);
+}
diff --git a/src/main.tsx b/src/main.tsx
index 05077165..63508e76 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,22 +1,58 @@
+// Load polyfills first
+import './polyfills.ts';
+
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
+import { ErrorBoundary } from './components/ErrorBoundary.tsx';
console.log('Main.tsx loading...');
-const rootElement = document.getElementById('root');
-if (!rootElement) {
- throw new Error('Root element not found');
-}
+try {
+ const rootElement = document.getElementById('root');
+ if (!rootElement) {
+ throw new Error('Root element not found');
+ }
-console.log('Root element found, creating React root...');
+ console.log('Root element found, creating React root...');
-const root = createRoot(rootElement);
-root.render(
-
-
-
-);
+ const root = createRoot(rootElement);
+ root.render(
+
+
+
+
+
+ );
-console.log('React app rendered');
\ No newline at end of file
+ console.log('React app rendered');
+} catch (error) {
+ console.error('Failed to render React app:', error);
+ const strings = (() => {
+ const lang = navigator.language?.startsWith('zh') ? 'zh' : 'en';
+ return {
+ title: lang === 'zh' ? '应用加载失败' : 'Application Failed to Load',
+ desc: lang === 'zh'
+ ? '您的浏览器可能不支持运行此应用。请尝试使用最新版本的 Chrome、Firefox、Safari 或 Edge。'
+ : 'Your browser may not support running this app. Please try using the latest version of Chrome, Firefox, Safari, or Edge.',
+ button: lang === 'zh' ? '重新加载' : 'Reload',
+ };
+ })();
+ const fallback = document.getElementById('root') || (() => {
+ const el = document.createElement('div');
+ el.id = 'root';
+ document.body.appendChild(el);
+ return el;
+ })();
+ fallback.innerHTML = `
+
+
+
😵
+
${strings.title}
+
${strings.desc}
+
${strings.button}
+
+
+ `;
+}
diff --git a/src/polyfills.ts b/src/polyfills.ts
new file mode 100644
index 00000000..30a84bf1
--- /dev/null
+++ b/src/polyfills.ts
@@ -0,0 +1,68 @@
+// Polyfills for older browsers
+
+// Promise.allSettled polyfill
+if (!Promise.allSettled) {
+ Promise.allSettled = function(
+ promises: Array>
+ ): Promise>> {
+ return Promise.all(
+ promises.map((promise) =>
+ promise
+ .then((value) => ({ status: 'fulfilled' as const, value }))
+ .catch((reason) => ({ status: 'rejected' as const, reason }))
+ )
+ );
+ };
+}
+
+// Object.values polyfill
+if (!Object.values) {
+ Object.values = function(obj: Record): T[] {
+ return Object.keys(obj).map((key) => obj[key]);
+ };
+}
+
+// Object.entries polyfill
+if (!Object.entries) {
+ Object.entries = function(obj: Record): [string, T][] {
+ return Object.keys(obj).map((key) => [key, obj[key]]);
+ };
+}
+
+// Array.from polyfill for NodeList and other array-like objects
+if (!Array.from) {
+ Array.from = function(arrayLike: ArrayLike | Iterable): T[] {
+ const result: T[] = [];
+ // Check if it's array-like (has length property)
+ if ('length' in (arrayLike as ArrayLike)) {
+ const arr = arrayLike as ArrayLike;
+ for (let i = 0; i < arr.length; i++) {
+ result.push(arr[i]);
+ }
+ } else {
+ // It's an iterable
+ const iterator = (arrayLike as Iterable)[Symbol.iterator]();
+ let item = iterator.next();
+ while (!item.done) {
+ result.push(item.value);
+ item = iterator.next();
+ }
+ }
+ return result;
+ };
+}
+
+// CustomEvent polyfill for IE
+if (typeof window !== 'undefined' && !window.CustomEvent) {
+ window.CustomEvent = class CustomEvent extends Event {
+ detail: T;
+ constructor(type: string, eventInitDict?: { detail?: T; bubbles?: boolean; cancelable?: boolean }) {
+ super(type, eventInitDict);
+ this.detail = eventInitDict?.detail as T;
+ }
+ } as typeof window.CustomEvent;
+}
+
+if (import.meta.env.DEV) {
+ console.log('[polyfills] Polyfills loaded');
+}
diff --git a/src/services/aiAnalysisOptimizer.ts b/src/services/aiAnalysisOptimizer.ts
new file mode 100644
index 00000000..71b93478
--- /dev/null
+++ b/src/services/aiAnalysisOptimizer.ts
@@ -0,0 +1,477 @@
+import { Repository } from '../types';
+import { AIService } from './aiService';
+import { GitHubApiService } from './githubApi';
+import { backend } from './backendAdapter';
+
+export interface AnalysisTask {
+ repo: Repository;
+ readmeContent: string;
+ retries: number;
+ startTime?: number;
+}
+
+export interface AnalysisResult {
+ repo: Repository;
+ success: boolean;
+ summary?: string;
+ tags?: string[];
+ platforms?: string[];
+ category?: string;
+ error?: Error;
+ duration: number;
+}
+
+export interface OptimizerConfig {
+ initialConcurrency: number;
+ maxConcurrency: number;
+ minConcurrency: number;
+ targetResponseTime: number;
+ batchDelayMs: number;
+ maxRetries: number;
+ retryDelayBaseMs: number;
+ enableAdaptiveConcurrency: boolean;
+}
+
+const DEFAULT_CONFIG: OptimizerConfig = {
+ initialConcurrency: 3,
+ maxConcurrency: 10,
+ minConcurrency: 1,
+ targetResponseTime: 5000,
+ batchDelayMs: 100,
+ maxRetries: 3,
+ retryDelayBaseMs: 1000,
+ enableAdaptiveConcurrency: true,
+};
+
+export class AIAnalysisOptimizer {
+ private config: OptimizerConfig;
+ private currentConcurrency: number;
+ private responseTimes: number[] = [];
+ private aborted = false;
+ private paused = false;
+ private activeWorkers = 0;
+ private shouldExitWorkers = false;
+
+ constructor(config: Partial = {}) {
+ this.config = { ...DEFAULT_CONFIG, ...config };
+ this.currentConcurrency = this.config.initialConcurrency;
+ }
+
+ abort(): void {
+ this.aborted = true;
+ this.shouldExitWorkers = true;
+ }
+
+ pause(): void {
+ this.paused = true;
+ }
+
+ resume(): void {
+ this.paused = false;
+ }
+
+ isAborted(): boolean {
+ return this.aborted;
+ }
+
+ isPaused(): boolean {
+ return this.paused;
+ }
+
+ getCurrentConcurrency(): number {
+ return this.currentConcurrency;
+ }
+
+ private recordResponseTime(duration: number): void {
+ this.responseTimes.push(duration);
+ if (this.responseTimes.length > 20) {
+ this.responseTimes.shift();
+ }
+
+ if (this.config.enableAdaptiveConcurrency && this.responseTimes.length >= 5) {
+ this.adjustConcurrency();
+ }
+ }
+
+ private adjustConcurrency(): void {
+ const recentTimes = this.responseTimes.slice(-5);
+ const recentAvg = recentTimes.reduce((a, b) => a + b, 0) / recentTimes.length;
+
+ const oldConcurrency = this.currentConcurrency;
+
+ if (recentAvg > this.config.targetResponseTime * 1.5) {
+ this.currentConcurrency = Math.max(
+ this.config.minConcurrency,
+ Math.floor(this.currentConcurrency * 0.8)
+ );
+ } else if (recentAvg < this.config.targetResponseTime * 0.7 && this.currentConcurrency < this.config.maxConcurrency) {
+ this.currentConcurrency = Math.min(
+ this.config.maxConcurrency,
+ this.currentConcurrency + 1
+ );
+ }
+
+ // 如果并发数减少,通知 worker 退出
+ if (this.currentConcurrency < oldConcurrency) {
+ this.shouldExitWorkers = true;
+ }
+ }
+
+ private delay(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ private async waitWhilePaused(): Promise {
+ while (this.paused && !this.aborted) {
+ await this.delay(500);
+ }
+ }
+
+ private calculateRetryDelay(retryCount: number): number {
+ const jitter = Math.random() * 500;
+ return this.config.retryDelayBaseMs * Math.pow(2, retryCount) + jitter;
+ }
+
+ private async fetchReadme(repo: Repository, githubApi: GitHubApiService): Promise {
+ if (this.aborted) return '';
+ await this.waitWhilePaused();
+ if (this.aborted) return '';
+
+ try {
+ if (backend.isAvailable) {
+ const [owner, name] = repo.full_name.split('/');
+ return await backend.getRepositoryReadme(owner, name);
+ }
+ const [owner, name] = repo.full_name.split('/');
+ return await githubApi.getRepositoryReadme(owner, name);
+ } catch (error) {
+ console.warn(`Failed to fetch README for ${repo.full_name}:`, error);
+ return '';
+ }
+ }
+
+ async prefetchReadmes(
+ repos: Repository[],
+ githubApi: GitHubApiService,
+ onProgress?: (completed: number, total: number) => void
+ ): Promise> {
+ const readmeCache = new Map();
+ const concurrency = Math.min(10, repos.length);
+ const results = new Map();
+
+ const fetchReadme = async (repo: Repository): Promise => {
+ if (this.aborted) return;
+ await this.waitWhilePaused();
+ if (this.aborted) return;
+
+ try {
+ const content = await this.fetchReadme(repo, githubApi);
+ results.set(repo.id, { content });
+ } catch (error) {
+ results.set(repo.id, { content: '', error: error as Error });
+ }
+ };
+
+ for (let i = 0; i < repos.length; i += concurrency) {
+ if (this.aborted) break;
+
+ const batch = repos.slice(i, i + concurrency);
+ await Promise.all(batch.map(repo => fetchReadme(repo)));
+
+ if (onProgress) {
+ onProgress(Math.min(i + concurrency, repos.length), repos.length);
+ }
+
+ if (i + concurrency < repos.length && !this.aborted) {
+ await this.delay(100);
+ }
+ }
+
+ for (const [repoId, result] of results) {
+ readmeCache.set(repoId, result.content || '');
+ }
+
+ return readmeCache;
+ }
+
+ async analyzeWithRetry(
+ task: AnalysisTask,
+ aiService: AIService,
+ categoryNames: string[]
+ ): Promise {
+ const startTime = Date.now();
+ let lastError: Error | undefined;
+
+ for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
+ if (this.aborted) {
+ return {
+ repo: task.repo,
+ success: false,
+ error: new Error('Analysis aborted'),
+ duration: Date.now() - startTime,
+ };
+ }
+
+ await this.waitWhilePaused();
+
+ if (this.aborted) {
+ return {
+ repo: task.repo,
+ success: false,
+ error: new Error('Analysis aborted'),
+ duration: Date.now() - startTime,
+ };
+ }
+
+ try {
+ const analysisStart = Date.now();
+ const analysis = await aiService.analyzeRepository(task.repo, task.readmeContent, categoryNames);
+ const analysisDuration = Date.now() - analysisStart;
+
+ this.recordResponseTime(analysisDuration);
+
+ return {
+ repo: task.repo,
+ success: true,
+ summary: analysis.summary,
+ tags: analysis.tags,
+ platforms: analysis.platforms,
+ duration: Date.now() - startTime,
+ };
+ } catch (error) {
+ lastError = error as Error;
+
+ if (attempt < this.config.maxRetries) {
+ const delayMs = this.calculateRetryDelay(attempt);
+ await this.delay(delayMs);
+ }
+ }
+ }
+
+ return {
+ repo: task.repo,
+ success: false,
+ error: lastError,
+ duration: Date.now() - startTime,
+ };
+ }
+
+ async analyzeRepositories(
+ repos: Repository[],
+ readmeCache: Map,
+ aiService: AIService,
+ categoryNames: string[],
+ onProgress?: (completed: number, total: number, currentConcurrency: number) => void,
+ onResult?: (result: AnalysisResult) => void
+ ): Promise {
+ const results: AnalysisResult[] = [];
+ const pendingRepos = [...repos];
+ const completedCount = { value: 0 };
+ const total = repos.length;
+ const workerPromises: Promise[] = [];
+
+ const runTask = async (repo: Repository): Promise => {
+ const readmeContent = readmeCache.get(repo.id) || '';
+ const task: AnalysisTask = { repo, readmeContent, retries: 0 };
+ return this.analyzeWithRetry(task, aiService, categoryNames);
+ };
+
+ const worker = async (workerId: number): Promise => {
+ this.activeWorkers++;
+ try {
+ while (pendingRepos.length > 0) {
+ if (this.aborted) break;
+ if (this.shouldExitWorkers && workerId >= this.currentConcurrency) break;
+
+ await this.waitWhilePaused();
+ if (this.aborted) break;
+ if (this.shouldExitWorkers && workerId >= this.currentConcurrency) break;
+
+ const repo = pendingRepos.shift();
+ if (!repo) break;
+
+ try {
+ const result = await runTask(repo);
+ completedCount.value++;
+ results.push(result);
+
+ if (onResult) {
+ onResult(result);
+ }
+ if (onProgress) {
+ onProgress(completedCount.value, total, this.activeWorkers);
+ }
+ } catch {
+ completedCount.value++;
+ }
+ }
+ } finally {
+ this.activeWorkers--;
+ }
+ };
+
+ // 启动初始 worker
+ const initialWorkers = Math.min(this.currentConcurrency, repos.length);
+ for (let i = 0; i < initialWorkers; i++) {
+ workerPromises.push(worker(i));
+ }
+
+ // 监控并发调整并动态增减 worker
+ const concurrencyMonitor = async (): Promise => {
+ while (pendingRepos.length > 0 && !this.aborted) {
+ await this.delay(1000);
+
+ if (this.shouldExitWorkers) {
+ this.shouldExitWorkers = false;
+ continue;
+ }
+
+ // 如果并发数增加,启动新 worker
+ if (this.activeWorkers < this.currentConcurrency && workerPromises.length < this.currentConcurrency) {
+ const newWorkerId = workerPromises.length;
+ workerPromises.push(worker(newWorkerId));
+ }
+ }
+ };
+
+ workerPromises.push(concurrencyMonitor());
+ await Promise.all(workerPromises);
+
+ return results;
+ }
+
+ async analyzeRepositoriesPipelined(
+ repos: Repository[],
+ githubApi: GitHubApiService,
+ aiService: AIService,
+ categoryNames: string[],
+ onProgress?: (completed: number, total: number, currentConcurrency: number) => void,
+ onResult?: (result: AnalysisResult) => void
+ ): Promise {
+ const results: AnalysisResult[] = [];
+ const pendingRepos = [...repos];
+ const completedCount = { value: 0 };
+ const total = repos.length;
+ const workerPromises: Promise[] = [];
+
+ const readmeCache = new Map();
+ const readmeFetching = new Map>();
+
+ const prefetchReadme = async (repo: Repository): Promise => {
+ if (readmeCache.has(repo.id)) {
+ return readmeCache.get(repo.id)!;
+ }
+ if (readmeFetching.has(repo.id)) {
+ return readmeFetching.get(repo.id)!;
+ }
+
+ const promise = this.fetchReadme(repo, githubApi).then(content => {
+ readmeCache.set(repo.id, content);
+ readmeFetching.delete(repo.id);
+ return content;
+ }).catch(() => {
+ readmeCache.set(repo.id, '');
+ readmeFetching.delete(repo.id);
+ return '';
+ });
+
+ readmeFetching.set(repo.id, promise);
+ return promise;
+ };
+
+ const worker = async (workerId: number): Promise => {
+ this.activeWorkers++;
+ try {
+ while (pendingRepos.length > 0) {
+ if (this.aborted) break;
+ if (this.shouldExitWorkers && workerId >= this.currentConcurrency) break;
+
+ await this.waitWhilePaused();
+ if (this.aborted) break;
+ if (this.shouldExitWorkers && workerId >= this.currentConcurrency) break;
+
+ const repo = pendingRepos.shift();
+ if (!repo) break;
+
+ try {
+ const readmeContent = await prefetchReadme(repo);
+
+ // 动态计算预取数量
+ const readmePrefetchCount = Math.min(this.currentConcurrency * 2, pendingRepos.length);
+ if (pendingRepos.length > 0) {
+ for (let i = 0; i < Math.min(readmePrefetchCount, pendingRepos.length); i++) {
+ prefetchReadme(pendingRepos[i]);
+ }
+ }
+
+ const task: AnalysisTask = { repo, readmeContent, retries: 0 };
+ const result = await this.analyzeWithRetry(task, aiService, categoryNames);
+
+ completedCount.value++;
+ results.push(result);
+
+ if (onResult) {
+ onResult(result);
+ }
+ if (onProgress) {
+ onProgress(completedCount.value, total, this.activeWorkers);
+ }
+ } catch {
+ completedCount.value++;
+ }
+ }
+ } finally {
+ this.activeWorkers--;
+ }
+ };
+
+ // 启动初始 worker
+ const initialWorkers = Math.min(this.currentConcurrency, repos.length);
+ for (let i = 0; i < initialWorkers; i++) {
+ workerPromises.push(worker(i));
+ }
+
+ // 监控并发调整并动态增减 worker
+ const concurrencyMonitor = async (): Promise => {
+ while (pendingRepos.length > 0 && !this.aborted) {
+ await this.delay(1000);
+
+ if (this.shouldExitWorkers) {
+ this.shouldExitWorkers = false;
+ continue;
+ }
+
+ // 如果并发数增加,启动新 worker
+ if (this.activeWorkers < this.currentConcurrency && workerPromises.length < this.currentConcurrency) {
+ const newWorkerId = workerPromises.length;
+ workerPromises.push(worker(newWorkerId));
+ }
+ }
+ };
+
+ workerPromises.push(concurrencyMonitor());
+ await Promise.all(workerPromises);
+
+ return results;
+ }
+
+ getStats(): {
+ averageResponseTime: number;
+ currentConcurrency: number;
+ totalRequests: number;
+ } {
+ const avgTime = this.responseTimes.length > 0
+ ? this.responseTimes.reduce((a, b) => a + b, 0) / this.responseTimes.length
+ : 0;
+
+ return {
+ averageResponseTime: Math.round(avgTime),
+ currentConcurrency: this.currentConcurrency,
+ totalRequests: this.responseTimes.length,
+ };
+ }
+}
+
+export function createOptimizedAIAnalyzer(config?: Partial): AIAnalysisOptimizer {
+ return new AIAnalysisOptimizer(config);
+}
diff --git a/src/services/aiService.ts b/src/services/aiService.ts
index aefd107b..6ebbfd4b 100644
--- a/src/services/aiService.ts
+++ b/src/services/aiService.ts
@@ -1,6 +1,29 @@
import { Repository, AIConfig, AIApiType } from '../types';
import { backend } from './backendAdapter';
+interface OpenAIResponseContentPart {
+ text?: string;
+}
+
+interface OpenAIResponseOutputItem {
+ content?: OpenAIResponseContentPart[];
+}
+
+interface OpenAIResponseMessage {
+ content?: string;
+ reasoning_content?: string;
+}
+
+interface OpenAIResponseChoice {
+ message?: OpenAIResponseMessage;
+}
+
+interface OpenAIResponse {
+ output_text?: string;
+ output?: OpenAIResponseOutputItem[];
+ choices?: OpenAIResponseChoice[];
+}
+
export class AIService {
private config: AIConfig;
private language: string;
@@ -15,9 +38,7 @@ export class AIService {
}
private getOpenAIReasoningPayload(): { effort: 'none' | 'low' | 'medium' | 'high' | 'xhigh' } | undefined {
- const effort = this.config.reasoningEffort === 'minimal'
- ? 'low'
- : this.config.reasoningEffort;
+ const effort = this.config.reasoningEffort;
return effort ? { effort } : undefined;
}
@@ -87,9 +108,9 @@ export class AIService {
...(!isDeepSeekReasoner && reasoning ? { reasoning } : {}),
};
- let data: any;
+ let data: Record;
if (backend.isAvailable && this.config.id) {
- data = await backend.proxyAIRequest(this.config.id, requestBody);
+ data = await backend.proxyAIRequest(this.config.id, requestBody) as Record;
} else {
const url = this.buildApiUrl(apiType === 'openai-responses' ? 'v1/responses' : 'v1/chat/completions');
const response = await fetch(url, {
@@ -109,25 +130,26 @@ export class AIService {
}
if (apiType === 'openai-responses') {
- const outputText = typeof data?.output_text === 'string' ? data.output_text : '';
+ const typedData = data as OpenAIResponse;
+ const outputText = typedData.output_text;
if (outputText) return outputText;
- const output = data?.output;
+ const output = typedData.output;
if (Array.isArray(output)) {
const text = output
- .flatMap((item: any) => Array.isArray(item?.content) ? item.content : [])
- .map((part: any) => (typeof part?.text === 'string' ? part.text : ''))
+ .flatMap((item) => Array.isArray(item?.content) ? item.content : [])
+ .map((part) => part?.text || '')
.join('');
if (text) return text;
}
} else {
- const message = data?.choices?.[0]?.message;
- const content = typeof message?.content === 'string' ? message.content : '';
+ const typedData = data as { choices?: OpenAIResponseChoice[] };
+ const choices = typedData.choices;
+ const message = choices?.[0]?.message;
+ const content = message?.content;
if (content) return content;
- const reasoningContent = typeof message?.reasoning_content === 'string'
- ? message.reasoning_content
- : '';
+ const reasoningContent = message?.reasoning_content;
if (reasoningContent) return reasoningContent;
}
@@ -445,103 +467,6 @@ Focus on practicality and accurate categorization to help users quickly understa
}
}
- private fallbackAnalysis(repository: Repository): { summary: string; tags: string[]; platforms: string[] } {
- const summary = repository.description
- ? `${repository.description}(${repository.language || (this.language === 'zh' ? '未知语言' : 'Unknown language')}${this.language === 'zh' ? '项目' : ' project'})`
- : (this.language === 'zh'
- ? `一个${repository.language || '软件'}项目,拥有${repository.stargazers_count}个星标`
- : `A ${repository.language || 'software'} project with ${repository.stargazers_count} stars`
- );
-
- const tags: string[] = [];
- const platforms: string[] = [];
-
- // Add language-based tags and platforms
- if (repository.language) {
- const langMap: Record = this.language === 'zh' ? {
- 'JavaScript': { tag: 'Web应用', platforms: ['web', 'cli'] },
- 'TypeScript': { tag: 'Web应用', platforms: ['web', 'cli'] },
- 'Python': { tag: 'Python工具', platforms: ['linux', 'mac', 'windows', 'cli'] },
- 'Java': { tag: 'Java应用', platforms: ['linux', 'mac', 'windows'] },
- 'Go': { tag: '系统工具', platforms: ['linux', 'mac', 'windows', 'cli'] },
- 'Rust': { tag: '系统工具', platforms: ['linux', 'mac', 'windows', 'cli'] },
- 'C++': { tag: '系统软件', platforms: ['linux', 'mac', 'windows'] },
- 'C': { tag: '系统软件', platforms: ['linux', 'mac', 'windows'] },
- 'Swift': { tag: '移动应用', platforms: ['ios', 'mac'] },
- 'Kotlin': { tag: '移动应用', platforms: ['android'] },
- 'Dart': { tag: '移动应用', platforms: ['ios', 'android'] },
- 'PHP': { tag: 'Web应用', platforms: ['web', 'linux'] },
- 'Ruby': { tag: 'Web应用', platforms: ['web', 'linux', 'mac'] },
- 'Shell': { tag: '脚本工具', platforms: ['linux', 'mac', 'cli'] }
- } : {
- 'JavaScript': { tag: 'Web App', platforms: ['web', 'cli'] },
- 'TypeScript': { tag: 'Web App', platforms: ['web', 'cli'] },
- 'Python': { tag: 'Python Tool', platforms: ['linux', 'mac', 'windows', 'cli'] },
- 'Java': { tag: 'Java App', platforms: ['linux', 'mac', 'windows'] },
- 'Go': { tag: 'System Tool', platforms: ['linux', 'mac', 'windows', 'cli'] },
- 'Rust': { tag: 'System Tool', platforms: ['linux', 'mac', 'windows', 'cli'] },
- 'C++': { tag: 'System Software', platforms: ['linux', 'mac', 'windows'] },
- 'C': { tag: 'System Software', platforms: ['linux', 'mac', 'windows'] },
- 'Swift': { tag: 'Mobile App', platforms: ['ios', 'mac'] },
- 'Kotlin': { tag: 'Mobile App', platforms: ['android'] },
- 'Dart': { tag: 'Mobile App', platforms: ['ios', 'android'] },
- 'PHP': { tag: 'Web App', platforms: ['web', 'linux'] },
- 'Ruby': { tag: 'Web App', platforms: ['web', 'linux', 'mac'] },
- 'Shell': { tag: 'Script Tool', platforms: ['linux', 'mac', 'cli'] }
- };
-
- const langInfo = langMap[repository.language];
- if (langInfo) {
- tags.push(langInfo.tag);
- platforms.push(...langInfo.platforms);
- }
- }
-
- // Add category based on keywords
- const desc = (repository.description || '').toLowerCase();
- const name = repository.name.toLowerCase();
- const searchText = `${desc} ${name}`;
-
- const keywordMap = this.language === 'zh' ? {
- web: { keywords: ['web', 'frontend', 'website'], tag: 'Web应用', platforms: ['web'] },
- api: { keywords: ['api', 'backend', 'server'], tag: '后端服务', platforms: ['linux', 'docker'] },
- cli: { keywords: ['cli', 'command', 'tool'], tag: '命令行工具', platforms: ['cli', 'linux', 'mac', 'windows'] },
- library: { keywords: ['library', 'framework', 'sdk'], tag: '开发库', platforms: [] },
- mobile: { keywords: ['mobile', 'android', 'ios'], tag: '移动应用', platforms: [] },
- game: { keywords: ['game', 'gaming'], tag: '游戏', platforms: ['windows', 'mac', 'linux'] },
- ai: { keywords: ['ai', 'ml', 'machine learning'], tag: 'AI工具', platforms: ['linux', 'mac', 'windows'] },
- database: { keywords: ['database', 'db', 'storage'], tag: '数据库', platforms: ['linux', 'docker'] },
- docker: { keywords: ['docker', 'container'], tag: '容器化', platforms: ['docker'] }
- } : {
- web: { keywords: ['web', 'frontend', 'website'], tag: 'Web App', platforms: ['web'] },
- api: { keywords: ['api', 'backend', 'server'], tag: 'Backend Service', platforms: ['linux', 'docker'] },
- cli: { keywords: ['cli', 'command', 'tool'], tag: 'CLI Tool', platforms: ['cli', 'linux', 'mac', 'windows'] },
- library: { keywords: ['library', 'framework', 'sdk'], tag: 'Development Library', platforms: [] },
- mobile: { keywords: ['mobile', 'android', 'ios'], tag: 'Mobile App', platforms: [] },
- game: { keywords: ['game', 'gaming'], tag: 'Game', platforms: ['windows', 'mac', 'linux'] },
- ai: { keywords: ['ai', 'ml', 'machine learning'], tag: 'AI Tool', platforms: ['linux', 'mac', 'windows'] },
- database: { keywords: ['database', 'db', 'storage'], tag: 'Database', platforms: ['linux', 'docker'] },
- docker: { keywords: ['docker', 'container'], tag: 'Containerized', platforms: ['docker'] }
- };
-
- Object.values(keywordMap).forEach(({ keywords, tag, platforms: keywordPlatforms }) => {
- if (keywords.some(keyword => searchText.includes(keyword))) {
- tags.push(tag);
- platforms.push(...keywordPlatforms);
- }
- });
-
- // Handle specific mobile platforms
- if (searchText.includes('android')) platforms.push('android');
- if (searchText.includes('ios')) platforms.push('ios');
-
- return {
- summary: summary.substring(0, 50),
- tags: [...new Set(tags)].slice(0, 5), // Remove duplicates and limit to 5
- platforms: [...new Set(platforms)].slice(0, 8), // Remove duplicates and limit to 8
- };
- }
-
async testConnection(): Promise {
try {
const base = new URL(this.config.baseUrl);
@@ -563,7 +488,7 @@ Focus on practicality and accurate categorization to help users quickly understa
} finally {
clearTimeout(timeoutId);
}
- } catch (error) {
+ } catch {
return false;
}
}
@@ -718,74 +643,6 @@ Reply in JSON format:
}
}
- private createEnhancedSearchPrompt(query: string): string {
- if (this.language === 'zh') {
- return `
-用户搜索查询: "${query}"
-
-请深度分析这个搜索查询并提供:
-1. 核心搜索意图和目标
-2. 多语言关键词(中文、英文、技术术语)
-3. 相关的应用类型、技术栈、平台类型
-4. 同义词和相关概念
-5. 重要性权重(用于排序)
-
-以JSON格式回复:
-{
- "intent": "用户的核心搜索意图",
- "keywords": {
- "primary": ["主要关键词1", "primary keyword1"],
- "secondary": ["次要关键词1", "secondary keyword1"],
- "technical": ["技术术语1", "technical term1"]
- },
- "categories": ["应用分类1", "category1"],
- "platforms": ["平台类型1", "platform1"],
- "synonyms": ["同义词1", "synonym1"],
- "weights": {
- "name_match": 0.4,
- "description_match": 0.3,
- "tags_match": 0.2,
- "summary_match": 0.1
- }
-}
-
-注意:请确保能够跨语言匹配,即使用户用中文搜索,也要能匹配到英文仓库,反之亦然。
- `.trim();
- } else {
- return `
-User search query: "${query}"
-
-Please deeply analyze this search query and provide:
-1. Core search intent and objectives
-2. Multilingual keywords (Chinese, English, technical terms)
-3. Related application types, tech stacks, platform types
-4. Synonyms and related concepts
-5. Importance weights (for ranking)
-
-Reply in JSON format:
-{
- "intent": "User's core search intent",
- "keywords": {
- "primary": ["primary keyword1", "主要关键词1"],
- "secondary": ["secondary keyword1", "次要关键词1"],
- "technical": ["technical term1", "技术术语1"]
- },
- "categories": ["category1", "应用分类1"],
- "platforms": ["platform1", "平台类型1"],
- "synonyms": ["synonym1", "同义词1"],
- "weights": {
- "name_match": 0.4,
- "description_match": 0.3,
- "tags_match": 0.2,
- "summary_match": 0.1
- }
-}
-
-Note: Ensure cross-language matching, so Chinese queries can match English repositories and vice versa.
- `.trim();
- }
- }
-
private parseSearchResponse(content: string): string[] {
try {
const jsonMatch = content.match(/\{[\s\S]*\}/);
@@ -804,61 +661,6 @@ Note: Ensure cross-language matching, so Chinese queries can match English repos
return [];
}
- private parseEnhancedSearchResponse(content: string): {
- intent: string;
- keywords: {
- primary: string[];
- secondary: string[];
- technical: string[];
- };
- categories: string[];
- platforms: string[];
- synonyms: string[];
- weights: {
- name_match: number;
- description_match: number;
- tags_match: number;
- summary_match: number;
- };
- } {
- const defaultResponse = {
- intent: '',
- keywords: { primary: [], secondary: [], technical: [] },
- categories: [],
- platforms: [],
- synonyms: [],
- weights: { name_match: 0.4, description_match: 0.3, tags_match: 0.2, summary_match: 0.1 }
- };
-
- try {
- const jsonMatch = content.match(/\{[\s\S]*\}/);
- if (jsonMatch) {
- const parsed = JSON.parse(jsonMatch[0]);
- return {
- intent: parsed.intent || '',
- keywords: {
- primary: Array.isArray(parsed.keywords?.primary) ? parsed.keywords.primary : [],
- secondary: Array.isArray(parsed.keywords?.secondary) ? parsed.keywords.secondary : [],
- technical: Array.isArray(parsed.keywords?.technical) ? parsed.keywords.technical : []
- },
- categories: Array.isArray(parsed.categories) ? parsed.categories : [],
- platforms: Array.isArray(parsed.platforms) ? parsed.platforms : [],
- synonyms: Array.isArray(parsed.synonyms) ? parsed.synonyms : [],
- weights: {
- name_match: parsed.weights?.name_match || 0.4,
- description_match: parsed.weights?.description_match || 0.3,
- tags_match: parsed.weights?.tags_match || 0.2,
- summary_match: parsed.weights?.summary_match || 0.1
- }
- };
- }
- } catch (error) {
- console.warn('Failed to parse enhanced AI search response:', error);
- }
-
- return defaultResponse;
- }
-
private performEnhancedSearch(repositories: Repository[], originalQuery: string, aiTerms: string[]): Repository[] {
const allSearchTerms = [originalQuery, ...aiTerms];
@@ -884,145 +686,6 @@ Note: Ensure cross-language matching, so Chinese queries can match English repos
});
}
- private performSemanticSearchWithReranking(
- repositories: Repository[],
- originalQuery: string,
- searchAnalysis: any
- ): Repository[] {
- // Collect all search terms from the analysis
- const allSearchTerms = [
- originalQuery,
- ...searchAnalysis.keywords.primary,
- ...searchAnalysis.keywords.secondary,
- ...searchAnalysis.keywords.technical,
- ...searchAnalysis.categories,
- ...searchAnalysis.platforms,
- ...searchAnalysis.synonyms
- ].filter(term => term && typeof term === 'string');
-
- // First, filter repositories that match any search terms
- const matchedRepos = repositories.filter(repo => {
- const searchableFields = {
- name: repo.name.toLowerCase(),
- fullName: repo.full_name.toLowerCase(),
- description: (repo.description || '').toLowerCase(),
- language: (repo.language || '').toLowerCase(),
- topics: (repo.topics || []).join(' ').toLowerCase(),
- aiSummary: (repo.ai_summary || '').toLowerCase(),
- aiTags: (repo.ai_tags || []).join(' ').toLowerCase(),
- aiPlatforms: (repo.ai_platforms || []).join(' ').toLowerCase(),
- customDescription: (repo.custom_description || '').toLowerCase(),
- customTags: (repo.custom_tags || []).join(' ').toLowerCase()
- };
-
- // Check if any search term matches any field
- return allSearchTerms.some(term => {
- const normalizedTerm = term.toLowerCase();
- return Object.values(searchableFields).some(fieldValue => {
- return fieldValue.includes(normalizedTerm) ||
- // Fuzzy matching for partial matches
- normalizedTerm.split(/\s+/).every(word => fieldValue.includes(word));
- });
- });
- });
-
- // If no matches found, return empty array (don't show irrelevant results)
- if (matchedRepos.length === 0) {
- return [];
- }
-
- // Calculate relevance scores for matched repositories
- const scoredRepos = matchedRepos.map(repo => {
- let score = 0;
- const weights = searchAnalysis.weights;
-
- const searchableFields = {
- name: repo.name.toLowerCase(),
- fullName: repo.full_name.toLowerCase(),
- description: (repo.description || '').toLowerCase(),
- language: (repo.language || '').toLowerCase(),
- topics: (repo.topics || []).join(' ').toLowerCase(),
- aiSummary: (repo.ai_summary || '').toLowerCase(),
- aiTags: (repo.ai_tags || []).join(' ').toLowerCase(),
- aiPlatforms: (repo.ai_platforms || []).join(' ').toLowerCase(),
- customDescription: (repo.custom_description || '').toLowerCase(),
- customTags: (repo.custom_tags || []).join(' ').toLowerCase()
- };
-
- // Score based on different types of matches
- allSearchTerms.forEach(term => {
- const normalizedTerm = term.toLowerCase();
-
- // Name matches (highest weight)
- if (searchableFields.name.includes(normalizedTerm) || searchableFields.fullName.includes(normalizedTerm)) {
- score += weights.name_match;
- }
-
- // Description matches
- if (searchableFields.description.includes(normalizedTerm) || searchableFields.customDescription.includes(normalizedTerm)) {
- score += weights.description_match;
- }
-
- // Tags and topics matches
- if (searchableFields.topics.includes(normalizedTerm) ||
- searchableFields.aiTags.includes(normalizedTerm) ||
- searchableFields.customTags.includes(normalizedTerm)) {
- score += weights.tags_match;
- }
-
- // AI summary matches
- if (searchableFields.aiSummary.includes(normalizedTerm)) {
- score += weights.summary_match;
- }
-
- // Platform matches
- if (searchableFields.aiPlatforms.includes(normalizedTerm)) {
- score += weights.tags_match * 0.8; // Slightly lower than tags
- }
-
- // Language matches
- if (searchableFields.language.includes(normalizedTerm)) {
- score += weights.tags_match * 0.6;
- }
- });
-
- // Boost score for primary keywords
- searchAnalysis.keywords.primary.forEach(primaryTerm => {
- const normalizedTerm = primaryTerm.toLowerCase();
- Object.values(searchableFields).forEach(fieldValue => {
- if (fieldValue.includes(normalizedTerm)) {
- score += 0.2; // Additional boost for primary keywords
- }
- });
- });
-
- // Boost score for exact matches
- const exactMatch = allSearchTerms.some(term => {
- const normalizedTerm = term.toLowerCase();
- return searchableFields.name === normalizedTerm ||
- searchableFields.name.includes(` ${normalizedTerm} `) ||
- searchableFields.name.startsWith(`${normalizedTerm} `) ||
- searchableFields.name.endsWith(` ${normalizedTerm}`);
- });
-
- if (exactMatch) {
- score += 0.5;
- }
-
- // Consider repository popularity as a tie-breaker
- const popularityScore = Math.log10(repo.stargazers_count + 1) * 0.05;
- score += popularityScore;
-
- return { repo, score };
- });
-
- // Sort by relevance score (descending) and return only repositories with meaningful scores
- return scoredRepos
- .filter(item => item.score > 0.1) // Filter out very low relevance matches
- .sort((a, b) => b.score - a.score)
- .map(item => item.repo);
- }
-
private performBasicSearch(repositories: Repository[], query: string): Repository[] {
const normalizedQuery = query.toLowerCase();
diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts
index ee7b2dc7..cb10f5e9 100644
--- a/src/services/autoSync.ts
+++ b/src/services/autoSync.ts
@@ -26,7 +26,7 @@ let _pollTimer: ReturnType | null = null;
const POLL_INTERVAL = 5000;
// Last known backend data fingerprints — skip store update if unchanged
-let _lastHash = {
+const _lastHash = {
repos: '',
releases: '',
ai: '',
@@ -115,7 +115,10 @@ export async function syncFromBackend(): Promise {
}
// Only update store if backend data actually changed
- if (!Object.values(changed).some(Boolean)) return;
+ if (!Object.values(changed).some(Boolean)) {
+ _isSyncingFromBackendActive = false;
+ return;
+ }
_isSyncingFromBackend = true;
if (changed.repos || changed.releases) {
@@ -163,6 +166,18 @@ export async function syncFromBackend(): Promise {
}
}
}
+ if (Array.isArray(settings.categoryOrder)) {
+ useAppStore.setState({ categoryOrder: settings.categoryOrder.filter((id: unknown): id is string => typeof id === 'string') });
+ }
+ if (Array.isArray(settings.customCategories)) {
+ useAppStore.setState({ customCategories: settings.customCategories });
+ }
+ if (Array.isArray(settings.assetFilters)) {
+ useAppStore.setState({ assetFilters: settings.assetFilters });
+ }
+ if (typeof settings.collapsedSidebarCategoryCount === 'number' && settings.collapsedSidebarCategoryCount >= 1) {
+ useAppStore.setState({ collapsedSidebarCategoryCount: settings.collapsedSidebarCategoryCount });
+ }
_lastHash.settings = hashes.settings;
}
@@ -210,6 +225,10 @@ export async function syncToBackend(): Promise {
activeAIConfig: state.activeAIConfig,
activeWebDAVConfig: state.activeWebDAVConfig,
hiddenDefaultCategoryIds: state.hiddenDefaultCategoryIds,
+ categoryOrder: state.categoryOrder,
+ customCategories: state.customCategories,
+ assetFilters: state.assetFilters,
+ collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount,
}),
]);
const [reposSync, releasesSync, aiSync, webdavSync, settingsSync] = results;
@@ -233,6 +252,10 @@ export async function syncToBackend(): Promise {
activeAIConfig: state.activeAIConfig,
activeWebDAVConfig: state.activeWebDAVConfig,
hiddenDefaultCategoryIds: state.hiddenDefaultCategoryIds,
+ categoryOrder: state.categoryOrder,
+ customCategories: state.customCategories,
+ assetFilters: state.assetFilters,
+ collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount,
});
}
} catch (err) {
@@ -290,7 +313,12 @@ export function startAutoSync(): () => void {
state.aiConfigs !== prevState.aiConfigs ||
state.webdavConfigs !== prevState.webdavConfigs ||
state.activeAIConfig !== prevState.activeAIConfig ||
- state.activeWebDAVConfig !== prevState.activeWebDAVConfig;
+ state.activeWebDAVConfig !== prevState.activeWebDAVConfig ||
+ state.hiddenDefaultCategoryIds !== prevState.hiddenDefaultCategoryIds ||
+ state.categoryOrder !== prevState.categoryOrder ||
+ state.customCategories !== prevState.customCategories ||
+ state.assetFilters !== prevState.assetFilters ||
+ state.collapsedSidebarCategoryCount !== prevState.collapsedSidebarCategoryCount;
if (!changed) return;
diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts
index b1b2bd81..fddbaed3 100644
--- a/src/services/backendAdapter.ts
+++ b/src/services/backendAdapter.ts
@@ -179,7 +179,7 @@ class BackendAdapter {
method: 'POST',
headers: this.getAuthHeaders(),
body: JSON.stringify({ configId, body })
- });
+ }, 120000);
if (!res.ok) await this.throwTranslatedError(res, 'AI proxy error');
return res.json();
}
diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts
index db41add9..56e133f0 100644
--- a/src/services/githubApi.ts
+++ b/src/services/githubApi.ts
@@ -1,5 +1,18 @@
import { Repository, Release, GitHubUser } from '../types';
+interface GitHubStarredItem {
+ starred_at?: string;
+ repo?: Repository;
+ [key: string]: unknown;
+}
+
+interface GitHubRateLimitResponse {
+ rate: {
+ remaining: number;
+ reset: number;
+ };
+}
+
const GITHUB_API_BASE = 'https://api.github.com';
export class GitHubApiService {
@@ -9,9 +22,10 @@ export class GitHubApiService {
this.token = token;
}
- private async makeRequest(endpoint: string, options: RequestInit = {}): Promise {
+ private async makeRequest(endpoint: string, options: RequestInit = {}, signal?: AbortSignal): Promise {
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
...options,
+ signal,
headers: {
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/vnd.github.v3+json',
@@ -31,7 +45,7 @@ export class GitHubApiService {
// 如果是starred repositories的响应,需要处理特殊格式
if (endpoint.includes('/user/starred') && Array.isArray(data)) {
- return data.map((item: any) => {
+ return data.map((item: GitHubStarredItem) => {
// 如果使用了star+json格式,数据结构会不同
if (item.starred_at && item.repo) {
return {
@@ -83,14 +97,22 @@ export class GitHubApiService {
return allRepos;
}
- async getRepositoryReadme(owner: string, repo: string): Promise {
+ async getRepositoryReadme(owner: string, repo: string, signal?: AbortSignal): Promise {
try {
const response = await this.makeRequest<{ content: string; encoding: string }>(
- `/repos/${owner}/${repo}/readme`
+ `/repos/${owner}/${repo}/readme`,
+ undefined,
+ signal
);
-
+
if (response.encoding === 'base64') {
- return atob(response.content);
+ // 使用 TextDecoder 正确处理 UTF-8 编码,避免中文乱码
+ const binaryString = atob(response.content);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return new TextDecoder('utf-8').decode(bytes);
}
return response.content;
} catch (error) {
@@ -101,7 +123,7 @@ export class GitHubApiService {
async getRepositoryReleases(owner: string, repo: string, page = 1, perPage = 30): Promise {
try {
- const releases = await this.makeRequest(
+ const releases = await this.makeRequest(
`/repos/${owner}/${repo}/releases?page=${page}&per_page=${perPage}`
);
@@ -113,8 +135,10 @@ export class GitHubApiService {
published_at: release.published_at,
html_url: release.html_url,
assets: release.assets || [],
+ zipball_url: release.zipball_url,
+ tarball_url: release.tarball_url,
repository: {
- id: 0, // Will be set by caller
+ id: 0,
full_name: `${owner}/${repo}`,
name: repo,
},
@@ -157,9 +181,9 @@ export class GitHubApiService {
perPage = 10
): Promise