This commit is contained in:
AmintaCCCP
2025-07-29 17:40:55 +08:00
parent 055bff5b30
commit 277c340fbf
8 changed files with 748 additions and 247 deletions

149
IMPLEMENTATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,149 @@
# Release 下载功能实现总结
## 已完成的更改
### 1. 新增组件
#### Modal.tsx
- 通用弹窗组件
- 支持 ESC 键关闭
- 点击背景关闭
- 防止页面滚动
#### FilterModal.tsx
- 过滤器编辑弹窗
- 支持新建和编辑过滤器
- 关键词管理(添加/删除)
- 表单验证
#### AssetFilterManager.tsx
- 过滤器管理界面
- 过滤器列表展示
- 过滤器激活/取消激活
- 编辑和删除操作
### 2. 类型定义更新
#### types/index.ts
```typescript
export interface AssetFilter {
id: string;
name: string;
keywords: string[];
}
```
#### AppState 接口更新
```typescript
interface AppState {
// ...其他属性
assetFilters: AssetFilter[];
}
```
### 3. Store 更新
#### useAppStore.ts
- 添加 `assetFilters` 状态
- 添加过滤器管理方法:
- `addAssetFilter`
- `updateAssetFilter`
- `deleteAssetFilter`
- 持久化过滤器配置
### 4. ReleaseTimeline 组件重构
#### 移除的功能
- 平台检测逻辑 (`detectPlatforms`)
- 平台图标显示函数
- 平台颜色映射
- 平台相关的 UI 组件
#### 新增的功能
- 自定义过滤器集成
- 改进的下拉列表显示
- 文件详细信息展示(大小、更新时间、下载次数)
- 整个文件名区域可点击下载
#### 过滤逻辑更新
```typescript
// 旧的平台过滤
if (selectedPlatforms.length > 0) {
// 基于平台检测的过滤
}
// 新的自定义过滤器
if (selectedFilters.length > 0) {
const activeFilters = assetFilters.filter(filter =>
selectedFilters.includes(filter.id)
);
filtered = filtered.filter(release => {
const downloadLinks = getDownloadLinks(release);
return downloadLinks.some(link =>
activeFilters.some(filter =>
filter.keywords.some(keyword =>
link.name.toLowerCase().includes(keyword.toLowerCase())
)
)
);
});
}
```
### 5. UI 改进
#### 下拉列表优化
- 显示文件名、大小、更新时间
- 整个区域可点击
- 悬停效果改进
- 更好的视觉层次
#### 过滤器界面
- 直观的过滤器管理
- 清晰的激活状态指示
- 便捷的编辑和删除操作
## 功能特点
### 1. 灵活的过滤系统
- 用户可以创建任意数量的自定义过滤器
- 支持多关键词匹配
- 可以同时激活多个过滤器
### 2. 完整的文件信息
- 文件名与 GitHub Assets 完全一致
- 显示文件大小(自动格式化)
- 显示更新时间(相对时间)
- 显示下载统计
### 3. 优化的用户体验
- 点击文件名直接下载
- 清晰的视觉反馈
- 响应式设计
- 无障碍访问支持
### 4. 数据持久化
- 过滤器配置自动保存
- 跨会话保持用户设置
- 支持导入导出(通过现有的备份系统)
## 使用流程
1. **创建过滤器**
- 点击"新建过滤器"
- 输入过滤器名称
- 添加匹配关键词
- 保存过滤器
2. **使用过滤器**
- 在过滤器列表中点击过滤器名称
- 系统自动筛选匹配的 Release
- 查看筛选结果
3. **下载文件**
- 点击 Release 的下载按钮查看文件列表
- 点击文件名直接下载
- 查看文件详细信息
这个实现完全满足了用户的需求,提供了更灵活、更直观的 Release 文件管理和下载体验。

86
RELEASE_FILTER_FEATURE.md Normal file
View File

@@ -0,0 +1,86 @@
# Release 下载功能更新
## 功能概述
根据用户需求,我们对 Release 下载功能进行了以下更新:
### 1. 下拉列表显示所有 Assets
- 下拉列表现在显示 GitHub Release 页面的所有 Assets
- 文件名与 GitHub Assets 的文件名完全一致
- 不再区分平台,显示所有可用文件
### 2. 详细文件信息
下拉列表中每个文件显示:
- **文件名**: 与 GitHub Assets 完全一致
- **文件大小**: 格式化显示B, KB, MB, GB
- **更新时间**: 相对时间显示(如 "2 days ago"
- **下载次数**: 显示该文件的下载统计
### 3. 点击下载
- 整个文件名区域都可以点击
- 点击后直接触发下载,打开新标签页
- 支持鼠标悬停效果,提升用户体验
### 4. 自定义过滤器系统
替换原有的平台筛选,改为更灵活的自定义过滤器:
#### 过滤器管理
- **新建过滤器**: 用户可以创建自定义过滤器
- **编辑过滤器**: 修改现有过滤器的名称和关键词
- **删除过滤器**: 删除不需要的过滤器
- **弹窗操作**: 新增和修改都使用弹窗方式
#### 过滤器配置
- **过滤器名称**: 如 "macOS", "Windows", "Linux" 等
- **关键词匹配**: 支持多个关键词,如:
- macOS 过滤器: ["mac", "dmg", "darwin"]
- Windows 过滤器: ["win", "exe", "msi"]
- Linux 过滤器: ["linux", "deb", "rpm", "appimage"]
#### 过滤器使用
- 点击过滤器名称激活/取消激活
- 激活后Release 列表只显示包含匹配关键词的文件
- 支持多个过滤器同时激活
- 提供清除所有筛选的快捷操作
## 技术实现
### 新增组件
1. **FilterModal**: 过滤器编辑弹窗
2. **AssetFilterManager**: 过滤器管理组件
3. **Modal**: 通用弹窗组件
### 数据结构更新
```typescript
interface AssetFilter {
id: string;
name: string;
keywords: string[];
}
```
### Store 更新
- 添加 `assetFilters` 状态
- 添加过滤器的增删改查操作
- 持久化过滤器配置
### UI 改进
- 移除平台图标显示
- 优化下拉列表布局
- 增强文件信息展示
- 改进交互体验
## 使用示例
### 创建 macOS 过滤器
1. 点击"新建过滤器"按钮
2. 输入名称: "macOS"
3. 添加关键词: "mac", "dmg", "darwin"
4. 点击"创建"
### 使用过滤器
1. 在过滤器列表中点击"macOS"
2. Release 列表自动筛选,只显示包含 macOS 相关文件的 Release
3. 点击文件名直接下载
这个更新让用户能够更灵活地筛选和下载所需的文件,提供了更好的用户体验。

View File

@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { Plus, Edit3, Trash2, Filter } from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
import { FilterModal } from './FilterModal';
import { AssetFilter } from '../types';
interface AssetFilterManagerProps {
selectedFilters: string[];
onFilterToggle: (filterId: string) => void;
onClearFilters: () => void;
}
export const AssetFilterManager: React.FC<AssetFilterManagerProps> = ({
selectedFilters,
onFilterToggle,
onClearFilters
}) => {
const { assetFilters, addAssetFilter, updateAssetFilter, deleteAssetFilter, language } = useAppStore();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingFilter, setEditingFilter] = useState<AssetFilter | undefined>();
const handleCreateFilter = () => {
setEditingFilter(undefined);
setIsModalOpen(true);
};
const handleEditFilter = (filter: AssetFilter) => {
setEditingFilter(filter);
setIsModalOpen(true);
};
const handleDeleteFilter = (filterId: string) => {
if (confirm(language === 'zh' ? '确定要删除这个过滤器吗?' : 'Are you sure you want to delete this filter?')) {
deleteAssetFilter(filterId);
// Remove from selected filters if it was selected
if (selectedFilters.includes(filterId)) {
onFilterToggle(filterId);
}
}
};
const handleSaveFilter = (filter: AssetFilter) => {
if (editingFilter) {
updateAssetFilter(filter.id, filter);
} else {
addAssetFilter(filter);
}
};
const t = (zh: string, en: string) => language === 'zh' ? zh : en;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Filter className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{t('自定义过滤器', 'Custom Filters')}
</h3>
</div>
<button
onClick={handleCreateFilter}
className="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
<Plus className="w-4 h-4" />
<span>{t('新建过滤器', 'New Filter')}</span>
</button>
</div>
{/* Filters List */}
{assetFilters.length > 0 ? (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{assetFilters.map(filter => (
<div
key={filter.id}
className={`group flex items-center space-x-2 px-3 py-2 rounded-lg border transition-colors ${
selectedFilters.includes(filter.id)
? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900 dark:border-blue-700 dark:text-blue-300'
: 'bg-gray-100 border-gray-300 text-gray-700 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<button
onClick={() => onFilterToggle(filter.id)}
className="flex items-center space-x-2 flex-1"
>
<span className="font-medium">{filter.name}</span>
<span className="text-xs opacity-75">
({filter.keywords.join(', ')})
</span>
</button>
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEditFilter(filter)}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={t('编辑', 'Edit')}
>
<Edit3 className="w-3 h-3" />
</button>
<button
onClick={() => handleDeleteFilter(filter.id)}
className="p-1 rounded hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900 dark:hover:text-red-400 transition-colors"
title={t('删除', 'Delete')}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
{selectedFilters.length > 0 && (
<div className="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">
{t(`已选择 ${selectedFilters.length} 个过滤器`, `${selectedFilters.length} filters selected`)}
</span>
<button
onClick={onClearFilters}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
{t('清除选择', 'Clear Selection')}
</button>
</div>
)}
</div>
) : (
<div className="text-center py-8 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<Filter className="w-12 h-12 text-gray-400 dark:text-gray-600 mx-auto mb-3" />
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('暂无自定义过滤器', 'No Custom Filters')}
</h4>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{t('创建过滤器来快速筛选特定类型的文件', 'Create filters to quickly find specific types of files')}
</p>
<button
onClick={handleCreateFilter}
className="inline-flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
<span>{t('创建第一个过滤器', 'Create First Filter')}</span>
</button>
</div>
)}
{/* Filter Modal */}
<FilterModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
filter={editingFilter}
onSave={handleSaveFilter}
/>
</div>
);
};

View File

@@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { Modal } from './Modal';
import { AssetFilter } from '../types';
interface FilterModalProps {
isOpen: boolean;
onClose: () => void;
filter?: AssetFilter;
onSave: (filter: AssetFilter) => void;
}
export const FilterModal: React.FC<FilterModalProps> = ({
isOpen,
onClose,
filter,
onSave
}) => {
const [name, setName] = useState('');
const [keywords, setKeywords] = useState<string[]>([]);
const [newKeyword, setNewKeyword] = useState('');
useEffect(() => {
if (filter) {
setName(filter.name);
setKeywords([...filter.keywords]);
} else {
setName('');
setKeywords([]);
}
setNewKeyword('');
}, [filter, isOpen]);
const handleAddKeyword = () => {
const trimmed = newKeyword.trim();
if (trimmed && !keywords.includes(trimmed)) {
setKeywords([...keywords, trimmed]);
setNewKeyword('');
}
};
const handleRemoveKeyword = (index: number) => {
setKeywords(keywords.filter((_, i) => i !== index));
};
const handleSave = () => {
if (!name.trim() || keywords.length === 0) {
return;
}
const savedFilter: AssetFilter = {
id: filter?.id || Date.now().toString(),
name: name.trim(),
keywords: keywords.filter(k => k.trim())
};
onSave(savedFilter);
onClose();
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddKeyword();
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={filter ? '编辑过滤器' : '新建过滤器'}>
<div className="space-y-4">
{/* Filter Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如: macOS"
className="w-full px-3 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"
/>
</div>
{/* Keywords */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
{/* Add keyword input */}
<div className="flex space-x-2 mb-3">
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入关键词,如: mac, dmg"
className="flex-1 px-3 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"
/>
<button
onClick={handleAddKeyword}
disabled={!newKeyword.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<Plus className="w-4 h-4" />
<span></span>
</button>
</div>
{/* Keywords list */}
{keywords.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
:
</p>
<div className="flex flex-wrap gap-2">
{keywords.map((keyword, index) => (
<div
key={index}
className="flex items-center space-x-1 px-3 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 rounded-lg text-sm"
>
<span>{keyword}</span>
<button
onClick={() => handleRemoveKeyword(index)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{keywords.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
)}
</div>
{/* Help text */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<p className="text-sm text-blue-700 dark:text-blue-300">
<strong>:</strong> GitHub Release "mac" "dmg"
</p>
</div>
{/* Action buttons */}
<div className="flex justify-end space-x-3 pt-4">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
</button>
<button
onClick={handleSave}
disabled={!name.trim() || keywords.length === 0}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{filter ? '保存' : '创建'}
</button>
</div>
</div>
</Modal>
);
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
@@ -16,6 +16,26 @@ export const Modal: React.FC<ModalProps> = ({
children,
maxWidth = 'max-w-md'
}) => {
// Close modal on Escape key press
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
@@ -28,7 +48,10 @@ export const Modal: React.FC<ModalProps> = ({
{/* Modal */}
<div className="flex min-h-full items-center justify-center p-4">
<div className={`relative w-full ${maxWidth} bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700`}>
<div
className={`relative w-full ${maxWidth} bg-white dark:bg-gray-800 rounded-xl shadow-xl transform transition-all`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">

View File

@@ -1,9 +1,10 @@
import React, { useState, useMemo, useEffect } from 'react';
import { ExternalLink, GitBranch, Calendar, Package, Bell, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Eye, EyeOff, Apple, Monitor, Terminal, Smartphone, Globe, Download, ChevronDown } from 'lucide-react';
import { ExternalLink, GitBranch, Calendar, Package, Bell, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Eye, EyeOff, Download, ChevronDown } from 'lucide-react';
import { Release } from '../types';
import { useAppStore } from '../store/useAppStore';
import { GitHubApiService } from '../services/githubApi';
import { formatDistanceToNow, format } from 'date-fns';
import { AssetFilterManager } from './AssetFilterManager';
export const ReleaseTimeline: React.FC = () => {
const {
@@ -13,13 +14,14 @@ export const ReleaseTimeline: React.FC = () => {
readReleases,
githubToken,
language,
assetFilters,
setReleases,
addReleases,
markReleaseAsRead,
} = useAppStore();
const [searchQuery, setSearchQuery] = useState('');
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [lastRefreshTime, setLastRefreshTime] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
@@ -64,74 +66,17 @@ export const ReleaseTimeline: React.FC = () => {
});
};
// 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) => {
const links: Array<{ name: string; url: string; platforms: string[]; size: number; downloadCount: number }> = [];
const links: Array<{ name: string; url: string; size: number; downloadCount: number }> = [];
// Use GitHub release assets (this is the correct way to get downloads)
if (release.assets && release.assets.length > 0) {
release.assets.forEach(asset => {
const platforms = detectPlatforms(asset.name);
links.push({
name: asset.name,
url: asset.browser_download_url,
platforms,
size: asset.size,
downloadCount: asset.download_count
});
@@ -147,10 +92,9 @@ export const ReleaseTimeline: React.FC = () => {
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);
// Avoid duplicates with assets
if (!links.some(link => link.url === url || link.name === name)) {
links.push({ name, url, platforms, size: 0, downloadCount: 0 });
links.push({ name, url, size: 0, downloadCount: 0 });
}
}
}
@@ -163,7 +107,7 @@ export const ReleaseTimeline: React.FC = () => {
releaseSubscriptions.has(release.repository.id)
);
// Apply search and platform filters
// Apply search and custom filters
const filteredReleases = useMemo(() => {
let filtered = subscribedReleases;
@@ -179,12 +123,19 @@ export const ReleaseTimeline: React.FC = () => {
);
}
// Platform filter
if (selectedPlatforms.length > 0) {
// Custom asset filters
if (selectedFilters.length > 0) {
const activeFilters = assetFilters.filter(filter => selectedFilters.includes(filter.id));
filtered = filtered.filter(release => {
const downloadLinks = getDownloadLinks(release);
return downloadLinks.some(link =>
selectedPlatforms.some(platform => link.platforms.includes(platform))
activeFilters.some(filter =>
filter.keywords.some(keyword =>
link.name.toLowerCase().includes(keyword.toLowerCase())
)
)
);
});
}
@@ -192,24 +143,27 @@ export const ReleaseTimeline: React.FC = () => {
return filtered.sort((a, b) =>
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
);
}, [subscribedReleases, searchQuery, selectedPlatforms]);
}, [subscribedReleases, searchQuery, selectedFilters, assetFilters]);
// 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]);
// Filter handlers
const handleFilterToggle = (filterId: string) => {
setSelectedFilters(prev =>
prev.includes(filterId)
? prev.filter(id => id !== filterId)
: [...prev, filterId]
);
setCurrentPage(1); // Reset to first page when filtering
};
const handleClearFilters = () => {
setSelectedFilters([]);
setCurrentPage(1);
};
const handleRefresh = async () => {
if (!githubToken) {
@@ -286,18 +240,9 @@ export const ReleaseTimeline: React.FC = () => {
}
};
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 = () => {
const clearAllFilters = () => {
setSearchQuery('');
setSelectedPlatforms([]);
setSelectedFilters([]);
setCurrentPage(1);
};
@@ -331,51 +276,7 @@ export const ReleaseTimeline: React.FC = () => {
return rangeWithDots;
};
const getPlatformIcon = (platform: string) => {
const platformLower = platform.toLowerCase();
switch (platformLower) {
case 'windows':
return Monitor;
case 'macos':
case 'mac':
case 'ios':
return Apple;
case 'linux':
return Terminal;
case 'android':
return Smartphone;
case 'universal':
default:
return Download;
}
};
const getPlatformDisplayName = (platform: string) => {
const platformLower = platform.toLowerCase();
const nameMap: Record<string, string> = {
windows: 'Windows',
macos: 'macOS',
mac: 'macOS',
linux: 'Linux',
android: 'Android',
ios: 'iOS',
universal: 'Universal'
};
return nameMap[platformLower] || platform;
};
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;
@@ -529,35 +430,23 @@ export const ReleaseTimeline: React.FC = () => {
)}
</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'
}`}
>
{React.createElement(getPlatformIcon(platform), { className: "w-4 h-4" })}
<span className="capitalize">{getPlatformDisplayName(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>
)}
{/* Custom Asset Filters */}
<AssetFilterManager
selectedFilters={selectedFilters}
onFilterToggle={handleFilterToggle}
onClearFilters={handleClearFilters}
/>
{/* Clear All Filters */}
{(searchQuery || selectedFilters.length > 0) && (
<div className="flex justify-end pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={clearAllFilters}
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 All Filters')}</span>
</button>
</div>
)}
</div>
@@ -571,7 +460,7 @@ export const ReleaseTimeline: React.FC = () => {
`Showing ${startIndex + 1}-${Math.min(startIndex + itemsPerPage, filteredReleases.length)} of ${filteredReleases.length} releases`
)}
</span>
{(searchQuery || selectedPlatforms.length > 0) && (
{(searchQuery || selectedFilters.length > 0) && (
<span className="text-sm text-blue-600 dark:text-blue-400">
({t('已筛选', 'filtered')})
</span>
@@ -732,48 +621,44 @@ export const ReleaseTimeline: React.FC = () => {
</div>
{openDropdowns.has(release.id) && (
<div className="absolute z-10 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{downloadLinks.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-600 last:border-b-0"
onClick={(e) => {
e.stopPropagation();
handleReleaseClick(release.id);
toggleDropdown(release.id);
}}
>
<div className="flex items-center space-x-3 min-w-0 flex-1">
<div className="flex items-center space-x-1 flex-shrink-0">
{link.platforms.map((platform, pIndex) => {
const IconComponent = getPlatformIcon(platform);
return (
<IconComponent
key={pIndex}
className={`w-4 h-4 ${getPlatformColor(platform)}`}
title={getPlatformDisplayName(platform)}
/>
);
})}
</div>
<div className="absolute z-10 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-80 overflow-y-auto">
{downloadLinks.map((link, index) => {
const asset = release.assets.find(asset => asset.name === link.name);
return (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-600 last:border-b-0 group"
onClick={(e) => {
e.stopPropagation();
handleReleaseClick(release.id);
toggleDropdown(release.id);
}}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate group-hover:text-blue-600 dark:group-hover:text-blue-400">
{link.name}
</div>
{link.size > 0 && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(link.size)}
{link.downloadCount > 0 && `${link.downloadCount.toLocaleString()} ${t('下载', 'downloads')}`}
</div>
)}
<div className="flex items-center space-x-3 text-xs text-gray-500 dark:text-gray-400 mt-1">
{link.size > 0 && (
<span>{formatFileSize(link.size)}</span>
)}
{asset?.updated_at && (
<span>
{formatDistanceToNow(new Date(asset.updated_at), { addSuffix: true })}
</span>
)}
{link.downloadCount > 0 && (
<span>{link.downloadCount.toLocaleString()} {t('次下载', 'downloads')}</span>
)}
</div>
</div>
</div>
<Download className="w-4 h-4 text-gray-400 flex-shrink-0" />
</a>
))}
<Download className="w-4 h-4 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 flex-shrink-0" />
</a>
);
})}
</div>
)}
</div>
@@ -852,47 +737,44 @@ export const ReleaseTimeline: React.FC = () => {
</button>
{openDropdowns.has(release.id) && (
<div className="absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{downloadLinks.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-600 last:border-b-0"
onClick={(e) => {
e.stopPropagation();
handleReleaseClick(release.id);
toggleDropdown(release.id);
}}
>
<div className="flex items-center space-x-2 min-w-0 flex-1">
<div className="flex items-center space-x-1 flex-shrink-0">
{link.platforms.slice(0, 2).map((platform, pIndex) => {
const IconComponent = getPlatformIcon(platform);
return (
<IconComponent
key={pIndex}
className={`w-3 h-3 ${getPlatformColor(platform)}`}
title={getPlatformDisplayName(platform)}
/>
);
})}
</div>
<div className="absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{downloadLinks.map((link, index) => {
const asset = release.assets.find(asset => asset.name === link.name);
return (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-600 last:border-b-0 group"
onClick={(e) => {
e.stopPropagation();
handleReleaseClick(release.id);
toggleDropdown(release.id);
}}
>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate group-hover:text-blue-600 dark:group-hover:text-blue-400">
{link.name}
</div>
{link.size > 0 && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(link.size)}
</div>
)}
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400 mt-1">
{link.size > 0 && (
<span>{formatFileSize(link.size)}</span>
)}
{asset?.updated_at && (
<span>
{formatDistanceToNow(new Date(asset.updated_at), { addSuffix: true })}
</span>
)}
{link.downloadCount > 0 && (
<span>{link.downloadCount.toLocaleString()} {t('次下载', 'downloads')}</span>
)}
</div>
</div>
</div>
<Download className="w-3 h-3 text-gray-400 flex-shrink-0" />
</a>
))}
<Download className="w-3 h-3 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 flex-shrink-0" />
</a>
);
})}
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { AppState, Repository, Release, AIConfig, WebDAVConfig, SearchFilters, GitHubUser, Category } from '../types';
import { AppState, Repository, Release, AIConfig, WebDAVConfig, SearchFilters, GitHubUser, Category, AssetFilter } from '../types';
interface AppActions {
// Auth actions
@@ -43,6 +43,11 @@ interface AppActions {
updateCustomCategory: (id: string, updates: Partial<Category>) => void;
deleteCustomCategory: (id: string) => void;
// Asset Filter actions
addAssetFilter: (filter: AssetFilter) => void;
updateAssetFilter: (id: string, updates: Partial<AssetFilter>) => void;
deleteAssetFilter: (id: string) => void;
// UI actions
setTheme: (theme: 'light' | 'dark') => void;
setCurrentView: (view: 'repositories' | 'releases' | 'settings') => void;
@@ -168,6 +173,7 @@ export const useAppStore = create<AppState & AppActions>()(
releaseSubscriptions: new Set<number>(),
readReleases: new Set<number>(),
customCategories: [],
assetFilters: [],
theme: 'light',
currentView: 'repositories',
language: 'zh',
@@ -284,6 +290,19 @@ export const useAppStore = create<AppState & AppActions>()(
customCategories: state.customCategories.filter(category => category.id !== id)
})),
// Asset Filter actions
addAssetFilter: (filter) => set((state) => ({
assetFilters: [...state.assetFilters, filter]
})),
updateAssetFilter: (id, updates) => set((state) => ({
assetFilters: state.assetFilters.map(filter =>
filter.id === id ? { ...filter, ...updates } : filter
)
})),
deleteAssetFilter: (id) => set((state) => ({
assetFilters: state.assetFilters.filter(filter => filter.id !== id)
})),
// UI actions
setTheme: (theme) => set({ theme }),
setCurrentView: (currentView) => set({ currentView }),
@@ -318,6 +337,9 @@ export const useAppStore = create<AppState & AppActions>()(
// 持久化自定义分类
customCategories: state.customCategories,
// 持久化资源过滤器
assetFilters: state.assetFilters,
// 持久化UI设置
theme: state.theme,
language: state.language,
@@ -361,6 +383,11 @@ export const useAppStore = create<AppState & AppActions>()(
state.customCategories = [];
}
// 初始化资源过滤器
if (!state.assetFilters) {
state.assetFilters = [];
}
console.log('Store rehydrated:', {
isAuthenticated: state.isAuthenticated,
repositoriesCount: state.repositories?.length || 0,

View File

@@ -105,6 +105,12 @@ export interface Category {
isCustom?: boolean;
}
export interface AssetFilter {
id: string;
name: string;
keywords: string[];
}
export interface AppState {
// Auth
user: GitHubUser | null;
@@ -137,6 +143,9 @@ export interface AppState {
// Categories
customCategories: Category[]; // 新增:自定义分类
// Asset Filters
assetFilters: AssetFilter[]; // 新增:资源过滤器
// UI
theme: 'light' | 'dark';
currentView: 'repositories' | 'releases' | 'settings';