mirror of
https://github.com/AmintaCCCP/GithubStarsManager.git
synced 2025-11-25 02:34:54 +08:00
0.1.1
This commit is contained in:
149
IMPLEMENTATION_SUMMARY.md
Normal file
149
IMPLEMENTATION_SUMMARY.md
Normal 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
86
RELEASE_FILTER_FEATURE.md
Normal 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. 点击文件名直接下载
|
||||||
|
|
||||||
|
这个更新让用户能够更灵活地筛选和下载所需的文件,提供了更好的用户体验。
|
||||||
156
src/components/AssetFilterManager.tsx
Normal file
156
src/components/AssetFilterManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
169
src/components/FilterModal.tsx
Normal file
169
src/components/FilterModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
@@ -9,13 +9,33 @@ interface ModalProps {
|
|||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<ModalProps> = ({
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
maxWidth = 'max-w-md'
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,7 +48,10 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="flex min-h-full items-center justify-center p-4">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
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 { Release } from '../types';
|
||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
import { GitHubApiService } from '../services/githubApi';
|
import { GitHubApiService } from '../services/githubApi';
|
||||||
import { formatDistanceToNow, format } from 'date-fns';
|
import { formatDistanceToNow, format } from 'date-fns';
|
||||||
|
import { AssetFilterManager } from './AssetFilterManager';
|
||||||
|
|
||||||
export const ReleaseTimeline: React.FC = () => {
|
export const ReleaseTimeline: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -13,13 +14,14 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
readReleases,
|
readReleases,
|
||||||
githubToken,
|
githubToken,
|
||||||
language,
|
language,
|
||||||
|
assetFilters,
|
||||||
setReleases,
|
setReleases,
|
||||||
addReleases,
|
addReleases,
|
||||||
markReleaseAsRead,
|
markReleaseAsRead,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [lastRefreshTime, setLastRefreshTime] = useState<string | null>(null);
|
const [lastRefreshTime, setLastRefreshTime] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
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 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)
|
// Use GitHub release assets (this is the correct way to get downloads)
|
||||||
if (release.assets && release.assets.length > 0) {
|
if (release.assets && release.assets.length > 0) {
|
||||||
release.assets.forEach(asset => {
|
release.assets.forEach(asset => {
|
||||||
const platforms = detectPlatforms(asset.name);
|
|
||||||
links.push({
|
links.push({
|
||||||
name: asset.name,
|
name: asset.name,
|
||||||
url: asset.browser_download_url,
|
url: asset.browser_download_url,
|
||||||
platforms,
|
|
||||||
size: asset.size,
|
size: asset.size,
|
||||||
downloadCount: asset.download_count
|
downloadCount: asset.download_count
|
||||||
});
|
});
|
||||||
@@ -147,10 +92,9 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
if (url.includes('/download/') || url.includes('/releases/') ||
|
if (url.includes('/download/') || url.includes('/releases/') ||
|
||||||
name.toLowerCase().includes('download') ||
|
name.toLowerCase().includes('download') ||
|
||||||
/\.(exe|dmg|deb|rpm|apk|ipa|zip|tar\.gz|msi|pkg|appimage)$/i.test(url)) {
|
/\.(exe|dmg|deb|rpm|apk|ipa|zip|tar\.gz|msi|pkg|appimage)$/i.test(url)) {
|
||||||
const platforms = detectPlatforms(name + ' ' + url);
|
|
||||||
// Avoid duplicates with assets
|
// Avoid duplicates with assets
|
||||||
if (!links.some(link => link.url === url || link.name === name)) {
|
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)
|
releaseSubscriptions.has(release.repository.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply search and platform filters
|
// Apply search and custom filters
|
||||||
const filteredReleases = useMemo(() => {
|
const filteredReleases = useMemo(() => {
|
||||||
let filtered = subscribedReleases;
|
let filtered = subscribedReleases;
|
||||||
|
|
||||||
@@ -179,12 +123,19 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform filter
|
// Custom asset filters
|
||||||
if (selectedPlatforms.length > 0) {
|
if (selectedFilters.length > 0) {
|
||||||
|
const activeFilters = assetFilters.filter(filter => selectedFilters.includes(filter.id));
|
||||||
|
|
||||||
filtered = filtered.filter(release => {
|
filtered = filtered.filter(release => {
|
||||||
const downloadLinks = getDownloadLinks(release);
|
const downloadLinks = getDownloadLinks(release);
|
||||||
|
|
||||||
return downloadLinks.some(link =>
|
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) =>
|
return filtered.sort((a, b) =>
|
||||||
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
|
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
|
||||||
);
|
);
|
||||||
}, [subscribedReleases, searchQuery, selectedPlatforms]);
|
}, [subscribedReleases, searchQuery, selectedFilters, assetFilters]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const totalPages = Math.ceil(filteredReleases.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredReleases.length / itemsPerPage);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const paginatedReleases = filteredReleases.slice(startIndex, startIndex + itemsPerPage);
|
const paginatedReleases = filteredReleases.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
|
||||||
// Get available platforms from all releases
|
// Filter handlers
|
||||||
const availablePlatforms = useMemo(() => {
|
const handleFilterToggle = (filterId: string) => {
|
||||||
const platforms = new Set<string>();
|
setSelectedFilters(prev =>
|
||||||
subscribedReleases.forEach(release => {
|
prev.includes(filterId)
|
||||||
const downloadLinks = getDownloadLinks(release);
|
? prev.filter(id => id !== filterId)
|
||||||
downloadLinks.forEach(link => {
|
: [...prev, filterId]
|
||||||
link.platforms.forEach(platform => platforms.add(platform));
|
);
|
||||||
});
|
setCurrentPage(1); // Reset to first page when filtering
|
||||||
});
|
};
|
||||||
return Array.from(platforms).sort();
|
|
||||||
}, [subscribedReleases]);
|
const handleClearFilters = () => {
|
||||||
|
setSelectedFilters([]);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
if (!githubToken) {
|
if (!githubToken) {
|
||||||
@@ -286,18 +240,9 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlatformToggle = (platform: string) => {
|
const clearAllFilters = () => {
|
||||||
setSelectedPlatforms(prev =>
|
|
||||||
prev.includes(platform)
|
|
||||||
? prev.filter(p => p !== platform)
|
|
||||||
: [...prev, platform]
|
|
||||||
);
|
|
||||||
setCurrentPage(1); // Reset to first page when filtering
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedPlatforms([]);
|
setSelectedFilters([]);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,51 +276,7 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
return rangeWithDots;
|
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) => {
|
const truncateBody = (body: string, maxLength = 200) => {
|
||||||
if (body.length <= maxLength) return body;
|
if (body.length <= maxLength) return body;
|
||||||
@@ -529,35 +430,23 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform Filters */}
|
{/* Custom Asset Filters */}
|
||||||
{availablePlatforms.length > 0 && (
|
<AssetFilterManager
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
selectedFilters={selectedFilters}
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 mr-2">
|
onFilterToggle={handleFilterToggle}
|
||||||
{t('平台:', 'Platforms:')}
|
onClearFilters={handleClearFilters}
|
||||||
</span>
|
/>
|
||||||
{availablePlatforms.map(platform => (
|
|
||||||
<button
|
{/* Clear All Filters */}
|
||||||
key={platform}
|
{(searchQuery || selectedFilters.length > 0) && (
|
||||||
onClick={() => handlePlatformToggle(platform)}
|
<div className="flex justify-end pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
<button
|
||||||
selectedPlatforms.includes(platform)
|
onClick={clearAllFilters}
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
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"
|
||||||
: '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('清除所有筛选', 'Clear All Filters')}</span>
|
||||||
{React.createElement(getPlatformIcon(platform), { className: "w-4 h-4" })}
|
</button>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +460,7 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
`Showing ${startIndex + 1}-${Math.min(startIndex + itemsPerPage, filteredReleases.length)} of ${filteredReleases.length} releases`
|
`Showing ${startIndex + 1}-${Math.min(startIndex + itemsPerPage, filteredReleases.length)} of ${filteredReleases.length} releases`
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{(searchQuery || selectedPlatforms.length > 0) && (
|
{(searchQuery || selectedFilters.length > 0) && (
|
||||||
<span className="text-sm text-blue-600 dark:text-blue-400">
|
<span className="text-sm text-blue-600 dark:text-blue-400">
|
||||||
({t('已筛选', 'filtered')})
|
({t('已筛选', 'filtered')})
|
||||||
</span>
|
</span>
|
||||||
@@ -732,48 +621,44 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{openDropdowns.has(release.id) && (
|
{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">
|
<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) => (
|
{downloadLinks.map((link, index) => {
|
||||||
<a
|
const asset = release.assets.find(asset => asset.name === link.name);
|
||||||
key={index}
|
return (
|
||||||
href={link.url}
|
<a
|
||||||
target="_blank"
|
key={index}
|
||||||
rel="noopener noreferrer"
|
href={link.url}
|
||||||
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"
|
target="_blank"
|
||||||
onClick={(e) => {
|
rel="noopener noreferrer"
|
||||||
e.stopPropagation();
|
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"
|
||||||
handleReleaseClick(release.id);
|
onClick={(e) => {
|
||||||
toggleDropdown(release.id);
|
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="min-w-0 flex-1">
|
<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}
|
{link.name}
|
||||||
</div>
|
</div>
|
||||||
{link.size > 0 && (
|
<div className="flex items-center space-x-3 text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
{link.size > 0 && (
|
||||||
{formatFileSize(link.size)}
|
<span>{formatFileSize(link.size)}</span>
|
||||||
{link.downloadCount > 0 && ` • ${link.downloadCount.toLocaleString()} ${t('下载', 'downloads')}`}
|
)}
|
||||||
</div>
|
{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>
|
||||||
</div>
|
<Download className="w-4 h-4 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 flex-shrink-0" />
|
||||||
<Download className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
</a>
|
||||||
</a>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -852,47 +737,44 @@ export const ReleaseTimeline: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{openDropdowns.has(release.id) && (
|
{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">
|
<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) => (
|
{downloadLinks.map((link, index) => {
|
||||||
<a
|
const asset = release.assets.find(asset => asset.name === link.name);
|
||||||
key={index}
|
return (
|
||||||
href={link.url}
|
<a
|
||||||
target="_blank"
|
key={index}
|
||||||
rel="noopener noreferrer"
|
href={link.url}
|
||||||
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"
|
target="_blank"
|
||||||
onClick={(e) => {
|
rel="noopener noreferrer"
|
||||||
e.stopPropagation();
|
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"
|
||||||
handleReleaseClick(release.id);
|
onClick={(e) => {
|
||||||
toggleDropdown(release.id);
|
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="min-w-0 flex-1">
|
<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}
|
{link.name}
|
||||||
</div>
|
</div>
|
||||||
{link.size > 0 && (
|
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
{link.size > 0 && (
|
||||||
{formatFileSize(link.size)}
|
<span>{formatFileSize(link.size)}</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
{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>
|
||||||
</div>
|
<Download className="w-3 h-3 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 flex-shrink-0" />
|
||||||
<Download className="w-3 h-3 text-gray-400 flex-shrink-0" />
|
</a>
|
||||||
</a>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
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 {
|
interface AppActions {
|
||||||
// Auth actions
|
// Auth actions
|
||||||
@@ -43,6 +43,11 @@ interface AppActions {
|
|||||||
updateCustomCategory: (id: string, updates: Partial<Category>) => void;
|
updateCustomCategory: (id: string, updates: Partial<Category>) => void;
|
||||||
deleteCustomCategory: (id: string) => 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
|
// UI actions
|
||||||
setTheme: (theme: 'light' | 'dark') => void;
|
setTheme: (theme: 'light' | 'dark') => void;
|
||||||
setCurrentView: (view: 'repositories' | 'releases' | 'settings') => void;
|
setCurrentView: (view: 'repositories' | 'releases' | 'settings') => void;
|
||||||
@@ -168,6 +173,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
releaseSubscriptions: new Set<number>(),
|
releaseSubscriptions: new Set<number>(),
|
||||||
readReleases: new Set<number>(),
|
readReleases: new Set<number>(),
|
||||||
customCategories: [],
|
customCategories: [],
|
||||||
|
assetFilters: [],
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
currentView: 'repositories',
|
currentView: 'repositories',
|
||||||
language: 'zh',
|
language: 'zh',
|
||||||
@@ -284,6 +290,19 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
customCategories: state.customCategories.filter(category => category.id !== id)
|
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
|
// UI actions
|
||||||
setTheme: (theme) => set({ theme }),
|
setTheme: (theme) => set({ theme }),
|
||||||
setCurrentView: (currentView) => set({ currentView }),
|
setCurrentView: (currentView) => set({ currentView }),
|
||||||
@@ -318,6 +337,9 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// 持久化自定义分类
|
// 持久化自定义分类
|
||||||
customCategories: state.customCategories,
|
customCategories: state.customCategories,
|
||||||
|
|
||||||
|
// 持久化资源过滤器
|
||||||
|
assetFilters: state.assetFilters,
|
||||||
|
|
||||||
// 持久化UI设置
|
// 持久化UI设置
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
language: state.language,
|
language: state.language,
|
||||||
@@ -361,6 +383,11 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
state.customCategories = [];
|
state.customCategories = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化资源过滤器
|
||||||
|
if (!state.assetFilters) {
|
||||||
|
state.assetFilters = [];
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Store rehydrated:', {
|
console.log('Store rehydrated:', {
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
repositoriesCount: state.repositories?.length || 0,
|
repositoriesCount: state.repositories?.length || 0,
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ export interface Category {
|
|||||||
isCustom?: boolean;
|
isCustom?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssetFilter {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
// Auth
|
// Auth
|
||||||
user: GitHubUser | null;
|
user: GitHubUser | null;
|
||||||
@@ -137,6 +143,9 @@ export interface AppState {
|
|||||||
// Categories
|
// Categories
|
||||||
customCategories: Category[]; // 新增:自定义分类
|
customCategories: Category[]; // 新增:自定义分类
|
||||||
|
|
||||||
|
// Asset Filters
|
||||||
|
assetFilters: AssetFilter[]; // 新增:资源过滤器
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
currentView: 'repositories' | 'releases' | 'settings';
|
currentView: 'repositories' | 'releases' | 'settings';
|
||||||
|
|||||||
Reference in New Issue
Block a user