Initialization

This commit is contained in:
AmintaCCCP
2025-06-29 18:44:08 +08:00
parent f40c649ea9
commit a55367f92c
30 changed files with 10058 additions and 0 deletions

506
dist/assets/index-Dnv_KkUR.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-m15jYu--.css vendored Normal file

File diff suppressed because one or more lines are too long

19
dist/index.html vendored Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Stars Manager - AI-Powered Repository Management</title>
<meta name="description" content="Intelligent management of your GitHub starred repositories with AI-powered analysis and release tracking" />
<!-- Font Awesome CDN -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Material Icons CDN -->
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<script type="module" crossorigin src="/assets/index-Dnv_KkUR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-m15jYu--.css">
</head>
<body class="bg-gray-50 dark:bg-gray-900">
<div id="root"></div>
</body>
</html>

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

18
index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Stars Manager - AI-Powered Repository Management</title>
<meta name="description" content="Intelligent management of your GitHub starred repositories with AI-powered analysis and release tracking" />
<!-- Font Awesome CDN -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Material Icons CDN -->
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
</head>
<body class="bg-gray-50 dark:bg-gray-900">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4133
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "github-stars-manager",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.0",
"zustand": "^4.5.0",
"date-fns": "^3.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

75
src/App.tsx Normal file
View File

@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { LoginScreen } from './components/LoginScreen';
import { Header } from './components/Header';
import { SearchBar } from './components/SearchBar';
import { RepositoryList } from './components/RepositoryList';
import { CategorySidebar } from './components/CategorySidebar';
import { ReleaseTimeline } from './components/ReleaseTimeline';
import { SettingsPanel } from './components/SettingsPanel';
import { useAppStore } from './store/useAppStore';
function App() {
const {
isAuthenticated,
currentView,
theme,
searchResults,
repositories
} = useAppStore();
const [selectedCategory, setSelectedCategory] = useState('all');
// Apply theme to document
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [theme]);
// Show login screen if not authenticated
if (!isAuthenticated) {
return <LoginScreen />;
}
// Main application interface
const renderCurrentView = () => {
switch (currentView) {
case 'repositories':
return (
<div className="flex space-x-6">
<CategorySidebar
repositories={repositories}
selectedCategory={selectedCategory}
onCategorySelect={setSelectedCategory}
/>
<div className="flex-1 space-y-6">
<SearchBar />
<RepositoryList
repositories={searchResults.length > 0 ? searchResults : repositories}
selectedCategory={selectedCategory}
/>
</div>
</div>
);
case 'releases':
return <ReleaseTimeline />;
case 'settings':
return <SettingsPanel />;
default:
return null;
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{renderCurrentView()}
</main>
</div>
);
}
export default App;

View File

@@ -0,0 +1,194 @@
import React from 'react';
import {
Folder,
Code,
Globe,
Smartphone,
Database,
Shield,
Gamepad2,
Palette,
Bot,
Wrench,
BookOpen,
Zap,
Users,
BarChart3
} from 'lucide-react';
import { Repository } from '../types';
interface CategorySidebarProps {
repositories: Repository[];
selectedCategory: string;
onCategorySelect: (category: string) => void;
}
interface Category {
id: string;
name: string;
icon: React.ComponentType<any>;
keywords: string[];
}
const categories: Category[] = [
{
id: 'all',
name: '全部分类',
icon: Folder,
keywords: []
},
{
id: 'web',
name: 'Web应用',
icon: Globe,
keywords: ['web应用', 'web', 'website', 'frontend', 'react', 'vue', 'angular']
},
{
id: 'mobile',
name: '移动应用',
icon: Smartphone,
keywords: ['移动应用', 'mobile', 'android', 'ios', 'flutter', 'react-native']
},
{
id: 'desktop',
name: '桌面应用',
icon: Code,
keywords: ['桌面应用', 'desktop', 'electron', 'gui', 'qt', 'gtk']
},
{
id: 'database',
name: '数据库',
icon: Database,
keywords: ['数据库', 'database', 'sql', 'nosql', 'mongodb', 'mysql', 'postgresql']
},
{
id: 'ai',
name: 'AI/机器学习',
icon: Bot,
keywords: ['ai工具', 'ai', 'ml', 'machine learning', 'deep learning', 'neural']
},
{
id: 'devtools',
name: '开发工具',
icon: Wrench,
keywords: ['开发工具', 'tool', 'cli', 'build', 'deploy', 'debug', 'test', 'automation']
},
{
id: 'security',
name: '安全工具',
icon: Shield,
keywords: ['安全工具', 'security', 'encryption', 'auth', 'vulnerability']
},
{
id: 'game',
name: '游戏',
icon: Gamepad2,
keywords: ['游戏', 'game', 'gaming', 'unity', 'unreal', 'godot']
},
{
id: 'design',
name: '设计工具',
icon: Palette,
keywords: ['设计工具', 'design', 'ui', 'ux', 'graphics', 'image']
},
{
id: 'productivity',
name: '效率工具',
icon: Zap,
keywords: ['效率工具', 'productivity', 'note', 'todo', 'calendar', 'task']
},
{
id: 'education',
name: '教育学习',
icon: BookOpen,
keywords: ['教育学习', 'education', 'learning', 'tutorial', 'course']
},
{
id: 'social',
name: '社交网络',
icon: Users,
keywords: ['社交网络', 'social', 'chat', 'messaging', 'communication']
},
{
id: 'analytics',
name: '数据分析',
icon: BarChart3,
keywords: ['数据分析', 'analytics', 'data', 'visualization', 'chart']
}
];
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
repositories,
selectedCategory,
onCategorySelect
}) => {
// Calculate repository count for each category
const getCategoryCount = (category: Category) => {
if (category.id === 'all') return repositories.length;
return repositories.filter(repo => {
// 优先使用AI标签进行匹配
if (repo.ai_tags && repo.ai_tags.length > 0) {
return repo.ai_tags.some(tag =>
category.keywords.some(keyword =>
tag.toLowerCase().includes(keyword.toLowerCase()) ||
keyword.toLowerCase().includes(tag.toLowerCase())
)
);
}
// 如果没有AI标签使用传统方式匹配
const repoText = [
repo.name,
repo.description || '',
repo.language || '',
...(repo.topics || []),
repo.ai_summary || ''
].join(' ').toLowerCase();
return category.keywords.some(keyword =>
repoText.includes(keyword.toLowerCase())
);
}).length;
};
return (
<div className="w-64 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 h-fit sticky top-24">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
</h3>
<div className="space-y-1">
{categories.map(category => {
const count = getCategoryCount(category);
const Icon = category.icon;
const isSelected = selectedCategory === category.id;
return (
<button
key={category.id}
onClick={() => onCategorySelect(category.id)}
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-left transition-colors ${
isSelected
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center space-x-3">
<Icon className="w-4 h-4" />
<span className="text-sm font-medium">{category.name}</span>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
isSelected
? 'bg-blue-200 text-blue-800 dark:bg-blue-800 dark:text-blue-200'
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
}`}>
{count}
</span>
</button>
);
})}
</div>
</div>
);
};

208
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,208 @@
import React from 'react';
import { Star, Settings, Calendar, Search, Moon, Sun, LogOut, RefreshCw } from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
import { GitHubApiService } from '../services/githubApi';
export const Header: React.FC = () => {
const {
user,
theme,
currentView,
isLoading,
lastSync,
githubToken,
repositories,
setTheme,
setCurrentView,
setRepositories,
setReleases,
setLoading,
setLastSync,
logout,
} = useAppStore();
const handleSync = async () => {
if (!githubToken) {
alert('GitHub token not found. Please login again.');
return;
}
setLoading(true);
try {
const githubApi = new GitHubApiService(githubToken);
// 1. 获取所有starred仓库
console.log('Fetching starred repositories...');
const newRepositories = await githubApi.getAllStarredRepositories();
// 2. 合并现有仓库数据保留AI分析结果
const existingRepoMap = new Map(repositories.map(repo => [repo.id, repo]));
const mergedRepositories = newRepositories.map(newRepo => {
const existing = existingRepoMap.get(newRepo.id);
if (existing) {
// 保留AI分析结果更新其他信息
return {
...newRepo,
ai_summary: existing.ai_summary,
ai_tags: existing.ai_tags,
ai_platforms: existing.ai_platforms,
analyzed_at: existing.analyzed_at,
};
}
return newRepo;
});
setRepositories(mergedRepositories);
// 3. 获取Release信息
console.log('Fetching releases...');
const releases = await githubApi.getMultipleRepositoryReleases(mergedRepositories.slice(0, 20));
setReleases(releases);
setLastSync(new Date().toISOString());
console.log('Sync completed successfully');
// 显示同步结果
const newRepoCount = newRepositories.length - repositories.length;
if (newRepoCount > 0) {
alert(`同步完成!发现 ${newRepoCount} 个新仓库。`);
} else {
alert('同步完成!所有仓库都是最新的。');
}
} catch (error) {
console.error('Sync failed:', error);
if (error instanceof Error && error.message.includes('token')) {
alert('GitHub token 已过期或无效,请重新登录。');
logout();
} else {
alert('同步失败,请检查网络连接。');
}
} finally {
setLoading(false);
}
};
const formatLastSync = (timestamp: string | null) => {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours < 1) return 'Just now';
if (diffHours < 24) return `${diffHours}h ago`;
return date.toLocaleDateString();
};
return (
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo and Title */}
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-10 h-10 bg-blue-600 rounded-lg">
<Star className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
GitHub Stars Manager
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
AI-powered repository management
</p>
</div>
</div>
{/* Navigation */}
<nav className="hidden md:flex items-center space-x-1">
<button
onClick={() => setCurrentView('repositories')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
currentView === 'repositories'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<Search className="w-4 h-4 inline mr-2" />
Repositories ({repositories.length})
</button>
<button
onClick={() => setCurrentView('releases')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
currentView === 'releases'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<Calendar className="w-4 h-4 inline mr-2" />
Releases
</button>
<button
onClick={() => setCurrentView('settings')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
currentView === 'settings'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<Settings className="w-4 h-4 inline mr-2" />
Settings
</button>
</nav>
{/* User Actions */}
<div className="flex items-center space-x-3">
{/* Sync Status */}
<div className="hidden sm:flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<span>Last sync: {formatLastSync(lastSync)}</span>
<button
onClick={handleSync}
disabled={isLoading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
title="Sync repositories"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Theme Toggle */}
<button
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Toggle theme"
>
{theme === 'light' ? (
<Moon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
) : (
<Sun className="w-5 h-5 text-gray-700 dark:text-gray-300" />
)}
</button>
{/* User Profile */}
{user && (
<div className="flex items-center space-x-3">
<img
src={user.avatar_url}
alt={user.name || user.login}
className="w-8 h-8 rounded-full"
/>
<div className="hidden sm:block">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{user.name || user.login}
</p>
</div>
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Logout"
>
<LogOut className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
</div>
)}
</div>
</div>
</div>
</header>
);
};

View File

@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { Star, Github, Key, ArrowRight, AlertCircle } from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
import { GitHubApiService } from '../services/githubApi';
export const LoginScreen: React.FC = () => {
const [token, setToken] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { setUser, setGitHubToken, repositories, lastSync } = useAppStore();
const handleConnect = async () => {
if (!token.trim()) {
setError('Please enter a valid GitHub token');
return;
}
setIsLoading(true);
setError('');
try {
// Test the token by fetching user info
const githubApi = new GitHubApiService(token);
const user = await githubApi.getCurrentUser();
// If successful, save the token and user info
setGitHubToken(token);
setUser(user);
console.log('Successfully authenticated user:', user);
} catch (error) {
console.error('Authentication failed:', error);
setError(
error instanceof Error
? error.message
: 'Failed to authenticate. Please check your token.'
);
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading) {
handleConnect();
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-2xl mx-auto mb-4 shadow-lg">
<Star className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
GitHub Stars Manager
</h1>
<p className="text-gray-600 text-lg">
AI-powered repository management
</p>
</div>
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 p-8">
<div className="text-center mb-6">
<Github className="w-10 h-10 text-gray-700 mx-auto mb-3" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Connect with GitHub
</h2>
<p className="text-gray-600 text-sm">
Enter your GitHub personal access token to get started
</p>
</div>
{/* 显示缓存状态 */}
{repositories.length > 0 && lastSync && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center space-x-2 text-green-700">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm font-medium">
{repositories.length}
</span>
</div>
<p className="text-xs text-green-600 mt-1">
: {new Date(lastSync).toLocaleString()}
</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
GitHub Personal Access Token
</label>
<div className="relative">
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="password"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
value={token}
onChange={(e) => {
setToken(e.target.value);
setError(''); // Clear error when user types
}}
onKeyPress={handleKeyPress}
disabled={isLoading}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white text-gray-900 disabled:bg-gray-50 disabled:text-gray-500"
/>
</div>
</div>
{error && (
<div className="flex items-center space-x-2 p-3 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<button
onClick={handleConnect}
disabled={isLoading || !token.trim()}
className="w-full flex items-center justify-center space-x-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Connecting...</span>
</>
) : (
<>
<span>Connect to GitHub</span>
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
</div>
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<h3 className="font-medium text-gray-900 mb-2 text-sm">
How to create a GitHub token:
</h3>
<ol className="text-xs text-gray-600 space-y-1">
<li>1. Go to GitHub Settings Developer settings Personal access tokens</li>
<li>2. Click "Generate new token (classic)"</li>
<li>3. Select scopes: <strong>repo</strong> and <strong>user</strong></li>
<li>4. Copy the generated token and paste it above</li>
</ol>
<div className="mt-3">
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 text-sm font-medium hover:underline"
>
Create token on GitHub
</a>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,804 @@
import React, { useState, useMemo } from 'react';
import { ExternalLink, GitBranch, Calendar, Package, Bell, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Eye, EyeOff } from 'lucide-react';
import { Release } from '../types';
import { useAppStore } from '../store/useAppStore';
import { GitHubApiService } from '../services/githubApi';
import { formatDistanceToNow, format } from 'date-fns';
export const ReleaseTimeline: React.FC = () => {
const {
releases,
repositories,
releaseSubscriptions,
githubToken,
language,
setReleases,
addReleases,
} = useAppStore();
const [searchQuery, setSearchQuery] = useState('');
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [lastRefreshTime, setLastRefreshTime] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(100);
const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact');
// Enhanced platform detection based on the userscript
const detectPlatforms = (filename: string): string[] => {
const name = filename.toLowerCase();
const platforms: string[] = [];
// Platform detection rules based on the userscript
const platformRules = {
windows: [
'.exe', '.msi', '.zip', '.7z',
'windows', 'win32', 'win64', 'win-x64', 'win-x86', 'win-arm64',
'-win.', '.win.', '-windows.', '.windows.',
'setup', 'installer'
],
macos: [
'.dmg', '.pkg', '.app.zip',
'darwin', 'macos', 'mac-os', 'osx', 'mac-universal',
'-mac.', '.mac.', '-macos.', '.macos.', '-darwin.', '.darwin.',
'universal', 'x86_64-apple', 'arm64-apple'
],
linux: [
'.deb', '.rpm', '.tar.gz', '.tar.xz', '.tar.bz2', '.appimage',
'linux', 'ubuntu', 'debian', 'fedora', 'centos', 'arch', 'alpine',
'-linux.', '.linux.', 'x86_64-unknown-linux', 'aarch64-unknown-linux',
'musl', 'gnu'
],
android: [
'.apk', '.aab',
'android', '-android.', '.android.',
'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'
],
ios: [
'.ipa',
'ios', '-ios.', '.ios.',
'iphone', 'ipad'
]
};
// Check each platform
Object.entries(platformRules).forEach(([platform, keywords]) => {
if (keywords.some(keyword => name.includes(keyword))) {
platforms.push(platform);
}
});
// Special handling for universal files
if (platforms.length === 0) {
// Check for source code or universal packages
if (name.includes('source') || name.includes('src') ||
name.includes('universal') || name.includes('all') ||
name.match(/\.(zip|tar\.gz|tar\.xz)$/) && !name.includes('win') && !name.includes('mac') && !name.includes('linux')) {
platforms.push('universal');
}
}
return platforms.length > 0 ? platforms : ['universal'];
};
const getDownloadLinks = (release: Release) => {
// Extract download links from release body
const downloadRegex = /\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g;
const links: Array<{ name: string; url: string; platforms: string[] }> = [];
let match;
while ((match = downloadRegex.exec(release.body)) !== null) {
const [, name, url] = match;
// Only include actual download links (not documentation, etc.)
if (url.includes('/download/') || url.includes('/releases/') ||
name.toLowerCase().includes('download') ||
/\.(exe|dmg|deb|rpm|apk|ipa|zip|tar\.gz|msi|pkg|appimage)$/i.test(url)) {
const platforms = detectPlatforms(name + ' ' + url);
links.push({ name, url, platforms });
}
}
// Also check for GitHub release assets pattern
const assetRegex = /https:\/\/github\.com\/[^\/]+\/[^\/]+\/releases\/download\/[^\/]+\/([^\s\)]+)/g;
while ((match = assetRegex.exec(release.body)) !== null) {
const [url, filename] = match;
const platforms = detectPlatforms(filename);
// Avoid duplicates
if (!links.some(link => link.url === url)) {
links.push({ name: filename, url, platforms });
}
}
return links;
};
// Filter releases for subscribed repositories
const subscribedReleases = releases.filter(release =>
releaseSubscriptions.has(release.repository.id)
);
// Apply search and platform filters
const filteredReleases = useMemo(() => {
let filtered = subscribedReleases;
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(release =>
release.repository.name.toLowerCase().includes(query) ||
release.repository.full_name.toLowerCase().includes(query) ||
release.tag_name.toLowerCase().includes(query) ||
release.name.toLowerCase().includes(query) ||
release.body.toLowerCase().includes(query)
);
}
// Platform filter
if (selectedPlatforms.length > 0) {
filtered = filtered.filter(release => {
const downloadLinks = getDownloadLinks(release);
return downloadLinks.some(link =>
selectedPlatforms.some(platform => link.platforms.includes(platform))
);
});
}
return filtered.sort((a, b) =>
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
);
}, [subscribedReleases, searchQuery, selectedPlatforms]);
// Pagination
const totalPages = Math.ceil(filteredReleases.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedReleases = filteredReleases.slice(startIndex, startIndex + itemsPerPage);
// Get available platforms from all releases
const availablePlatforms = useMemo(() => {
const platforms = new Set<string>();
subscribedReleases.forEach(release => {
const downloadLinks = getDownloadLinks(release);
downloadLinks.forEach(link => {
link.platforms.forEach(platform => platforms.add(platform));
});
});
return Array.from(platforms).sort();
}, [subscribedReleases]);
const handleRefresh = async () => {
if (!githubToken) {
alert(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.');
return;
}
setIsRefreshing(true);
try {
const githubApi = new GitHubApiService(githubToken);
const subscribedRepos = repositories.filter(repo => releaseSubscriptions.has(repo.id));
if (subscribedRepos.length === 0) {
alert(language === 'zh' ? '没有订阅的仓库。' : 'No subscribed repositories.');
return;
}
let newReleasesCount = 0;
const allNewReleases: Release[] = [];
// 获取最新的release时间戳
const latestReleaseTime = releases.length > 0
? Math.max(...releases.map(r => new Date(r.published_at).getTime()))
: 0;
const sinceTimestamp = latestReleaseTime > 0 ? new Date(latestReleaseTime).toISOString() : undefined;
for (const repo of subscribedRepos) {
const [owner, name] = repo.full_name.split('/');
// 检查这个仓库是否是新订阅的没有任何release记录
const hasExistingReleases = releases.some(r => r.repository.id === repo.id);
let repoReleases: Release[];
if (!hasExistingReleases) {
// 新订阅的仓库获取全部releases
repoReleases = await githubApi.getRepositoryReleases(owner, name, 1, 10);
} else {
// 已有记录的仓库,增量更新
repoReleases = await githubApi.getIncrementalRepositoryReleases(owner, name, sinceTimestamp, 10);
}
// 设置repository信息
repoReleases.forEach(release => {
release.repository.id = repo.id;
});
allNewReleases.push(...repoReleases);
newReleasesCount += repoReleases.length;
// Rate limiting protection
await new Promise(resolve => setTimeout(resolve, 200));
}
if (allNewReleases.length > 0) {
addReleases(allNewReleases);
}
const now = new Date().toISOString();
setLastRefreshTime(now);
const message = language === 'zh'
? `刷新完成!发现 ${newReleasesCount} 个新Release。`
: `Refresh completed! Found ${newReleasesCount} new releases.`;
alert(message);
} catch (error) {
console.error('Refresh failed:', error);
const errorMessage = language === 'zh'
? 'Release刷新失败请检查网络连接。'
: 'Release refresh failed. Please check your network connection.';
alert(errorMessage);
} finally {
setIsRefreshing(false);
}
};
const handlePlatformToggle = (platform: string) => {
setSelectedPlatforms(prev =>
prev.includes(platform)
? prev.filter(p => p !== platform)
: [...prev, platform]
);
setCurrentPage(1); // Reset to first page when filtering
};
const clearFilters = () => {
setSearchQuery('');
setSelectedPlatforms([]);
setCurrentPage(1);
};
const handlePageChange = (page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};
const getPageNumbers = () => {
const delta = 2;
const range = [];
const rangeWithDots = [];
for (let i = Math.max(2, currentPage - delta); i <= Math.min(totalPages - 1, currentPage + delta); i++) {
range.push(i);
}
if (currentPage - delta > 2) {
rangeWithDots.push(1, '...');
} else {
rangeWithDots.push(1);
}
rangeWithDots.push(...range);
if (currentPage + delta < totalPages - 1) {
rangeWithDots.push('...', totalPages);
} else if (totalPages > 1) {
rangeWithDots.push(totalPages);
}
return rangeWithDots;
};
const getPlatformIcon = (platform: string) => {
const iconMap: Record<string, string> = {
windows: 'fab fa-windows',
macos: 'fab fa-apple',
linux: 'fab fa-linux',
android: 'fab fa-android',
ios: 'fab fa-apple',
universal: 'fas fa-download'
};
return iconMap[platform] || 'fas fa-download';
};
const getPlatformColor = (platform: string) => {
const colorMap: Record<string, string> = {
windows: 'text-blue-600 dark:text-blue-400',
macos: 'text-gray-600 dark:text-gray-400',
linux: 'text-yellow-600 dark:text-yellow-400',
android: 'text-green-600 dark:text-green-400',
ios: 'text-gray-600 dark:text-gray-400',
universal: 'text-purple-600 dark:text-purple-400'
};
return colorMap[platform] || 'text-gray-600 dark:text-gray-400';
};
const truncateBody = (body: string, maxLength = 200) => {
if (body.length <= maxLength) return body;
return body.substring(0, maxLength) + '...';
};
const t = (zh: string, en: string) => language === 'zh' ? zh : en;
if (subscribedReleases.length === 0) {
const subscribedRepoCount = releaseSubscriptions.size;
return (
<div className="text-center py-12">
<Package className="w-16 h-16 text-gray-400 dark:text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{subscribedRepoCount === 0 ? t('没有Release订阅', 'No Release Subscriptions') : t('没有最近的Release', 'No Recent Releases')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
{subscribedRepoCount === 0
? t('从仓库页面订阅仓库Release以在此查看更新。', 'Subscribe to repository releases from the Repositories tab to see updates here.')
: t(`您已订阅 ${subscribedRepoCount} 个仓库但没有找到最近的Release。尝试同步以获取最新更新。`, `You're subscribed to ${subscribedRepoCount} repositories, but no recent releases were found. Try syncing to get the latest updates.`)
}
</p>
{subscribedRepoCount === 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 max-w-md mx-auto">
<div className="flex items-center space-x-2 text-blue-700 dark:text-blue-300">
<Bell className="w-5 h-5" />
<span className="font-medium">{t('如何订阅:', 'How to subscribe:')}</span>
</div>
<p className="text-sm text-blue-600 dark:text-blue-400 mt-2">
{t('转到仓库页面点击任何仓库卡片上的铃铛图标以订阅其Release。', 'Go to the Repositories tab and click the bell icon on any repository card to subscribe to its releases.')}
</p>
</div>
)}
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{t('Release时间线', 'Release Timeline')}
</h2>
<p className="text-gray-600 dark:text-gray-400">
{t(`来自您的 ${releaseSubscriptions.size} 个订阅仓库的最新Release`, `Latest releases from your ${releaseSubscriptions.size} subscribed repositories`)}
</p>
</div>
<div className="flex items-center space-x-3">
{/* View Mode Toggle */}
<div className="flex items-center space-x-2">
<button
onClick={() => setViewMode('compact')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'compact'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title={t('精简视图', 'Compact View')}
>
<EyeOff className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('detailed')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'detailed'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title={t('详细视图', 'Detailed View')}
>
<Eye className="w-4 h-4" />
</button>
</div>
{/* Last Refresh Time */}
{lastRefreshTime && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{t('上次刷新:', 'Last refresh:')} {formatDistanceToNow(new Date(lastRefreshTime), { addSuffix: true })}
</span>
)}
{/* Refresh Button */}
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>{isRefreshing ? t('刷新中...', 'Refreshing...') : t('刷新', 'Refresh')}</span>
</button>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6">
{/* Search Bar */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder={t('搜索Release...', 'Search releases...')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{searchQuery && (
<button
onClick={() => {
setSearchQuery('');
setCurrentPage(1);
}}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Platform Filters */}
{availablePlatforms.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 mr-2">
{t('平台:', 'Platforms:')}
</span>
{availablePlatforms.map(platform => (
<button
key={platform}
onClick={() => handlePlatformToggle(platform)}
className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
selectedPlatforms.includes(platform)
? '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'
}`}
>
<i className={`${getPlatformIcon(platform)} w-4 h-4`}></i>
<span className="capitalize">{platform}</span>
</button>
))}
{(searchQuery || selectedPlatforms.length > 0) && (
<button
onClick={clearFilters}
className="flex items-center space-x-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
<X className="w-4 h-4" />
<span>{t('清除', 'Clear')}</span>
</button>
)}
</div>
)}
</div>
{/* Results Info and Pagination Controls */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600 dark:text-gray-400">
{t(
`显示 ${startIndex + 1}-${Math.min(startIndex + itemsPerPage, filteredReleases.length)}${filteredReleases.length} 个Release`,
`Showing ${startIndex + 1}-${Math.min(startIndex + itemsPerPage, filteredReleases.length)} of ${filteredReleases.length} releases`
)}
</span>
{(searchQuery || selectedPlatforms.length > 0) && (
<span className="text-sm text-blue-600 dark:text-blue-400">
({t('已筛选', 'filtered')})
</span>
)}
</div>
<div className="flex items-center space-x-4">
{/* Items per page selector */}
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600 dark:text-gray-400">{t('每页:', 'Per page:')}</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
<option value={500}>500</option>
</select>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center space-x-1">
<button
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="p-2 rounded-lg 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-50 disabled:cursor-not-allowed"
>
<ChevronsLeft className="w-4 h-4" />
</button>
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="p-2 rounded-lg 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-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
{getPageNumbers().map((page, index) => (
<button
key={index}
onClick={() => typeof page === 'number' ? handlePageChange(page) : undefined}
disabled={typeof page !== 'number'}
className={`px-3 py-2 rounded-lg text-sm ${
page === currentPage
? 'bg-blue-600 text-white'
: typeof page === 'number'
? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
: 'text-gray-400 cursor-default'
}`}
>
{page}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-2 rounded-lg 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-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className="p-2 rounded-lg 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-50 disabled:cursor-not-allowed"
>
<ChevronsRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
</div>
{/* Releases List */}
<div className="space-y-4">
{paginatedReleases.map(release => {
const downloadLinks = getDownloadLinks(release);
return (
<div
key={release.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow"
>
{viewMode === 'detailed' ? (
// Detailed View
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<div className="flex items-center justify-center w-10 h-10 bg-green-100 dark:bg-green-900 rounded-lg flex-shrink-0">
<GitBranch className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div className="min-w-0 flex-1">
<h4 className="font-semibold text-gray-900 dark:text-white truncate">
{release.repository.name} {release.tag_name}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{release.repository.full_name}
</p>
</div>
</div>
<div className="flex items-center space-x-2 flex-shrink-0">
<span className="text-sm text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(release.published_at), { addSuffix: true })}
</span>
<a
href={release.html_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={t('在GitHub上查看', 'View on GitHub')}
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
{release.name && release.name !== release.tag_name && (
<h5 className="font-medium text-gray-800 dark:text-gray-200 mb-2">
{release.name}
</h5>
)}
{/* Download Links */}
{downloadLinks.length > 0 && (
<div className="mb-4">
<h6 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('下载:', 'Downloads:')}
</h6>
<div className="flex flex-wrap gap-2">
{downloadLinks.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors text-sm"
title={link.name}
>
<div className="flex items-center space-x-1">
{link.platforms.map((platform, pIndex) => (
<i
key={pIndex}
className={`${getPlatformIcon(platform)} w-4 h-4 ${getPlatformColor(platform)}`}
title={platform}
></i>
))}
</div>
<span className="truncate max-w-32">{link.name}</span>
</a>
))}
</div>
</div>
)}
{release.body && (
<div className="prose prose-sm dark:prose-invert max-w-none">
<div className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{truncateBody(release.body)}
</div>
{release.body.length > 200 && (
<a
href={release.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline text-sm mt-2 inline-block"
>
{t('阅读完整Release说明 →', 'Read full release notes →')}
</a>
)}
</div>
)}
</div>
) : (
// Compact View - Table-like layout
<div className="p-4">
<div className="grid grid-cols-12 gap-4 items-center">
{/* Repository and Version - 缩小列宽 */}
<div className="col-span-3 min-w-0">
<div className="flex items-center space-x-2">
<div className="w-6 h-6 bg-green-100 dark:bg-green-900 rounded flex items-center justify-center flex-shrink-0">
<GitBranch className="w-3 h-3 text-green-600 dark:text-green-400" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-900 dark:text-white text-sm truncate">
{release.repository.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{release.tag_name}
</p>
</div>
</div>
</div>
{/* Release Name */}
<div className="col-span-3 min-w-0">
<p className="text-sm text-gray-900 dark:text-white truncate" title={release.name || release.tag_name}>
{release.name || release.tag_name}
</p>
</div>
{/* Download Links - 横向排列,可换行 */}
<div className="col-span-4 min-w-0">
{downloadLinks.length > 0 ? (
<div className="flex flex-wrap gap-1">
{downloadLinks.slice(0, 6).map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={`${link.name} (${link.platforms.join(', ')})`}
>
<div className="flex items-center space-x-0.5">
{link.platforms.map((platform, pIndex) => (
<i
key={pIndex}
className={`${getPlatformIcon(platform)} w-3 h-3 ${getPlatformColor(platform)}`}
title={platform}
></i>
))}
</div>
<span className="text-xs text-gray-700 dark:text-gray-300 truncate max-w-16">
{link.name.split('.').pop() || link.name}
</span>
</a>
))}
{downloadLinks.length > 6 && (
<span className="text-xs text-gray-500 dark:text-gray-400 px-2 py-1">
+{downloadLinks.length - 6}
</span>
)}
</div>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500">
{t('无下载', 'No downloads')}
</span>
)}
</div>
{/* Time and Actions */}
<div className="col-span-2 flex items-center justify-end space-x-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(release.published_at), { addSuffix: true })}
</span>
<a
href={release.html_url}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={t('在GitHub上查看', 'View on GitHub')}
>
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
{/* Bottom Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center mt-8">
<div className="flex items-center space-x-1">
<button
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="p-2 rounded-lg 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-50 disabled:cursor-not-allowed"
>
<ChevronsLeft className="w-4 h-4" />
</button>
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="p-2 rounded-lg 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-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
{getPageNumbers().map((page, index) => (
<button
key={index}
onClick={() => typeof page === 'number' ? handlePageChange(page) : undefined}
disabled={typeof page !== 'number'}
className={`px-3 py-2 rounded-lg text-sm ${
page === currentPage
? 'bg-blue-600 text-white'
: typeof page === 'number'
? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
: 'text-gray-400 cursor-default'
}`}
>
{page}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-2 rounded-lg 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-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className="p-2 rounded-lg 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-50 disabled:cursor-not-allowed"
>
<ChevronsRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,300 @@
import React from 'react';
import { Star, GitFork, Eye, ExternalLink, Calendar, Tag, Bell, BellOff, Bot, Monitor, Smartphone, Globe, Terminal, Package } from 'lucide-react';
import { Repository } from '../types';
import { useAppStore } from '../store/useAppStore';
import { GitHubApiService } from '../services/githubApi';
import { AIService } from '../services/aiService';
import { formatDistanceToNow } from 'date-fns';
interface RepositoryCardProps {
repository: Repository;
showAISummary?: boolean;
}
export const RepositoryCard: React.FC<RepositoryCardProps> = ({
repository,
showAISummary = true
}) => {
const {
releaseSubscriptions,
toggleReleaseSubscription,
updateRepository,
githubToken,
aiConfigs,
activeAIConfig,
isLoading,
setLoading,
language
} = useAppStore();
const isSubscribed = releaseSubscriptions.has(repository.id);
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};
const getLanguageColor = (language: string | null) => {
const colors = {
JavaScript: '#f1e05a',
TypeScript: '#3178c6',
Python: '#3572A5',
Java: '#b07219',
'C++': '#f34b7d',
C: '#555555',
'C#': '#239120',
Go: '#00ADD8',
Rust: '#dea584',
PHP: '#4F5D95',
Ruby: '#701516',
Swift: '#fa7343',
Kotlin: '#A97BFF',
Dart: '#00B4AB',
Shell: '#89e051',
HTML: '#e34c26',
CSS: '#1572B6',
Vue: '#4FC08D',
React: '#61DAFB',
};
return colors[language as keyof typeof colors] || '#6b7280';
};
const getPlatformIcon = (platform: string) => {
const iconMap: Record<string, string> = {
mac: 'fab fa-apple',
macos: 'fab fa-apple',
windows: 'fab fa-windows',
win: 'fab fa-windows',
linux: 'fab fa-linux',
ios: 'fab fa-apple',
android: 'fab fa-android',
web: 'fas fa-globe',
cli: 'fas fa-terminal',
docker: 'fab fa-docker',
};
return iconMap[platform.toLowerCase()] || 'fas fa-desktop';
};
const handleAIAnalyze = async () => {
if (!githubToken) {
alert('GitHub token not found. Please login again.');
return;
}
const activeConfig = aiConfigs.find(config => config.id === activeAIConfig);
if (!activeConfig) {
alert('请先在设置中配置AI服务。');
return;
}
setLoading(true);
try {
const githubApi = new GitHubApiService(githubToken);
const aiService = new AIService(activeConfig, language);
// 获取README内容
const [owner, name] = repository.full_name.split('/');
const readmeContent = await githubApi.getRepositoryReadme(owner, name);
// AI分析
const analysis = await aiService.analyzeRepository(repository, readmeContent);
// 更新仓库信息
const updatedRepo = {
...repository,
ai_summary: analysis.summary,
ai_tags: analysis.tags,
ai_platforms: analysis.platforms,
analyzed_at: new Date().toISOString()
};
updateRepository(updatedRepo);
alert('AI分析完成');
} catch (error) {
console.error('AI analysis failed:', error);
alert('AI分析失败请检查AI配置和网络连接。');
} finally {
setLoading(false);
}
};
// 根据切换状态决定显示的内容
const getDisplayContent = () => {
if (showAISummary && repository.ai_summary) {
return {
content: repository.ai_summary,
isAI: true
};
} else if (repository.description) {
return {
content: repository.description,
isAI: false
};
} else {
return {
content: language === 'zh' ? '暂无描述' : 'No description available',
isAI: false
};
}
};
const displayContent = getDisplayContent();
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-all duration-200 hover:border-blue-300 dark:hover:border-blue-600 animate-slide-up flex flex-col h-full">
{/* Header - Repository Info */}
<div className="flex items-center space-x-3 mb-3">
<img
src={repository.owner.avatar_url}
alt={repository.owner.login}
className="w-8 h-8 rounded-full flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{repository.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{repository.owner.login}
</p>
</div>
</div>
{/* Action Buttons Row - Left and Right Aligned */}
<div className="flex items-center justify-between mb-4">
{/* Left side: AI Analysis and Release Subscription */}
<div className="flex items-center space-x-2">
<button
onClick={handleAIAnalyze}
disabled={isLoading}
className={`p-2 rounded-lg transition-colors ${
repository.analyzed_at
? 'bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400'
: '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-50`}
title={repository.analyzed_at ? (language === 'zh' ? '已AI分析' : 'AI Analyzed') : (language === 'zh' ? 'AI分析此仓库' : 'Analyze with AI')}
>
<Bot className="w-4 h-4" />
</button>
<button
onClick={() => toggleReleaseSubscription(repository.id)}
className={`p-2 rounded-lg transition-colors ${
isSubscribed
? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title={isSubscribed ? 'Unsubscribe from releases' : 'Subscribe to releases'}
>
{isSubscribed ? <Bell className="w-4 h-4" /> : <BellOff className="w-4 h-4" />}
</button>
</div>
{/* Right side: GitHub Link - Fixed square container */}
<div>
<a
href={repository.html_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="View on GitHub"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
{/* Description */}
<div className="mb-4 flex-1">
<p className="text-gray-700 dark:text-gray-300 text-sm leading-relaxed line-clamp-3 mb-2">
{displayContent.content}
</p>
{displayContent.isAI && (
<div className="flex items-center space-x-1 text-xs text-green-600 dark:text-green-400">
<Bot className="w-3 h-3" />
<span>{language === 'zh' ? 'AI总结' : 'AI Summary'}</span>
</div>
)}
</div>
{/* Tags */}
{(repository.ai_tags?.length || repository.topics?.length) && (
<div className="flex flex-wrap gap-2 mb-4">
{repository.ai_tags?.slice(0, 3).map((tag, index) => (
<span
key={`ai-${index}`}
className="px-2 py-1 bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300 rounded-md text-xs font-medium"
>
<Tag className="w-3 h-3 inline mr-1" />
{tag}
</span>
))}
{repository.topics?.slice(0, 2).map((topic, index) => (
<span
key={`topic-${index}`}
className="px-2 py-1 bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-md text-xs"
>
{topic}
</span>
))}
</div>
)}
{/* Platform Icons */}
{repository.ai_platforms && repository.ai_platforms.length > 0 && (
<div className="flex items-center space-x-2 mb-4">
<span className="text-xs text-gray-500 dark:text-gray-400">
{language === 'zh' ? '支持平台:' : 'Platforms:'}
</span>
<div className="flex space-x-1">
{repository.ai_platforms.slice(0, 6).map((platform, index) => (
<div
key={index}
className="w-6 h-6 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded text-gray-600 dark:text-gray-400"
title={platform}
>
<i className={`${getPlatformIcon(platform)} text-xs`}></i>
</div>
))}
</div>
</div>
)}
{/* Stats */}
<div className="space-y-3 mt-auto">
{/* Language and Stars */}
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center space-x-4">
{repository.language && (
<div className="flex items-center space-x-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getLanguageColor(repository.language) }}
/>
<span className="truncate max-w-20">{repository.language}</span>
</div>
)}
<div className="flex items-center space-x-1">
<Star className="w-4 h-4" />
<span>{formatNumber(repository.stargazers_count)}</span>
</div>
</div>
{repository.analyzed_at && (
<div className="flex items-center space-x-1 text-xs">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span>{language === 'zh' ? 'AI已分析' : 'AI analyzed'}</span>
</div>
)}
</div>
{/* Update Time - Separate Row */}
<div className="flex items-center space-x-1 text-sm text-gray-500 dark:text-gray-400 pt-2 border-t border-gray-100 dark:border-gray-700">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span className="truncate">
{language === 'zh' ? '更新于' : 'Updated'} {formatDistanceToNow(new Date(repository.updated_at), { addSuffix: true })}
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,416 @@
import React, { useState, useRef } from 'react';
import { Bot, ChevronDown, Pause, Play } from 'lucide-react';
import { RepositoryCard } from './RepositoryCard';
import { Repository } from '../types';
import { useAppStore } from '../store/useAppStore';
import { GitHubApiService } from '../services/githubApi';
import { AIService } from '../services/aiService';
interface RepositoryListProps {
repositories: Repository[];
selectedCategory: string;
}
export const RepositoryList: React.FC<RepositoryListProps> = ({
repositories,
selectedCategory
}) => {
const {
githubToken,
aiConfigs,
activeAIConfig,
isLoading,
setLoading,
updateRepository,
language,
} = useAppStore();
const [showAISummary, setShowAISummary] = useState(true);
const [showDropdown, setShowDropdown] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState({ current: 0, total: 0 });
const [isPaused, setIsPaused] = useState(false);
// 使用 useRef 来管理停止状态,确保在异步操作中能正确访问最新值
const shouldStopRef = useRef(false);
const isAnalyzingRef = useRef(false);
// Filter repositories by selected category
const filteredRepositories = repositories.filter(repo => {
if (selectedCategory === 'all') return true;
// 优先使用AI标签进行匹配
if (repo.ai_tags && repo.ai_tags.length > 0) {
const categoryKeywords = getCategoryKeywords(selectedCategory);
return repo.ai_tags.some(tag =>
categoryKeywords.some(keyword =>
tag.toLowerCase().includes(keyword.toLowerCase()) ||
keyword.toLowerCase().includes(tag.toLowerCase())
)
);
}
// 如果没有AI标签使用传统方式匹配
const repoText = [
repo.name,
repo.description || '',
repo.language || '',
...(repo.topics || []),
repo.ai_summary || ''
].join(' ').toLowerCase();
const categoryKeywords = getCategoryKeywords(selectedCategory);
return categoryKeywords.some(keyword =>
repoText.includes(keyword.toLowerCase())
);
});
const handleAIAnalyze = async (analyzeUnanalyzedOnly: boolean = false) => {
if (!githubToken) {
alert(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.');
return;
}
const activeConfig = aiConfigs.find(config => config.id === activeAIConfig);
if (!activeConfig) {
alert(language === 'zh' ? '请先在设置中配置AI服务。' : 'Please configure AI service in settings first.');
return;
}
const targetRepos = analyzeUnanalyzedOnly
? filteredRepositories.filter(repo => !repo.analyzed_at)
: filteredRepositories;
if (targetRepos.length === 0) {
alert(language === 'zh'
? (analyzeUnanalyzedOnly ? '所有仓库都已经分析过了!' : '没有可分析的仓库!')
: (analyzeUnanalyzedOnly ? 'All repositories have been analyzed!' : 'No repositories to analyze!')
);
return;
}
const actionText = language === 'zh'
? (analyzeUnanalyzedOnly ? '未分析' : '全部')
: (analyzeUnanalyzedOnly ? 'unanalyzed' : 'all');
const confirmMessage = language === 'zh'
? `将对 ${targetRepos.length}${actionText}仓库进行AI分析这可能需要几分钟时间。是否继续`
: `Will analyze ${targetRepos.length} ${actionText} repositories with AI. This may take several minutes. Continue?`;
const confirmed = confirm(confirmMessage);
if (!confirmed) return;
// 重置状态
shouldStopRef.current = false;
isAnalyzingRef.current = true;
setLoading(true);
setAnalysisProgress({ current: 0, total: targetRepos.length });
setShowDropdown(false);
setIsPaused(false);
try {
const githubApi = new GitHubApiService(githubToken);
const aiService = new AIService(activeConfig, language);
let analyzed = 0;
for (let i = 0; i < targetRepos.length; i++) {
// 检查是否需要停止
if (shouldStopRef.current) {
console.log('Analysis stopped by user');
break;
}
// 处理暂停
while (isPaused && !shouldStopRef.current) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
// 再次检查停止状态(暂停期间可能被停止)
if (shouldStopRef.current) {
console.log('Analysis stopped during pause');
break;
}
const repo = targetRepos[i];
setAnalysisProgress({ current: i + 1, total: targetRepos.length });
try {
// 获取README内容
const [owner, name] = repo.full_name.split('/');
const readmeContent = await githubApi.getRepositoryReadme(owner, name);
// AI分析
const analysis = await aiService.analyzeRepository(repo, readmeContent);
// 更新仓库信息
const updatedRepo = {
...repo,
ai_summary: analysis.summary,
ai_tags: analysis.tags,
ai_platforms: analysis.platforms,
analyzed_at: new Date().toISOString()
};
updateRepository(updatedRepo);
analyzed++;
// 避免API限制
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.warn(`Failed to analyze ${repo.full_name}:`, error);
}
}
const completionMessage = shouldStopRef.current
? (language === 'zh'
? `AI分析已停止已成功分析了 ${analyzed} 个仓库。`
: `AI analysis stopped! Successfully analyzed ${analyzed} repositories.`)
: (language === 'zh'
? `AI分析完成成功分析了 ${analyzed} 个仓库。`
: `AI analysis completed! Successfully analyzed ${analyzed} repositories.`);
alert(completionMessage);
} catch (error) {
console.error('AI analysis failed:', error);
const errorMessage = language === 'zh'
? 'AI分析失败请检查AI配置和网络连接。'
: 'AI analysis failed. Please check AI configuration and network connection.';
alert(errorMessage);
} finally {
// 清理状态
isAnalyzingRef.current = false;
shouldStopRef.current = false;
setLoading(false);
setAnalysisProgress({ current: 0, total: 0 });
setIsPaused(false);
}
};
const handlePauseResume = () => {
if (!isAnalyzingRef.current) return;
setIsPaused(!isPaused);
console.log(isPaused ? 'Analysis resumed' : 'Analysis paused');
};
const handleStop = () => {
if (!isAnalyzingRef.current) return;
const confirmMessage = language === 'zh'
? '确定要停止AI分析吗已分析的结果将会保存。'
: 'Are you sure you want to stop AI analysis? Analyzed results will be saved.';
if (confirm(confirmMessage)) {
shouldStopRef.current = true;
setIsPaused(false);
console.log('Stop requested by user');
}
};
if (filteredRepositories.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{selectedCategory === 'all'
? (language === 'zh' ? '未找到仓库。点击同步加载您的星标仓库。' : 'No repositories found. Click sync to load your starred repositories.')
: (language === 'zh'
? `在"${getCategoryName(selectedCategory)}"分类中未找到仓库。`
: `No repositories found in "${getCategoryName(selectedCategory)}" category.`
)
}
</p>
</div>
);
}
const unanalyzedCount = filteredRepositories.filter(r => !r.analyzed_at).length;
const analyzedCount = filteredRepositories.filter(r => r.analyzed_at).length;
const t = (zh: string, en: string) => language === 'zh' ? zh : en;
return (
<div className="space-y-6">
{/* AI Analysis Controls */}
<div className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center space-x-4">
{/* AI Analysis Dropdown Button */}
<div className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
disabled={isLoading}
className="flex items-center space-x-2 px-4 py-2 bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors disabled:opacity-50"
>
<Bot className="w-4 h-4" />
<span>
{isLoading
? t(`AI分析中... (${analysisProgress.current}/${analysisProgress.total})`, `AI Analyzing... (${analysisProgress.current}/${analysisProgress.total})`)
: t('AI分析', 'AI Analysis')
}
</span>
<ChevronDown className="w-4 h-4" />
</button>
{/* Dropdown Menu */}
{showDropdown && !isLoading && (
<div className="absolute top-full left-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-10">
<button
onClick={() => handleAIAnalyze(false)}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-600"
>
<div className="font-medium text-gray-900 dark:text-white">
{t('分析全部', 'Analyze All')}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{t(`分析 ${filteredRepositories.length} 个仓库`, `Analyze ${filteredRepositories.length} repositories`)}
</div>
</button>
<button
onClick={() => handleAIAnalyze(true)}
disabled={unanalyzedCount === 0}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="font-medium text-gray-900 dark:text-white">
{t('分析未分析的', 'Analyze Unanalyzed')}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{t(`分析 ${unanalyzedCount} 个未分析仓库`, `Analyze ${unanalyzedCount} unanalyzed repositories`)}
</div>
</button>
</div>
)}
</div>
{/* Progress Bar and Controls */}
{isLoading && analysisProgress.total > 0 && (
<div className="flex items-center space-x-3">
<div className="w-32 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${(analysisProgress.current / analysisProgress.total) * 100}%` }}
></div>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{Math.round((analysisProgress.current / analysisProgress.total) * 100)}%
</span>
<button
onClick={handlePauseResume}
className="p-1.5 rounded-lg bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 hover:bg-yellow-200 dark:hover:bg-yellow-800 transition-colors"
title={isPaused ? t('继续', 'Resume') : t('暂停', 'Pause')}
>
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</button>
<button
onClick={handleStop}
className="px-3 py-1.5 rounded-lg bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800 transition-colors text-sm"
>
{t('停止', 'Stop')}
</button>
</div>
)}
{/* Description Toggle - Radio Style */}
{!isLoading && (
<div className="flex items-center space-x-3">
<span className="text-sm text-gray-600 dark:text-gray-400">
{t('显示内容:', 'Display:')}
</span>
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
name="displayContent"
checked={showAISummary}
onChange={() => setShowAISummary(true)}
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"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('AI总结', 'AI Summary')}
</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
name="displayContent"
checked={!showAISummary}
onChange={() => setShowAISummary(false)}
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"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('原始描述', 'Original Description')}
</span>
</label>
</div>
</div>
)}
</div>
{/* Statistics */}
<div className="text-sm text-gray-500 dark:text-gray-400">
{t(`显示 ${filteredRepositories.length} 个仓库`, `Showing ${filteredRepositories.length} repositories`)}
{analyzedCount > 0 && (
<span className="ml-2">
{analyzedCount} {t('个已AI分析', 'AI analyzed')}
</span>
)}
{unanalyzedCount > 0 && (
<span className="ml-2">
{unanalyzedCount} {t('个未分析', 'unanalyzed')}
</span>
)}
</div>
</div>
{/* Repository Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredRepositories.map(repo => (
<RepositoryCard
key={repo.id}
repository={repo}
showAISummary={showAISummary}
/>
))}
</div>
</div>
);
};
// Helper function to get category keywords
function getCategoryKeywords(categoryId: string): string[] {
const categoryMap: Record<string, string[]> = {
'web': ['web应用', 'web', 'website', 'frontend', 'react', 'vue', 'angular'],
'mobile': ['移动应用', 'mobile', 'android', 'ios', 'flutter', 'react-native'],
'desktop': ['桌面应用', 'desktop', 'electron', 'gui', 'qt', 'gtk'],
'database': ['数据库', 'database', 'sql', 'nosql', 'mongodb', 'mysql', 'postgresql'],
'ai': ['ai工具', 'ai', 'ml', 'machine learning', 'deep learning', 'neural'],
'devtools': ['开发工具', 'tool', 'cli', 'build', 'deploy', 'debug', 'test', 'automation'],
'security': ['安全工具', 'security', 'encryption', 'auth', 'vulnerability'],
'game': ['游戏', 'game', 'gaming', 'unity', 'unreal', 'godot'],
'design': ['设计工具', 'design', 'ui', 'ux', 'graphics', 'image'],
'productivity': ['效率工具', 'productivity', 'note', 'todo', 'calendar', 'task'],
'education': ['教育学习', 'education', 'learning', 'tutorial', 'course'],
'social': ['社交网络', 'social', 'chat', 'messaging', 'communication'],
'analytics': ['数据分析', 'analytics', 'data', 'visualization', 'chart']
};
return categoryMap[categoryId] || [];
}
// Helper function to get category name
function getCategoryName(categoryId: string): string {
const nameMap: Record<string, string> = {
'web': 'Web应用',
'mobile': '移动应用',
'desktop': '桌面应用',
'database': '数据库',
'ai': 'AI/机器学习',
'devtools': '开发工具',
'security': '安全工具',
'game': '游戏',
'design': '设计工具',
'productivity': '效率工具',
'education': '教育学习',
'social': '社交网络',
'analytics': '数据分析'
};
return nameMap[categoryId] || categoryId;
}

View File

@@ -0,0 +1,520 @@
import React, { useState, useEffect } from 'react';
import { Search, Filter, X, SlidersHorizontal, Monitor, Smartphone, Globe, Terminal, Package, CheckCircle, Bell } from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
import { AIService } from '../services/aiService';
export const SearchBar: React.FC = () => {
const {
searchFilters,
repositories,
releaseSubscriptions,
aiConfigs,
activeAIConfig,
language,
setSearchFilters,
setSearchResults,
} = useAppStore();
const [showFilters, setShowFilters] = useState(false);
const [searchQuery, setSearchQuery] = useState(searchFilters.query);
const [isSearching, setIsSearching] = useState(false);
const [availableLanguages, setAvailableLanguages] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [availablePlatforms, setAvailablePlatforms] = useState<string[]>([]);
useEffect(() => {
// Extract unique languages, tags, and platforms from repositories
const languages = [...new Set(repositories.map(r => r.language).filter(Boolean))];
const tags = [...new Set([
...repositories.flatMap(r => r.ai_tags || []),
...repositories.flatMap(r => r.topics || [])
])];
const platforms = [...new Set(repositories.flatMap(r => r.ai_platforms || []))];
setAvailableLanguages(languages);
setAvailableTags(tags);
setAvailablePlatforms(platforms);
}, [repositories]);
useEffect(() => {
// Perform search when filters change (except query)
const performSearch = async () => {
if (searchFilters.query && !isSearching) {
setIsSearching(true);
await performAdvancedSearch();
setIsSearching(false);
} else if (!searchFilters.query) {
performBasicFilter();
}
};
performSearch();
}, [searchFilters, repositories, releaseSubscriptions]);
const performAdvancedSearch = async () => {
let filtered = repositories;
// AI-powered natural language search
if (searchFilters.query) {
const activeConfig = aiConfigs.find(config => config.id === activeAIConfig);
if (activeConfig) {
try {
const aiService = new AIService(activeConfig, language);
filtered = await aiService.searchRepositories(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 performBasicFilter = () => {
const filtered = applyFilters(repositories);
setSearchResults(filtered);
};
const performBasicTextSearch = (repos: typeof repositories, query: string) => {
const normalizedQuery = query.toLowerCase();
return repos.filter(repo => {
const searchableText = [
repo.name,
repo.full_name,
repo.description || '',
repo.language || '',
...(repo.topics || []),
repo.ai_summary || '',
...(repo.ai_tags || []),
...(repo.ai_platforms || []),
].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));
});
};
const applyFilters = (repos: typeof repositories) => {
let filtered = repos;
// Language filter
if (searchFilters.languages.length > 0) {
filtered = filtered.filter(repo =>
repo.language && searchFilters.languages.includes(repo.language)
);
}
// Tag filter
if (searchFilters.tags.length > 0) {
filtered = filtered.filter(repo => {
const repoTags = [...(repo.ai_tags || []), ...(repo.topics || [])];
return searchFilters.tags.some(tag => repoTags.includes(tag));
});
}
// Platform filter
if (searchFilters.platforms.length > 0) {
filtered = filtered.filter(repo => {
const repoPlatforms = repo.ai_platforms || [];
return searchFilters.platforms.some(platform => repoPlatforms.includes(platform));
});
}
// AI analyzed filter
if (searchFilters.isAnalyzed !== undefined) {
filtered = filtered.filter(repo =>
searchFilters.isAnalyzed ? !!repo.analyzed_at : !repo.analyzed_at
);
}
// Release subscription filter
if (searchFilters.isSubscribed !== undefined) {
filtered = filtered.filter(repo =>
searchFilters.isSubscribed ? releaseSubscriptions.has(repo.id) : !releaseSubscriptions.has(repo.id)
);
}
// Star count filter
if (searchFilters.minStars !== undefined) {
filtered = filtered.filter(repo => repo.stargazers_count >= searchFilters.minStars!);
}
if (searchFilters.maxStars !== undefined) {
filtered = filtered.filter(repo => repo.stargazers_count <= searchFilters.maxStars!);
}
// Sort
filtered.sort((a, b) => {
let aValue: any, bValue: any;
switch (searchFilters.sortBy) {
case 'stars':
aValue = a.stargazers_count;
bValue = b.stargazers_count;
break;
case 'updated':
aValue = new Date(a.updated_at).getTime();
bValue = new Date(b.updated_at).getTime();
break;
case 'name':
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
break;
default:
aValue = new Date(a.updated_at).getTime();
bValue = new Date(b.updated_at).getTime();
}
if (searchFilters.sortOrder === 'desc') {
return bValue > aValue ? 1 : -1;
} else {
return aValue > bValue ? 1 : -1;
}
});
return filtered;
};
const handleSearch = () => {
setSearchFilters({ query: searchQuery });
};
const handleClearSearch = () => {
setSearchQuery('');
setSearchFilters({ query: '' });
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleLanguageToggle = (language: string) => {
const newLanguages = searchFilters.languages.includes(language)
? searchFilters.languages.filter(l => l !== language)
: [...searchFilters.languages, language];
setSearchFilters({ languages: newLanguages });
};
const handleTagToggle = (tag: string) => {
const newTags = searchFilters.tags.includes(tag)
? searchFilters.tags.filter(t => t !== tag)
: [...searchFilters.tags, tag];
setSearchFilters({ tags: newTags });
};
const handlePlatformToggle = (platform: string) => {
const newPlatforms = searchFilters.platforms.includes(platform)
? searchFilters.platforms.filter(p => p !== platform)
: [...searchFilters.platforms, platform];
setSearchFilters({ platforms: newPlatforms });
};
const clearFilters = () => {
setSearchQuery('');
setSearchFilters({
query: '',
tags: [],
languages: [],
platforms: [],
sortBy: 'stars',
sortOrder: 'desc',
minStars: undefined,
maxStars: undefined,
isAnalyzed: undefined,
isSubscribed: undefined,
});
};
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);
const getPlatformIcon = (platform: string) => {
const iconMap: Record<string, string> = {
mac: 'fab fa-apple',
macos: 'fab fa-apple',
windows: 'fab fa-windows',
win: 'fab fa-windows',
linux: 'fab fa-linux',
ios: 'fab fa-apple',
android: 'fab fa-android',
web: 'fas fa-globe',
cli: 'fas fa-terminal',
docker: 'fab fa-docker',
};
return iconMap[platform.toLowerCase()] || 'fas fa-desktop';
};
const t = (zh: string, en: string) => language === 'zh' ? zh : en;
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
{/* Search Input */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder={t(
"使用自然语言搜索仓库 (例如: '查找所有笔记应用')",
"Search repositories with natural language (e.g., 'find all note-taking apps')"
)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleKeyPress}
className="w-full pl-10 pr-32 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
/>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center space-x-2">
{searchQuery && (
<button
onClick={handleClearSearch}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title={t('清除搜索', 'Clear search')}
>
<X className="w-4 h-4" />
</button>
)}
<button
onClick={handleSearch}
disabled={isSearching}
className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium disabled:opacity-50"
>
{isSearching ? t('搜索中...', 'Searching...') : t('搜索', 'Search')}
</button>
</div>
</div>
{/* Filter Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-colors ${
showFilters || activeFiltersCount > 0
? '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'
}`}
>
<SlidersHorizontal className="w-4 h-4" />
<span>{t('过滤器', 'Filters')}</span>
{activeFiltersCount > 0 && (
<span className="bg-blue-600 text-white rounded-full px-2 py-0.5 text-xs">
{activeFiltersCount}
</span>
)}
</button>
{activeFiltersCount > 0 && (
<button
onClick={clearFilters}
className="flex items-center space-x-1 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
<X className="w-4 h-4" />
<span>{t('清除全部', 'Clear all')}</span>
</button>
)}
</div>
{/* Sort Controls */}
<div className="flex items-center space-x-3">
<select
value={searchFilters.sortBy}
onChange={(e) => setSearchFilters({
sortBy: e.target.value as 'stars' | 'updated' | 'name' | 'created'
})}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
>
<option value="stars">{t('按星标排序', 'Sort by Stars')}</option>
<option value="updated">{t('按更新排序', 'Sort by Updated')}</option>
<option value="name">{t('按名称排序', 'Sort by Name')}</option>
</select>
<button
onClick={() => setSearchFilters({
sortOrder: searchFilters.sortOrder === 'desc' ? 'asc' : 'desc'
})}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
{searchFilters.sortOrder === 'desc' ? '↓' : '↑'}
</button>
</div>
</div>
{/* Advanced Filters */}
{showFilters && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700 space-y-6">
{/* Status Filters */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
{t('状态过滤', 'Status Filters')}
</h4>
<div className="flex flex-wrap gap-2">
<button
onClick={() => 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'
}`}
>
<CheckCircle className="w-4 h-4" />
<span>{t('已AI分析', 'AI Analyzed')}</span>
</button>
<button
onClick={() => 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'
}`}
>
<X className="w-4 h-4" />
<span>{t('未AI分析', 'Not Analyzed')}</span>
</button>
<button
onClick={() => 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'
}`}
>
<Bell className="w-4 h-4" />
<span>{t('已订阅Release', 'Subscribed to Releases')}</span>
</button>
</div>
</div>
{/* Languages */}
{availableLanguages.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
{t('编程语言', 'Programming Languages')}
</h4>
<div className="flex flex-wrap gap-2">
{availableLanguages.slice(0, 12).map(language => (
<button
key={language}
onClick={() => handleLanguageToggle(language)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
searchFilters.languages.includes(language)
? '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'
}`}
>
{language}
</button>
))}
</div>
</div>
)}
{/* Platforms */}
{availablePlatforms.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
{t('支持平台', 'Supported Platforms')}
</h4>
<div className="flex flex-wrap gap-2">
{availablePlatforms.map(platform => (
<button
key={platform}
onClick={() => handlePlatformToggle(platform)}
className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
searchFilters.platforms.includes(platform)
? 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<i className={`${getPlatformIcon(platform)} w-4 h-4`}></i>
<span>{platform}</span>
</button>
))}
</div>
</div>
)}
{/* Tags */}
{availableTags.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
{t('标签', 'Tags')}
</h4>
<div className="flex flex-wrap gap-2">
{availableTags.slice(0, 15).map(tag => (
<button
key={tag}
onClick={() => handleTagToggle(tag)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
searchFilters.tags.includes(tag)
? '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'
}`}
>
{tag}
</button>
))}
</div>
</div>
)}
{/* Star Range */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
{t('Star数量范围', 'Star Count Range')}
</h4>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-600 dark:text-gray-400">
{t('最小:', 'Min:')}
</label>
<input
type="number"
placeholder="0"
value={searchFilters.minStars || ''}
onChange={(e) => setSearchFilters({
minStars: e.target.value ? parseInt(e.target.value) : undefined
})}
className="w-24 px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-600 dark:text-gray-400">
{t('最大:', 'Max:')}
</label>
<input
type="number"
placeholder="∞"
value={searchFilters.maxStars || ''}
onChange={(e) => setSearchFilters({
maxStars: e.target.value ? parseInt(e.target.value) : undefined
})}
className="w-24 px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
</div>
</div>
</div>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

12
src/index.css Normal file
View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}

22
src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
console.log('Main.tsx loading...');
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
console.log('Root element found, creating React root...');
const root = createRoot(rootElement);
root.render(
<StrictMode>
<App />
</StrictMode>
);
console.log('React app rendered');

431
src/services/aiService.ts Normal file
View File

@@ -0,0 +1,431 @@
import { Repository, AIConfig } from '../types';
export class AIService {
private config: AIConfig;
private language: string;
constructor(config: AIConfig, language: string = 'zh') {
this.config = config;
this.language = language;
}
async analyzeRepository(repository: Repository, readmeContent: string): Promise<{
summary: string;
tags: string[];
platforms: string[];
}> {
const prompt = this.createAnalysisPrompt(repository, readmeContent);
try {
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
model: this.config.model,
messages: [
{
role: 'system',
content: this.language === 'zh'
? '你是一个专业的GitHub仓库分析助手。请用中文简洁地分析仓库提供实用的概述、分类标签和支持的平台类型。'
: 'You are a professional GitHub repository analysis assistant. Please analyze repositories concisely in English, providing practical overviews, category tags, and supported platform types.',
},
{
role: 'user',
content: prompt,
},
],
temperature: 0.3,
max_tokens: 400,
}),
});
if (!response.ok) {
throw new Error(`AI API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No content received from AI service');
}
return this.parseAIResponse(content);
} catch (error) {
console.error('AI analysis failed:', error);
// Fallback to basic analysis
return this.fallbackAnalysis(repository);
}
}
private createAnalysisPrompt(repository: Repository, readmeContent: string): string {
const repoInfo = `
${this.language === 'zh' ? '仓库名称' : 'Repository Name'}: ${repository.full_name}
${this.language === 'zh' ? '描述' : 'Description'}: ${repository.description || (this.language === 'zh' ? '无描述' : 'No description')}
${this.language === 'zh' ? '编程语言' : 'Programming Language'}: ${repository.language || (this.language === 'zh' ? '未知' : 'Unknown')}
${this.language === 'zh' ? 'Star数' : 'Stars'}: ${repository.stargazers_count}
${this.language === 'zh' ? '主题标签' : 'Topics'}: ${repository.topics?.join(', ') || (this.language === 'zh' ? '无' : 'None')}
${this.language === 'zh' ? 'README内容 (前2000字符)' : 'README Content (first 2000 characters)'}:
${readmeContent.substring(0, 2000)}
`.trim();
if (this.language === 'zh') {
return `
请分析这个GitHub仓库并提供
1. 一个简洁的中文概述不超过50字说明这个仓库的主要功能和用途
2. 3-5个相关的应用类型标签用中文类似应用商店的分类开发工具、Web应用、移动应用、数据库、AI工具等
3. 支持的平台类型从以下选择mac、windows、linux、ios、android、docker、web、cli
请以JSON格式回复
{
"summary": "你的中文概述",
"tags": ["标签1", "标签2", "标签3", "标签4", "标签5"],
"platforms": ["platform1", "platform2", "platform3"]
}
仓库信息:
${repoInfo}
重点关注实用性和准确的分类,帮助用户快速理解仓库的用途和支持的平台。
`.trim();
} 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)
Please reply in JSON format:
{
"summary": "Your English overview",
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],
"platforms": ["platform1", "platform2", "platform3"]
}
Repository information:
${repoInfo}
Focus on practicality and accurate categorization to help users quickly understand the repository's purpose and supported platforms.
`.trim();
}
}
private parseAIResponse(content: string): { summary: string; tags: string[]; platforms: string[] } {
try {
// Try to extract JSON from the response
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
return {
summary: parsed.summary || (this.language === 'zh' ? '无法生成概述' : 'Unable to generate summary'),
tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 5) : [],
platforms: Array.isArray(parsed.platforms) ? parsed.platforms.slice(0, 8) : [],
};
}
// Fallback parsing
return {
summary: content.substring(0, 50) + '...',
tags: [],
platforms: [],
};
} catch (error) {
console.error('Failed to parse AI response:', error);
return {
summary: this.language === 'zh' ? '分析失败' : 'Analysis failed',
tags: [],
platforms: [],
};
}
}
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<string, { tag: string; platforms: string[] }> = 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<boolean> {
try {
const response = await fetch(`${this.config.baseUrl}/models`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
},
});
return response.ok;
} catch (error) {
return false;
}
}
async searchRepositories(repositories: Repository[], query: string): Promise<Repository[]> {
if (!query.trim()) return repositories;
try {
// Use AI to understand and translate the search query
const searchPrompt = this.createSearchPrompt(query);
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
model: this.config.model,
messages: [
{
role: 'system',
content: this.language === 'zh'
? '你是一个智能搜索助手。请分析用户的搜索意图,提取关键词并提供多语言翻译。'
: 'You are an intelligent search assistant. Please analyze user search intent, extract keywords and provide multilingual translations.',
},
{
role: 'user',
content: searchPrompt,
},
],
temperature: 0.1,
max_tokens: 200,
}),
});
if (response.ok) {
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (content) {
const searchTerms = this.parseSearchResponse(content);
return this.performEnhancedSearch(repositories, query, searchTerms);
}
}
} catch (error) {
console.warn('AI search failed, falling back to basic search:', error);
}
// Fallback to basic search
return this.performBasicSearch(repositories, query);
}
private createSearchPrompt(query: string): string {
if (this.language === 'zh') {
return `
用户搜索查询: "${query}"
请分析这个搜索查询并提供:
1. 主要关键词(中英文)
2. 相关的技术术语和同义词
3. 可能的应用类型或分类
以JSON格式回复
{
"keywords": ["关键词1", "keyword1", "关键词2", "keyword2"],
"categories": ["分类1", "category1"],
"synonyms": ["同义词1", "synonym1"]
}
`.trim();
} else {
return `
User search query: "${query}"
Please analyze this search query and provide:
1. Main keywords (in English and Chinese)
2. Related technical terms and synonyms
3. Possible application types or categories
Reply in JSON format:
{
"keywords": ["keyword1", "关键词1", "keyword2", "关键词2"],
"categories": ["category1", "分类1"],
"synonyms": ["synonym1", "同义词1"]
}
`.trim();
}
}
private parseSearchResponse(content: string): string[] {
try {
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
const allTerms = [
...(parsed.keywords || []),
...(parsed.categories || []),
...(parsed.synonyms || [])
];
return allTerms.filter(term => typeof term === 'string' && term.length > 0);
}
} catch (error) {
console.warn('Failed to parse AI search response:', error);
}
return [];
}
private performEnhancedSearch(repositories: Repository[], originalQuery: string, aiTerms: string[]): Repository[] {
const allSearchTerms = [originalQuery, ...aiTerms];
return repositories.filter(repo => {
const searchableText = [
repo.name,
repo.full_name,
repo.description || '',
repo.language || '',
...(repo.topics || []),
repo.ai_summary || '',
...(repo.ai_tags || []),
...(repo.ai_platforms || []),
].join(' ').toLowerCase();
// Check if any of the AI-enhanced terms match
return allSearchTerms.some(term => {
const normalizedTerm = term.toLowerCase();
return searchableText.includes(normalizedTerm) ||
// Fuzzy matching for partial matches
normalizedTerm.split(/\s+/).every(word => searchableText.includes(word));
});
});
}
private performBasicSearch(repositories: Repository[], query: string): Repository[] {
const normalizedQuery = query.toLowerCase();
return repositories.filter(repo => {
const searchableText = [
repo.name,
repo.full_name,
repo.description || '',
repo.language || '',
...(repo.topics || []),
repo.ai_summary || '',
...(repo.ai_tags || []),
...(repo.ai_platforms || []),
].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));
});
}
static async searchRepositories(repositories: Repository[], query: string): Promise<Repository[]> {
// This is a static fallback method for when no AI config is available
if (!query.trim()) return repositories;
const normalizedQuery = query.toLowerCase();
return repositories.filter(repo => {
const searchableText = [
repo.name,
repo.full_name,
repo.description || '',
repo.language || '',
...(repo.topics || []),
repo.ai_summary || '',
...(repo.ai_tags || []),
...(repo.ai_platforms || []),
].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));
});
}
}

189
src/services/githubApi.ts Normal file
View File

@@ -0,0 +1,189 @@
import { Repository, Release, GitHubUser } from '../types';
const GITHUB_API_BASE = 'https://api.github.com';
export class GitHubApiService {
private token: string;
constructor(token: string) {
this.token = token;
}
private async makeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
...options,
headers: {
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('GitHub token expired or invalid');
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async getCurrentUser(): Promise<GitHubUser> {
return this.makeRequest<GitHubUser>('/user');
}
async getStarredRepositories(page = 1, perPage = 100): Promise<Repository[]> {
const repos = await this.makeRequest<Repository[]>(
`/user/starred?page=${page}&per_page=${perPage}&sort=updated`
);
return repos;
}
async getAllStarredRepositories(): Promise<Repository[]> {
let allRepos: Repository[] = [];
let page = 1;
const perPage = 100;
while (true) {
const repos = await this.getStarredRepositories(page, perPage);
if (repos.length === 0) break;
allRepos = [...allRepos, ...repos];
if (repos.length < perPage) break;
page++;
// Rate limiting protection
await new Promise(resolve => setTimeout(resolve, 100));
}
return allRepos;
}
async getRepositoryReadme(owner: string, repo: string): Promise<string> {
try {
const response = await this.makeRequest<{ content: string; encoding: string }>(
`/repos/${owner}/${repo}/readme`
);
if (response.encoding === 'base64') {
return atob(response.content);
}
return response.content;
} catch (error) {
console.warn(`Failed to fetch README for ${owner}/${repo}:`, error);
return '';
}
}
async getRepositoryReleases(owner: string, repo: string, page = 1, perPage = 30): Promise<Release[]> {
try {
const releases = await this.makeRequest<any[]>(
`/repos/${owner}/${repo}/releases?page=${page}&per_page=${perPage}`
);
return releases.map(release => ({
id: release.id,
tag_name: release.tag_name,
name: release.name || release.tag_name,
body: release.body || '',
published_at: release.published_at,
html_url: release.html_url,
repository: {
id: 0, // Will be set by caller
full_name: `${owner}/${repo}`,
name: repo,
},
}));
} catch (error) {
console.warn(`Failed to fetch releases for ${owner}/${repo}:`, error);
return [];
}
}
async getMultipleRepositoryReleases(repositories: Repository[]): Promise<Release[]> {
const allReleases: Release[] = [];
for (const repo of repositories) {
const [owner, name] = repo.full_name.split('/');
const releases = await this.getRepositoryReleases(owner, name, 1, 5);
// Add repository info to releases
releases.forEach(release => {
release.repository.id = repo.id;
});
allReleases.push(...releases);
// Rate limiting protection
await new Promise(resolve => setTimeout(resolve, 150));
}
// Sort by published date (newest first)
return allReleases.sort((a, b) =>
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
);
}
// 新增获取仓库的增量releases基于时间戳
async getIncrementalRepositoryReleases(
owner: string,
repo: string,
since?: string,
perPage = 10
): Promise<Release[]> {
try {
let endpoint = `/repos/${owner}/${repo}/releases?per_page=${perPage}`;
const releases = await this.makeRequest<any[]>(endpoint);
const mappedReleases = releases.map(release => ({
id: release.id,
tag_name: release.tag_name,
name: release.name || release.tag_name,
body: release.body || '',
published_at: release.published_at,
html_url: release.html_url,
repository: {
id: 0, // Will be set by caller
full_name: `${owner}/${repo}`,
name: repo,
},
}));
// 如果提供了since时间戳只返回更新的releases
if (since) {
const sinceDate = new Date(since);
return mappedReleases.filter(release =>
new Date(release.published_at) > sinceDate
);
}
return mappedReleases;
} catch (error) {
console.warn(`Failed to fetch incremental releases for ${owner}/${repo}:`, error);
return [];
}
}
async checkRateLimit(): Promise<{ remaining: number; reset: number }> {
const response = await this.makeRequest<any>('/rate_limit');
return {
remaining: response.rate.remaining,
reset: response.rate.reset,
};
}
}
export const createGitHubOAuthUrl = (clientId: string, redirectUri: string): string => {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email repo',
state: Math.random().toString(36).substring(7),
});
return `https://github.com/login/oauth/authorize?${params.toString()}`;
};

View File

@@ -0,0 +1,384 @@
import { WebDAVConfig } from '../types';
export class WebDAVService {
private config: WebDAVConfig;
constructor(config: WebDAVConfig) {
this.config = config;
}
private getAuthHeader(): string {
const credentials = btoa(`${this.config.username}:${this.config.password}`);
return `Basic ${credentials}`;
}
private getFullPath(filename: string): string {
const basePath = this.config.path.endsWith('/') ? this.config.path : `${this.config.path}/`;
return `${this.config.url}${basePath}${filename}`;
}
private handleNetworkError(error: any, operation: string): never {
console.error(`WebDAV ${operation} failed:`, error);
// Check for CORS-related errors (most common issue)
const isCorsError = (
(error.name === 'TypeError' && error.message.includes('Failed to fetch')) ||
(error.message && error.message.includes('NetworkError when attempting to fetch resource')) ||
(error.name === 'NetworkError') ||
(error.message && error.message.includes('NetworkError'))
);
if (isCorsError) {
throw new Error(`CORS策略阻止了连接到WebDAV服务器。
这是一个常见的浏览器安全限制。要解决此问题,您需要:
1. 在WebDAV服务器上配置CORS头
• Access-Control-Allow-Origin: ${window.location.origin}
• Access-Control-Allow-Methods: GET, PUT, PROPFIND, HEAD, OPTIONS, MKCOL
• Access-Control-Allow-Headers: Authorization, Content-Type, Depth
2. 常见WebDAV服务器配置示例
Apache (.htaccess):
Header always set Access-Control-Allow-Origin "${window.location.origin}"
Header always set Access-Control-Allow-Methods "GET, PUT, PROPFIND, HEAD, OPTIONS, MKCOL"
Header always set Access-Control-Allow-Headers "Authorization, Content-Type, Depth"
Nginx:
add_header Access-Control-Allow-Origin "${window.location.origin}";
add_header Access-Control-Allow-Methods "GET, PUT, PROPFIND, HEAD, OPTIONS, MKCOL";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Depth";
3. 其他检查项:
• 确保WebDAV服务器正在运行
• 验证URL格式正确包含协议 http:// 或 https://
• 如果应用使用HTTPSWebDAV服务器也应使用HTTPS
技术详情: ${error.message}`);
}
throw new Error(`WebDAV ${operation} 失败: ${error.message || '未知错误'}`);
}
async testConnection(): Promise<boolean> {
try {
// 验证URL格式
if (!this.config.url.startsWith('http://') && !this.config.url.startsWith('https://')) {
throw new Error('WebDAV URL必须以 http:// 或 https:// 开头');
}
// 首先尝试OPTIONS请求检查CORS
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
try {
const optionsResponse = await fetch(this.config.url, {
method: 'OPTIONS',
headers: {
'Authorization': this.getAuthHeader(),
},
signal: controller.signal,
});
clearTimeout(timeoutId);
// 如果OPTIONS成功说明CORS配置正确
if (optionsResponse.ok) {
return true;
}
// 如果OPTIONS失败尝试PROPFIND某些服务器不支持OPTIONS
const propfindResponse = await fetch(this.config.url, {
method: 'PROPFIND',
headers: {
'Authorization': this.getAuthHeader(),
'Depth': '0',
},
});
return propfindResponse.ok || propfindResponse.status === 207;
} catch (fetchError) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error('连接超时。请检查WebDAV服务器是否可访问。');
}
throw fetchError;
}
} catch (error) {
this.handleNetworkError(error, '连接测试');
}
}
async uploadFile(filename: string, content: string): Promise<boolean> {
try {
// 验证URL格式
if (!this.config.url.startsWith('http://') && !this.config.url.startsWith('https://')) {
throw new Error('WebDAV URL必须以 http:// 或 https:// 开头');
}
// 确保目录存在
await this.ensureDirectoryExists();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时
try {
const response = await fetch(this.getFullPath(filename), {
method: 'PUT',
headers: {
'Authorization': this.getAuthHeader(),
'Content-Type': 'application/json',
},
body: content,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error('身份验证失败。请检查用户名和密码。');
}
if (response.status === 403) {
throw new Error('访问被拒绝。请检查指定路径的权限。');
}
if (response.status === 404) {
throw new Error('路径未找到。请验证WebDAV URL和路径是否正确。');
}
if (response.status === 507) {
throw new Error('服务器存储空间不足。');
}
throw new Error(`上传失败HTTP状态码 ${response.status}: ${response.statusText}`);
}
return true;
} catch (fetchError) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error('上传超时。文件可能太大或网络连接缓慢。');
}
throw fetchError;
}
} catch (error) {
if (error.message.includes('身份验证失败') ||
error.message.includes('访问被拒绝') ||
error.message.includes('路径未找到') ||
error.message.includes('存储空间不足') ||
error.message.includes('上传失败HTTP状态码') ||
error.message.includes('上传超时') ||
error.message.includes('WebDAV URL必须')) {
throw error; // 重新抛出特定错误
}
this.handleNetworkError(error, '上传');
}
}
private async ensureDirectoryExists(): Promise<void> {
try {
if (!this.config.path || this.config.path === '/') {
return; // 根目录总是存在
}
const dirPath = this.config.url + this.config.path;
const response = await fetch(dirPath, {
method: 'MKCOL',
headers: {
'Authorization': this.getAuthHeader(),
},
});
// 201 = 已创建, 405 = 已存在, 都是正常的
if (!response.ok && response.status !== 405) {
console.warn('无法创建目录,可能已存在或权限不足');
}
} catch (error) {
console.warn('目录创建检查失败:', error);
// 不在这里抛出错误,因为目录可能已经存在
}
}
async downloadFile(filename: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时
try {
const response = await fetch(this.getFullPath(filename), {
method: 'GET',
headers: {
'Authorization': this.getAuthHeader(),
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
return await response.text();
}
if (response.status === 404) {
return null; // 文件未找到是预期行为
}
if (response.status === 401) {
throw new Error('身份验证失败。请检查用户名和密码。');
}
throw new Error(`下载失败HTTP状态码 ${response.status}: ${response.statusText}`);
} catch (fetchError) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error('下载超时。请检查网络连接。');
}
throw fetchError;
}
} catch (error) {
if (error.message.includes('身份验证失败') ||
error.message.includes('下载超时')) {
throw error;
}
if (error.message.includes('HTTP 404')) {
return null;
}
this.handleNetworkError(error, '下载');
}
}
async fileExists(filename: string): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
const response = await fetch(this.getFullPath(filename), {
method: 'HEAD',
headers: {
'Authorization': this.getAuthHeader(),
},
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok;
} catch (error) {
console.error('WebDAV文件检查失败:', error);
return false;
}
}
async listFiles(): Promise<string[]> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时
try {
const response = await fetch(this.config.url + this.config.path, {
method: 'PROPFIND',
headers: {
'Authorization': this.getAuthHeader(),
'Depth': '1',
'Content-Type': 'application/xml',
},
body: `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:displayname/>
<D:getlastmodified/>
<D:getcontentlength/>
</D:prop>
</D:propfind>`,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok || response.status === 207) {
const xmlText = await response.text();
// 简单的XML解析提取文件名
const fileMatches = xmlText.match(/<D:displayname>([^<]+)<\/D:displayname>/g);
if (fileMatches) {
return fileMatches
.map(match => match.replace(/<\/?D:displayname>/g, ''))
.filter(name => name.endsWith('.json'));
}
} else if (response.status === 401) {
throw new Error('身份验证失败。请检查用户名和密码。');
} else {
throw new Error(`列出文件失败HTTP状态码 ${response.status}: ${response.statusText}`);
}
return [];
} catch (fetchError) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error('列出文件超时。请检查网络连接。');
}
throw fetchError;
}
} catch (error) {
if (error.message.includes('身份验证失败') ||
error.message.includes('列出文件超时')) {
throw error;
}
this.handleNetworkError(error, '列出文件');
}
}
// 新增:验证配置的静态方法
static validateConfig(config: Partial<WebDAVConfig>): string[] {
const errors: string[] = [];
if (!config.url) {
errors.push('WebDAV URL是必需的');
} else if (!config.url.startsWith('http://') && !config.url.startsWith('https://')) {
errors.push('WebDAV URL必须以 http:// 或 https:// 开头');
}
if (!config.username) {
errors.push('用户名是必需的');
}
if (!config.password) {
errors.push('密码是必需的');
}
if (!config.path) {
errors.push('路径是必需的');
} else if (!config.path.startsWith('/')) {
errors.push('路径必须以 / 开头');
}
return errors;
}
// 新增:获取服务器信息
async getServerInfo(): Promise<{ server?: string; davLevel?: string }> {
try {
const response = await fetch(this.config.url, {
method: 'OPTIONS',
headers: {
'Authorization': this.getAuthHeader(),
},
});
if (response.ok) {
return {
server: response.headers.get('Server') || undefined,
davLevel: response.headers.get('DAV') || undefined,
};
}
} catch (error) {
console.warn('无法获取服务器信息:', error);
}
return {};
}
}

238
src/store/useAppStore.ts Normal file
View File

@@ -0,0 +1,238 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { AppState, Repository, Release, AIConfig, WebDAVConfig, SearchFilters, GitHubUser } from '../types';
interface AppActions {
// Auth actions
setUser: (user: GitHubUser | null) => void;
setGitHubToken: (token: string | null) => void;
logout: () => void;
// Repository actions
setRepositories: (repos: Repository[]) => void;
updateRepository: (repo: Repository) => void;
setLoading: (loading: boolean) => void;
setLastSync: (timestamp: string) => void;
// AI actions
addAIConfig: (config: AIConfig) => void;
updateAIConfig: (id: string, updates: Partial<AIConfig>) => void;
deleteAIConfig: (id: string) => void;
setActiveAIConfig: (id: string | null) => void;
// WebDAV actions
addWebDAVConfig: (config: WebDAVConfig) => void;
updateWebDAVConfig: (id: string, updates: Partial<WebDAVConfig>) => void;
deleteWebDAVConfig: (id: string) => void;
setActiveWebDAVConfig: (id: string | null) => void;
setLastBackup: (timestamp: string) => void;
// Search actions
setSearchFilters: (filters: Partial<SearchFilters>) => void;
setSearchResults: (results: Repository[]) => void;
// Release actions
setReleases: (releases: Release[]) => void;
addReleases: (releases: Release[]) => void;
toggleReleaseSubscription: (repoId: number) => void;
// UI actions
setTheme: (theme: 'light' | 'dark') => void;
setCurrentView: (view: 'repositories' | 'releases' | 'settings') => void;
setLanguage: (language: 'zh' | 'en') => void;
}
const initialSearchFilters: SearchFilters = {
query: '',
tags: [],
languages: [],
platforms: [],
sortBy: 'stars',
sortOrder: 'desc',
isAnalyzed: undefined,
isSubscribed: undefined,
};
export const useAppStore = create<AppState & AppActions>()(
persist(
(set, get) => ({
// Initial state
user: null,
githubToken: null,
isAuthenticated: false,
repositories: [],
isLoading: false,
lastSync: null,
aiConfigs: [],
activeAIConfig: null,
webdavConfigs: [],
activeWebDAVConfig: null,
lastBackup: null,
searchFilters: initialSearchFilters,
searchResults: [],
releases: [],
releaseSubscriptions: new Set<number>(),
theme: 'light',
currentView: 'repositories',
language: 'zh',
// Auth actions
setUser: (user) => {
console.log('Setting user:', user);
set({ user, isAuthenticated: !!user });
},
setGitHubToken: (token) => {
console.log('Setting GitHub token:', !!token);
set({ githubToken: token });
},
logout: () => set({
user: null,
githubToken: null,
isAuthenticated: false,
repositories: [],
releases: [],
releaseSubscriptions: new Set(),
searchResults: [],
lastSync: null,
}),
// Repository actions
setRepositories: (repositories) => set({ repositories, searchResults: repositories }),
updateRepository: (repo) => set((state) => {
const updatedRepositories = state.repositories.map(r => r.id === repo.id ? repo : r);
return {
repositories: updatedRepositories,
searchResults: state.searchResults.map(r => r.id === repo.id ? repo : r)
};
}),
setLoading: (isLoading) => set({ isLoading }),
setLastSync: (lastSync) => set({ lastSync }),
// AI actions
addAIConfig: (config) => set((state) => ({
aiConfigs: [...state.aiConfigs, config]
})),
updateAIConfig: (id, updates) => set((state) => ({
aiConfigs: state.aiConfigs.map(config =>
config.id === id ? { ...config, ...updates } : config
)
})),
deleteAIConfig: (id) => set((state) => ({
aiConfigs: state.aiConfigs.filter(config => config.id !== id),
activeAIConfig: state.activeAIConfig === id ? null : state.activeAIConfig
})),
setActiveAIConfig: (activeAIConfig) => set({ activeAIConfig }),
// WebDAV actions
addWebDAVConfig: (config) => set((state) => ({
webdavConfigs: [...state.webdavConfigs, config]
})),
updateWebDAVConfig: (id, updates) => set((state) => ({
webdavConfigs: state.webdavConfigs.map(config =>
config.id === id ? { ...config, ...updates } : config
)
})),
deleteWebDAVConfig: (id) => set((state) => ({
webdavConfigs: state.webdavConfigs.filter(config => config.id !== id),
activeWebDAVConfig: state.activeWebDAVConfig === id ? null : state.activeWebDAVConfig
})),
setActiveWebDAVConfig: (activeWebDAVConfig) => set({ activeWebDAVConfig }),
setLastBackup: (lastBackup) => set({ lastBackup }),
// Search actions
setSearchFilters: (filters) => set((state) => ({
searchFilters: { ...state.searchFilters, ...filters }
})),
setSearchResults: (searchResults) => set({ searchResults }),
// Release actions
setReleases: (releases) => set({ releases }),
addReleases: (newReleases) => set((state) => {
const existingIds = new Set(state.releases.map(r => r.id));
const uniqueReleases = newReleases.filter(r => !existingIds.has(r.id));
return { releases: [...state.releases, ...uniqueReleases] };
}),
toggleReleaseSubscription: (repoId) => set((state) => {
const newSubscriptions = new Set(state.releaseSubscriptions);
if (newSubscriptions.has(repoId)) {
newSubscriptions.delete(repoId);
} else {
newSubscriptions.add(repoId);
}
return { releaseSubscriptions: newSubscriptions };
}),
// UI actions
setTheme: (theme) => set({ theme }),
setCurrentView: (currentView) => set({ currentView }),
setLanguage: (language) => set({ language }),
}),
{
name: 'github-stars-manager',
partialize: (state) => ({
// 持久化用户信息和认证状态
user: state.user,
githubToken: state.githubToken,
isAuthenticated: state.isAuthenticated,
// 持久化仓库数据
repositories: state.repositories,
lastSync: state.lastSync,
// 持久化AI配置
aiConfigs: state.aiConfigs,
activeAIConfig: state.activeAIConfig,
// 持久化WebDAV配置
webdavConfigs: state.webdavConfigs,
activeWebDAVConfig: state.activeWebDAVConfig,
lastBackup: state.lastBackup,
// 持久化Release订阅
releaseSubscriptions: Array.from(state.releaseSubscriptions),
releases: state.releases,
// 持久化UI设置
theme: state.theme,
language: state.language,
}),
onRehydrateStorage: () => (state) => {
if (state) {
// Convert array back to Set
if (Array.isArray(state.releaseSubscriptions)) {
state.releaseSubscriptions = new Set(state.releaseSubscriptions as number[]);
} else {
state.releaseSubscriptions = new Set<number>();
}
// 确保认证状态正确
state.isAuthenticated = !!(state.user && state.githubToken);
// 初始化搜索结果为所有仓库
state.searchResults = state.repositories || [];
// 重置搜索过滤器
state.searchFilters = initialSearchFilters;
// 确保语言设置存在
if (!state.language) {
state.language = 'zh';
}
// 初始化WebDAV配置数组
if (!state.webdavConfigs) {
state.webdavConfigs = [];
}
console.log('Store rehydrated:', {
isAuthenticated: state.isAuthenticated,
repositoriesCount: state.repositories?.length || 0,
lastSync: state.lastSync,
language: state.language,
webdavConfigsCount: state.webdavConfigs?.length || 0
});
}
},
}
)
);

111
src/types/index.ts Normal file
View File

@@ -0,0 +1,111 @@
export interface Repository {
id: number;
name: string;
full_name: string;
description: string | null;
html_url: string;
stargazers_count: number;
language: string | null;
updated_at: string;
pushed_at: string;
owner: {
login: string;
avatar_url: string;
};
topics: string[];
// AI generated fields
ai_summary?: string;
ai_tags?: string[];
ai_platforms?: string[]; // 新增:支持的平台类型
analyzed_at?: string;
// Release subscription
subscribed_to_releases?: boolean;
}
export interface Release {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
html_url: string;
repository: {
id: number;
full_name: string;
name: string;
};
}
export interface GitHubUser {
id: number;
login: string;
name: string;
avatar_url: string;
email: string | null;
}
export interface AIConfig {
id: string;
name: string;
baseUrl: string;
apiKey: string;
model: string;
isActive: boolean;
}
export interface WebDAVConfig {
id: string;
name: string;
url: string;
username: string;
password: string;
path: string;
isActive: boolean;
}
export interface SearchFilters {
query: string;
tags: string[];
languages: string[];
platforms: string[]; // 新增:平台过滤
sortBy: 'stars' | 'updated' | 'name' | 'created';
sortOrder: 'desc' | 'asc';
minStars?: number;
maxStars?: number;
isAnalyzed?: boolean; // 新增是否已AI分析
isSubscribed?: boolean; // 新增是否订阅Release
}
export interface AppState {
// Auth
user: GitHubUser | null;
githubToken: string | null;
isAuthenticated: boolean;
// Repositories
repositories: Repository[];
isLoading: boolean;
lastSync: string | null;
// AI
aiConfigs: AIConfig[];
activeAIConfig: string | null;
// WebDAV
webdavConfigs: WebDAVConfig[];
activeWebDAVConfig: string | null;
lastBackup: string | null;
// Search
searchFilters: SearchFilters;
searchResults: Repository[];
// Releases
releases: Release[];
releaseSubscriptions: Set<number>;
// UI
theme: 'light' | 'dark';
currentView: 'repositories' | 'releases' | 'settings';
language: 'zh' | 'en';
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

68
tailwind.config.js Normal file
View File

@@ -0,0 +1,68 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#0066CC',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
secondary: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
accent: {
50: '#fefbf0',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'bounce-gentle': 'bounceGentle 2s ease-in-out infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
bounceGentle: {
'0%, 20%, 50%, 80%, 100%': { transform: 'translateY(0)' },
'40%': { transform: 'translateY(-4px)' },
'60%': { transform: 'translateY(-2px)' },
}
}
},
},
plugins: [],
};

24
tsconfig.app.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});