mirror of
https://github.com/AmintaCCCP/GithubStarsManager.git
synced 2025-11-24 18:32:51 +08:00
Initialization
This commit is contained in:
506
dist/assets/index-Dnv_KkUR.js
vendored
Normal file
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
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
19
dist/index.html
vendored
Normal 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
28
eslint.config.js
Normal 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
18
index.html
Normal 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
4133
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
75
src/App.tsx
Normal file
75
src/App.tsx
Normal 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;
|
||||
194
src/components/CategorySidebar.tsx
Normal file
194
src/components/CategorySidebar.tsx
Normal 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
208
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
163
src/components/LoginScreen.tsx
Normal file
163
src/components/LoginScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
804
src/components/ReleaseTimeline.tsx
Normal file
804
src/components/ReleaseTimeline.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
300
src/components/RepositoryCard.tsx
Normal file
300
src/components/RepositoryCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
416
src/components/RepositoryList.tsx
Normal file
416
src/components/RepositoryList.tsx
Normal 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;
|
||||
}
|
||||
520
src/components/SearchBar.tsx
Normal file
520
src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1112
src/components/SettingsPanel.tsx
Normal file
1112
src/components/SettingsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
12
src/index.css
Normal file
12
src/index.css
Normal 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
22
src/main.tsx
Normal 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
431
src/services/aiService.ts
Normal 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
189
src/services/githubApi.ts
Normal 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()}`;
|
||||
};
|
||||
384
src/services/webdavService.ts
Normal file
384
src/services/webdavService.ts
Normal 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://)
|
||||
• 如果应用使用HTTPS,WebDAV服务器也应使用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
238
src/store/useAppStore.ts
Normal 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
111
src/types/index.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
68
tailwind.config.js
Normal file
68
tailwind.config.js
Normal 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
24
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal 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
10
vite.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user