39 Commits

Author SHA1 Message Date
tamina
fd41b4504a Add version 0.1.7 with changelog updates
Added version 0.1.7 with a fix for webdav backup recovery issue.
2025-11-16 17:01:11 +08:00
tamina
4c3ba04a25 Update updateService.ts 2025-11-16 16:48:47 +08:00
tamina
008a3250bf Update current version to v0.1.7 2025-11-16 16:47:38 +08:00
tamina
593a319f38 Bump version from 0.1.6 to 0.1.7 2025-11-16 16:45:23 +08:00
tamina
1a2e61b257 Merge pull request #35 from loveFeng/request
fix:修复webdav备份恢复问题
2025-11-15 23:53:55 +08:00
Joe
56453a728f fix:修复webdav备份恢复问题 2025-11-14 16:39:31 +08:00
tamina
094db2697c Update version-info.xml 2025-10-27 10:05:41 +08:00
tamina
6136d6ee29 Update updateService.ts 2025-10-27 09:56:12 +08:00
tamina
627667750a Update current version to v0.1.6 2025-10-27 09:55:24 +08:00
tamina
b0982f8358 Update package.json 2025-10-27 09:53:37 +08:00
tamina
9ed8583daa Merge pull request #30 from rootwhois/fix-sidebar-scoll
[fix]侧边栏支持独立滑动
2025-10-27 09:47:33 +08:00
rootwhois
eaefc7f351 Update CategorySidebar component to limit height and enable vertical scrolling 2025-10-11 16:01:42 +08:00
AmintaCCCP
8c5f71ea77 fix update path 2025-09-23 16:56:22 +08:00
AmintaCCCP
d78bcd75d6 fix workflow 2025-09-23 16:47:44 +08:00
AmintaCCCP
e0af19dd2e Revert "fix workflow"
This reverts commit 0678fe9b04.
2025-09-23 16:37:52 +08:00
AmintaCCCP
0678fe9b04 fix workflow 2025-09-23 16:10:43 +08:00
AmintaCCCP
69f4a0788c fix bug 2025-09-23 15:51:15 +08:00
tamina
b2c49460ab Merge pull request #25 from rootwhois/main
feat(repositories): switch to infinite scroll with consistent card widths
2025-09-23 15:36:52 +08:00
rootwhois
b7ad4558ef feat(repositories): switch to infinite scroll with consistent card widths
- Replace pagination with on-demand infinite scrolling (load 50 per batch)
- Use IntersectionObserver and a bottom sentinel to trigger loading
- Update stats to show current range “X–Y / N repositories”
- Switch from CSS columns to fixed grid to ensure consistent card widths
- Remove pagination state and controls
- Clean up unused variables and resolve lint warnings

UX: smoother scrolling, stable card widths, more natural loading behavior.
2025-09-16 21:40:42 +08:00
tamina
e095d955e1 Merge pull request #23 from VancySavoki/fix/build-and-drag-window
feat(Header):  Add drag and no-drag regions for desktop app
2025-09-03 14:11:52 +08:00
VancySavoki
babe33e616 feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 20:01:28 +08:00
VancySavoki
1b914584e3 feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 19:28:31 +08:00
VancySavoki
deb015ca8c feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 17:51:15 +08:00
VancySavoki
36636c5d31 feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 17:40:57 +08:00
GitButler
07684356b4 GitButler Workspace Commit
This is a merge commit the virtual branches in your workspace.

Due to GitButler managing multiple virtual branches, you cannot switch back and
forth between git branches and virtual branches easily. 

If you switch to another branch, GitButler will need to be reinitialized.
If you commit on this branch, GitButler will throw it away.

Here are the branches that are currently applied:
 - fix/build-and-drag-window (refs/gitbutler/fix/build-and-drag-window)
   - electron-builder.yml
   - dist/index.html
For more information about what we're doing here, check out our docs:
https://docs.gitbutler.com/features/virtual-branches/integration-branch
2025-09-01 17:37:52 +08:00
VancySavoki
d4475a644d feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 17:34:47 +08:00
VancySavoki
f5d7819fc7 feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 17:31:39 +08:00
VancySavoki
724bce3ff4 feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 17:25:42 +08:00
VancySavoki
e49d20dcdb feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 15:58:07 +08:00
Savoki
da13c7b759 feat(Header): Add drag and no-drag regions for desktop app
* Implemented `.hd-drag` and `.hd-btns` classes for better window management in the Electron app.
* Updated `Header.tsx` to utilize these new classes for improved user experience.
2025-09-01 15:26:45 +08:00
tamina
7cddb5e480 Merge pull request #14 from CrisChr/bugfix/#10
bugfix[#10]:修复ai分析过程中,切换Header导致分析进度变为0的问题
2025-08-23 00:47:27 +08:00
CrisChr
83bf2d9334 bugfix[#10] 2025-08-21 14:17:18 +08:00
AmintaCCCP
3272ff2d66 Add Docker support. 2025-08-14 16:37:05 +08:00
AmintaCCCP
ca65dc53ec 0.1.4 2025-08-12 21:10:49 +08:00
AmintaCCCP
3783e120ad 0.1.4 2025-08-12 21:09:41 +08:00
AmintaCCCP
3372552391 0.1.4 2025-08-12 20:48:52 +08:00
AmintaCCCP
4ef03f9dec 0.1.4 2025-08-12 20:15:01 +08:00
AmintaCCCP
0b5d01fbb2 0.1.4 2025-08-12 20:12:51 +08:00
AmintaCCCP
83bbc588db 0.1.4 2025-08-12 20:01:43 +08:00
30 changed files with 1068 additions and 141 deletions

View File

@@ -33,8 +33,8 @@ jobs:
- name: Build web app
run: npm run build
env:
VITE_BASE_PATH: './'
# env:
# VITE_BASE_PATH: './'
- name: Verify and fix web build
shell: bash
@@ -409,7 +409,8 @@ jobs:
icon: 'build/icon.png',
category: 'public.app-category.productivity',
hardenedRuntime: true,
gatekeeperAssess: false
gatekeeperAssess: false,
identity: null
};
packageJson.build.dmg = {
title: 'GitHub Stars Manager',
@@ -467,6 +468,12 @@ jobs:
DEBUG: electron-builder
# Linux 特定环境变量
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
# macOS signing and notarization
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: List build output
shell: bash

3
.gitignore vendored
View File

@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
*.local
@@ -23,3 +24,5 @@ dist-ssr
*.sln
*.sw?
.env
release
electron

66
DOCKER.md Normal file
View File

@@ -0,0 +1,66 @@
# Docker Deployment
This application can be deployed using Docker with minimal configuration. The Docker setup serves the static frontend files via Nginx and handles CORS properly.
## Prerequisites
- Docker installed on your system
- Docker Compose (optional, but recommended)
## Building and Running with Docker
### Using Docker Compose (Recommended)
```bash
# Build and start the container
docker-compose up -d
# The application will be available at http://localhost:8080
```
### Using Docker directly
```bash
# Build the image
docker build -t github-stars-manager .
# Run the container
docker run -d -p 8080:80 --name github-stars-manager github-stars-manager
# The application will be available at http://localhost:8080
```
## CORS Handling
This Docker setup handles CORS in two ways:
1. **Nginx CORS Headers**: The Nginx configuration adds appropriate CORS headers to allow API calls to external services.
2. **Client-Side Handling**: The application is designed to work with any AI or WebDAV service URL configured by the user, without requiring proxying.
## Configuration
No special configuration is needed for the Docker container itself. All application settings (API URLs, credentials, etc.) are configured through the application UI.
## Environment Variables
While not required, you can pass environment variables to the container if needed:
```bash
docker run -d -p 8080:80 -e NODE_ENV=production --name github-stars-manager github-stars-manager
```
## Stopping the Container
```bash
# With Docker Compose
docker-compose down
# With Docker directly
docker stop github-stars-manager
docker rm github-stars-manager
```
## Note on Desktop Packaging
This Docker setup does not affect the existing desktop packaging workflows. The GitHub Actions workflow for building desktop applications remains unchanged and continues to work as before.

View File

@@ -0,0 +1,103 @@
# Docker Implementation Summary
This document summarizes the Docker implementation for the GitHub Stars Manager application.
## Implementation Details
### Files Created
1. **Dockerfile** - Multi-stage build process:
- Build stage: Uses Node.js 18 Alpine to build the React application
- Production stage: Uses Nginx Alpine to serve the static files
2. **nginx.conf** - Custom Nginx configuration:
- Handles CORS headers properly for API calls
- Serves static files with proper caching headers
- Implements SPA routing with try_files directive
- Adds security headers
3. **docker-compose.yml** - Docker Compose configuration:
- Simplifies deployment with a single command
- Maps port 8080 to container port 80
4. **DOCKER.md** - Detailed documentation:
- Instructions for building and running with Docker
- Explanation of CORS handling approach
- Configuration guidance
5. **test-docker.html** - Simple test page:
- Verifies that the application is accessible
### Key Features
1. **Minimal Changes**: The Docker setup doesn't affect the existing desktop packaging workflows or GitHub Actions.
2. **CORS Handling**:
- Nginx adds appropriate CORS headers to allow API calls to external services
- No proxying is used, allowing users to configure any AI or WebDAV service URLs
3. **Static File Serving**:
- Optimized Nginx configuration for serving static React applications
- Proper caching headers for better performance
- SPA routing support
4. **Flexibility**:
- Works with any AI service that supports OpenAI-compatible APIs
- Works with any WebDAV service
- No hardcoded API URLs or endpoints
### Testing Performed
1. ✅ Docker image builds successfully
2. ✅ Container runs and serves files on port 8080
3. ✅ Docker Compose setup works correctly
4. ✅ CORS headers are properly configured
5. ✅ Static files are served correctly
6. ✅ SPA routing works correctly
### How It Works
1. **Build Process**: The Dockerfile uses a multi-stage build:
- First stage installs Node.js dependencies and builds the React app
- Second stage copies the built files to an Nginx container
2. **Runtime**: The Nginx server:
- Serves static files from `/usr/share/nginx/html`
- Handles CORS with appropriate headers
- Routes all requests to index.html for SPA functionality
3. **API Calls**:
- The application makes direct calls to AI and WebDAV services
- Nginx adds CORS headers to allow these cross-origin requests
- Users can configure any service URLs in the application UI
### Advantages
1. **No Proxy Required**: Unlike development setups, this production setup doesn't need proxying since the browser considers all requests as coming from the same origin (the Docker container).
2. **Dynamic URL Support**: Users can configure any AI or WebDAV service URLs without rebuilding the container.
3. **Performance**: Nginx is highly efficient for serving static files.
4. **Compatibility**: Doesn't interfere with existing desktop packaging workflows.
### Usage Instructions
1. **With Docker Compose** (recommended):
```bash
docker-compose up -d
# Application available at http://localhost:8080
```
2. **With Docker directly**:
```bash
docker build -t github-stars-manager .
docker run -d -p 8080:80 github-stars-manager
# Application available at http://localhost:8080
```
The implementation satisfies all the requirements:
- ✅ Minimal changes to existing codebase
- ✅ Doesn't affect desktop packaging workflows
- ✅ Handles CORS properly for API calls
- ✅ Supports dynamic API URLs configured by users

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Build stage
FROM node:18-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -146,4 +146,19 @@ if (selectedFilters.length > 0) {
- 点击文件名直接下载
- 查看文件详细信息
## Docker 部署支持
### 新增文件
1. **Dockerfile** - 多阶段构建配置
2. **nginx.conf** - Nginx 服务器配置,包含 CORS 头设置
3. **docker-compose.yml** - Docker Compose 配置文件
4. **DOCKER.md** - 详细部署文档
5. **DOCKER_IMPLEMENTATION_SUMMARY.md** - 实现总结
### 功能特点
- 通过 Nginx 正确处理 CORS支持任意 AI/WebDAV 服务 URL
- 不影响现有的桌面应用打包流程
- 支持 Docker 和 Docker Compose 两种部署方式
- 静态文件优化服务
这个实现完全满足了用户的需求,提供了更灵活、更直观的 Release 文件管理和下载体验。

View File

@@ -60,6 +60,10 @@ https://github.com/AmintaCCCP/GithubStarsManager/releases
> 💡 When running the project locally using `npm run dev`, calls to AI services and WebDAV may fail due to CORS restrictions. To avoid this issue, use the prebuilt client application or build the client yourself.
### 🐳 Run With Docker
You can also run this application using Docker. See [DOCKER.md](DOCKER.md) for detailed instructions on how to build and deploy using Docker. The Docker setup handles CORS properly and allows you to configure any AI or WebDAV service URLs directly in the application.
## Who its for

View File

@@ -143,6 +143,9 @@ npm run build
- Cloudflare Pages
- 自建服务器
### Docker 部署
您也可以使用 Docker 来运行此应用程序。请参阅 [DOCKER.md](DOCKER.md) 获取详细的构建和部署说明。Docker 设置正确处理了 CORS并允许您直接在应用程序中配置任何 AI 或 WebDAV 服务 URL。
## 贡献 / Contributing
欢迎提交Issue和Pull Request

4
dist/index.html vendored
View File

@@ -10,8 +10,8 @@
<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-CQ4S0AeE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQKjD-gi.css">
<script type="module" crossorigin src="/assets/index-DZXyNObJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BWYOfSoc.css">
</head>
<body class="bg-gray-50 dark:bg-gray-900">
<div id="root"></div>

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
version: '3.8'
services:
github-stars-manager:
build: .
ports:
- "8080:80"
restart: unless-stopped
# Environment variables can be set here if needed
# environment:
# - NODE_ENV=production

76
nginx.conf Normal file
View File

@@ -0,0 +1,76 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Hide nginx version
server_tokens off;
# Log format
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Include additional configuration files
include /etc/nginx/conf.d/*.conf;
# Server block
server {
listen 80;
server_name localhost;
# Handle preflight requests for CORS
location / {
# Handle preflight requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Max-Age' 1728000 always;
add_header 'Content-Type' 'text/plain; charset=utf-8' always;
add_header 'Content-Length' 0 always;
return 204;
}
root /usr/share/nginx/html;
index index.html index.htm;
# Try to serve file, if not found, serve index.html (for SPA routing)
try_files $uri $uri/ /index.html;
# Add CORS headers for all responses
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
# Add security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Error pages
error_page 404 /404.html;
location = /404.html {
internal;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
internal;
}
}
}

2
package-lock.json generated
View File

@@ -4162,4 +4162,4 @@
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "github-stars-manager",
"private": true,
"version": "0.1.3",
"version": "0.1.7",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -37,7 +37,7 @@ function createWindow() {
enableRemoteModule: false,
webSecurity: true
},
icon: path.join(__dirname, '../dist/vite.svg'),
icon: path.join(__dirname, '../dist/icon.svg'),
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
show: false
});

View File

@@ -102,7 +102,7 @@ export const CategorySidebar: React.FC<CategorySidebarProps> = ({
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">
<div className="w-64 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 max-h-[calc(100vh-8rem)] sticky top-24 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('应用分类', 'Categories')}

View File

@@ -98,7 +98,7 @@ export const Header: React.FC = () => {
const t = (zh: string, en: string) => language === 'zh' ? zh : en;
return (
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-50">
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-50 hd-drag">
<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 */}
@@ -121,7 +121,7 @@ export const Header: React.FC = () => {
</div>
{/* Navigation */}
<nav className="hidden md:flex items-center space-x-1">
<nav className="hidden md:flex items-center space-x-1 hd-btns">
<button
onClick={() => setCurrentView('repositories')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
@@ -158,7 +158,7 @@ export const Header: React.FC = () => {
</nav>
{/* User Actions */}
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-3 hd-btns">
{/* Sync Status */}
<div className="hidden sm:flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<span>{t('上次同步:', 'Last sync:')} {formatLastSync(lastSync)}</span>

View File

@@ -197,7 +197,8 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
ai_summary: analysis.summary,
ai_tags: analysis.tags,
ai_platforms: analysis.platforms,
analyzed_at: new Date().toISOString()
analyzed_at: new Date().toISOString(),
analysis_failed: false // 分析成功,清除失败标记
};
updateRepository(updatedRepo);
@@ -209,6 +210,16 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
alert(successMessage);
} catch (error) {
console.error('AI analysis failed:', error);
// 标记为分析失败
const failedRepo = {
...repository,
analyzed_at: new Date().toISOString(),
analysis_failed: true
};
updateRepository(failedRepo);
alert(language === 'zh' ? 'AI分析失败请检查AI配置和网络连接。' : 'AI analysis failed. Please check AI configuration and network connection.');
} finally {
setLoading(false);
@@ -232,6 +243,12 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
content: repository.custom_description,
isCustom: true
};
} else if (showAISummary && repository.analysis_failed) {
return {
content: repository.description || (language === 'zh' ? '暂无描述' : 'No description available'),
isAI: false,
isFailed: true
};
} else if (showAISummary && repository.ai_summary) {
return {
content: repository.ai_summary,
@@ -277,7 +294,12 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
// 获取AI分析按钮的提示文本
const getAIButtonTitle = () => {
if (repository.analyzed_at) {
if (repository.analysis_failed) {
const analyzeTime = new Date(repository.analyzed_at!).toLocaleString();
return language === 'zh'
? `分析失败于 ${analyzeTime},点击重新分析`
: `Analysis failed on ${analyzeTime}, click to retry`;
} else if (repository.analyzed_at) {
const analyzeTime = new Date(repository.analyzed_at).toLocaleString();
return language === 'zh'
? `已于 ${analyzeTime} 分析过,点击重新分析`
@@ -315,9 +337,12 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
<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 hover:bg-green-200 dark:hover:bg-green-800'
: 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-400 hover:bg-purple-200 dark:hover:bg-purple-800'
className={`p-2 rounded-lg transition-colors ${
repository.analysis_failed
? 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-800'
: repository.analyzed_at
? 'bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800'
: '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={getAIButtonTitle()}
>
@@ -398,7 +423,13 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
<span>{language === 'zh' ? '自定义' : 'Custom'}</span>
</div>
)}
{displayContent.isAI && (
{displayContent.isFailed && (
<div className="flex items-center space-x-1 text-xs text-red-600 dark:text-red-400">
<Bot className="w-3 h-3" />
<span>{language === 'zh' ? '分析失败' : 'Analysis Failed'}</span>
</div>
)}
{displayContent.isAI && !displayContent.isFailed && (
<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>
@@ -410,8 +441,7 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
{/* Category Display */}
{displayCategory && (
<div className="mb-3">
<span className="inline-flex items-center px-2 py-1 bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300 rounded-md text-xs font-medium">
<Tag className="w-3 h-3 mr-1" />
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 rounded-md text-xs font-medium">
{displayCategory}
</span>
</div>
@@ -423,13 +453,8 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
{displayTags.tags.slice(0, 3).map((tag, index) => (
<span
key={index}
className={`px-2 py-1 rounded-md text-xs font-medium ${displayTags.isCustom
? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300'
}`}
className="px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
>
{displayTags.isCustom && <Edit3 className="w-3 h-3 inline mr-1" />}
{!displayTags.isCustom && <Tag className="w-3 h-3 inline mr-1" />}
{highlightSearchTerm(tag, searchQuery)}
</span>
))}
@@ -438,7 +463,7 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
{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"
className="px-2 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 rounded-md text-xs font-medium"
>
{topic}
</span>
@@ -500,7 +525,12 @@ export const RepositoryCard: React.FC<RepositoryCardProps> = ({
<span>{language === 'zh' ? '已编辑' : 'Edited'}</span>
</div>
)}
{repository.analyzed_at && (
{repository.analysis_failed ? (
<div className="flex items-center space-x-1 text-xs">
<div className="w-2 h-2 bg-red-500 rounded-full" />
<span>{language === 'zh' ? '分析失败' : 'Analysis failed'}</span>
</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>

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Bot, ChevronDown, Pause, Play } from 'lucide-react';
import { RepositoryCard } from './RepositoryCard';
@@ -25,13 +25,13 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
updateRepository,
language,
customCategories,
analysisProgress,
setAnalysisProgress
} = useAppStore();
const [showAISummary, setShowAISummary] = useState(true);
const [showDropdown, setShowDropdown] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState({ current: 0, total: 0 });
const [isPaused, setIsPaused] = useState(false);
const [searchTime, setSearchTime] = useState<number | undefined>(undefined);
// 使用 useRef 来管理停止状态,确保在异步操作中能正确访问最新值
const shouldStopRef = useRef(false);
@@ -75,7 +75,44 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
);
});
const handleAIAnalyze = async (analyzeUnanalyzedOnly: boolean = false) => {
// Infinite scroll (瀑布流按需加载)
const LOAD_BATCH = 50;
const [visibleCount, setVisibleCount] = useState(LOAD_BATCH);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const startIndex = filteredRepositories.length === 0 ? 0 : 1;
const endIndex = Math.min(visibleCount, filteredRepositories.length);
const visibleRepositories = filteredRepositories.slice(0, visibleCount);
// Reset visible count when filters or data change
useEffect(() => {
setVisibleCount(LOAD_BATCH);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCategory, repositories, filteredRepositories.length]);
// IntersectionObserver to load more on demand
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
setVisibleCount((count) => {
if (count >= filteredRepositories.length) return count;
return Math.min(count + LOAD_BATCH, filteredRepositories.length);
});
}
},
{ root: null, rootMargin: '200px', threshold: 0 }
);
observer.observe(node);
return () => observer.disconnect();
}, [filteredRepositories.length]);
const handleAIAnalyze = async (analyzeUnanalyzedOnly: boolean = false, analyzeFailedOnly: boolean = false) => {
if (!githubToken) {
alert(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.');
return;
@@ -87,21 +124,27 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
return;
}
const targetRepos = analyzeUnanalyzedOnly
? filteredRepositories.filter(repo => !repo.analyzed_at)
: filteredRepositories;
const targetRepos = analyzeFailedOnly
? filteredRepositories.filter(repo => repo.analysis_failed)
: 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!')
);
const message = analyzeFailedOnly
? (language === 'zh' ? '没有分析失败的仓库!' : 'No failed repositories to re-analyze!')
: analyzeUnanalyzedOnly
? (language === 'zh' ? '所有仓库都已经分析过了!' : 'All repositories have been analyzed!')
: (language === 'zh' ? '没有可分析的仓库!' : 'No repositories to analyze!');
alert(message);
return;
}
const actionText = language === 'zh'
? (analyzeUnanalyzedOnly ? '未分析' : '全部')
: (analyzeUnanalyzedOnly ? 'unanalyzed' : 'all');
const actionText = analyzeFailedOnly
? (language === 'zh' ? '失败' : 'failed')
: analyzeUnanalyzedOnly
? (language === 'zh' ? '未分析' : 'unanalyzed')
: (language === 'zh' ? '全部' : 'all');
const confirmMessage = language === 'zh'
? `将对 ${targetRepos.length}${actionText}仓库进行AI分析这可能需要几分钟时间。是否继续`
@@ -126,12 +169,13 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
const customCategoryNames = customCategories.map(cat => cat.name);
let analyzed = 0;
const concurrency = activeConfig.concurrency || 1;
for (let i = 0; i < targetRepos.length; i++) {
// 并发分析函数
const analyzeRepository = async (repo: Repository) => {
// 检查是否需要停止
if (shouldStopRef.current) {
console.log('Analysis stopped by user');
break;
return false;
}
// 处理暂停
@@ -141,13 +185,9 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
// 再次检查停止状态(暂停期间可能被停止)
if (shouldStopRef.current) {
console.log('Analysis stopped during pause');
break;
return false;
}
const repo = targetRepos[i];
setAnalysisProgress({ current: i + 1, total: targetRepos.length });
try {
// 获取README内容
const [owner, name] = repo.full_name.split('/');
@@ -162,16 +202,48 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
ai_summary: analysis.summary,
ai_tags: analysis.tags,
ai_platforms: analysis.platforms,
analyzed_at: new Date().toISOString()
analyzed_at: new Date().toISOString(),
analysis_failed: false // 分析成功,清除失败标记
};
updateRepository(updatedRepo);
analyzed++;
setAnalysisProgress({ current: analyzed, total: targetRepos.length });
// 避免API限制
await new Promise(resolve => setTimeout(resolve, 1000));
return true;
} catch (error) {
console.warn(`Failed to analyze ${repo.full_name}:`, error);
// 标记为分析失败
const failedRepo = {
...repo,
analyzed_at: new Date().toISOString(),
analysis_failed: true
};
updateRepository(failedRepo);
analyzed++;
setAnalysisProgress({ current: analyzed, total: targetRepos.length });
return false;
}
};
// 分批处理,支持并发
for (let i = 0; i < targetRepos.length; i += concurrency) {
if (shouldStopRef.current) {
console.log('Analysis stopped by user');
break;
}
const batch = targetRepos.slice(i, i + concurrency);
const promises = batch.map((repo) => analyzeRepository(repo));
await Promise.all(promises);
// 避免API限制批次间稍作延迟
if (i + concurrency < targetRepos.length && !shouldStopRef.current) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
@@ -257,7 +329,8 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
}
const unanalyzedCount = filteredRepositories.filter(r => !r.analyzed_at).length;
const analyzedCount = filteredRepositories.filter(r => r.analyzed_at).length;
const analyzedCount = filteredRepositories.filter(r => r.analyzed_at && !r.analysis_failed).length;
const failedCount = filteredRepositories.filter(r => r.analysis_failed).length;
const t = (zh: string, en: string) => language === 'zh' ? zh : en;
@@ -302,7 +375,7 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
<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"
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 border-b border-gray-100 dark:border-gray-600"
>
<div className="font-medium text-gray-900 dark:text-white">
{t('分析未分析的', 'Analyze Unanalyzed')}
@@ -311,6 +384,18 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
{t(`分析 ${unanalyzedCount} 个未分析仓库`, `Analyze ${unanalyzedCount} unanalyzed repositories`)}
</div>
</button>
<button
onClick={() => handleAIAnalyze(false, true)}
disabled={failedCount === 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('重新分析失败的', 'Re-analyze Failed')}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{t(`重新分析 ${failedCount} 个失败仓库`, `Re-analyze ${failedCount} failed repositories`)}
</div>
</button>
</div>
)}
</div>
@@ -383,7 +468,10 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
<div className="text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-between">
<div>
{t(`显示 ${filteredRepositories.length} 个仓库`, `Showing ${filteredRepositories.length} repositories`)}
{t(
`${startIndex}-${endIndex} / 共 ${filteredRepositories.length} 个仓库`,
`Showing ${startIndex}-${endIndex} of ${filteredRepositories.length} repositories`
)}
{repositories.length !== filteredRepositories.length && (
<span className="ml-2 text-blue-600 dark:text-blue-400">
{t(`(从 ${repositories.length} 个中筛选)`, `(filtered from ${repositories.length})`)}
@@ -396,6 +484,11 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
{analyzedCount} {t('个已AI分析', 'AI analyzed')}
</span>
)}
{failedCount > 0 && (
<span className="mr-3">
{failedCount} {t('个分析失败', 'analysis failed')}
</span>
)}
{unanalyzedCount > 0 && (
<span>
{unanalyzedCount} {t('个未分析', 'unanalyzed')}
@@ -406,17 +499,22 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
</div>
</div>
{/* Repository Grid */}
{/* Repository Grid with consistent card widths */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredRepositories.map(repo => (
{visibleRepositories.map(repo => (
<RepositoryCard
key={repo.id}
key={repo.id}
repository={repo}
showAISummary={showAISummary}
searchQuery={useAppStore.getState().searchFilters.query}
/>
))}
</div>
{/* Sentinel for on-demand loading */}
{visibleCount < filteredRepositories.length && (
<div ref={sentinelRef} className="h-8" />
)}
</div>
);
};

View File

@@ -49,6 +49,10 @@ export const SettingsPanel: React.FC = () => {
setActiveWebDAVConfig,
setLastBackup,
setLanguage,
setRepositories,
setReleases,
addCustomCategory,
deleteCustomCategory,
} = useAppStore();
const [showAIForm, setShowAIForm] = useState(false);
@@ -68,6 +72,7 @@ export const SettingsPanel: React.FC = () => {
model: '',
customPrompt: '',
useCustomPrompt: false,
concurrency: 1,
});
const [webdavForm, setWebDAVForm] = useState({
@@ -86,6 +91,7 @@ export const SettingsPanel: React.FC = () => {
model: '',
customPrompt: '',
useCustomPrompt: false,
concurrency: 1,
});
setShowAIForm(false);
setEditingAIId(null);
@@ -119,6 +125,7 @@ export const SettingsPanel: React.FC = () => {
isActive: false,
customPrompt: aiForm.customPrompt || undefined,
useCustomPrompt: aiForm.useCustomPrompt,
concurrency: aiForm.concurrency,
};
if (editingAIId) {
@@ -138,6 +145,7 @@ export const SettingsPanel: React.FC = () => {
model: config.model,
customPrompt: config.customPrompt || '',
useCustomPrompt: config.useCustomPrompt || false,
concurrency: config.concurrency || 1,
});
setEditingAIId(config.id);
setShowAIForm(true);
@@ -293,12 +301,106 @@ export const SettingsPanel: React.FC = () => {
if (backupContent) {
const backupData = JSON.parse(backupContent);
// Note: In a real implementation, you would restore the data here
// For now, we'll just show a success message
// 1) 恢复仓库与发布
if (Array.isArray(backupData.repositories)) {
setRepositories(backupData.repositories);
}
if (Array.isArray(backupData.releases)) {
setReleases(backupData.releases);
}
// 2) 恢复自定义分类(全部替换)
try {
// 先清空现有自定义分类
if (Array.isArray(customCategories)) {
for (const cat of customCategories) {
if (cat && cat.id) {
deleteCustomCategory(cat.id);
}
}
}
// 再添加备份中的自定义分类
if (Array.isArray(backupData.customCategories)) {
for (const cat of backupData.customCategories) {
if (cat && cat.id && cat.name) {
addCustomCategory({ ...cat, isCustom: true });
}
}
}
} catch (e) {
console.warn('恢复自定义分类时发生问题:', e);
}
// 3) 合并 AI 配置(保留现有密钥;备份中密钥为***时不覆盖)
try {
if (Array.isArray(backupData.aiConfigs)) {
const currentMap = new Map(aiConfigs.map((c: AIConfig) => [c.id, c]));
for (const cfg of backupData.aiConfigs as AIConfig[]) {
if (!cfg || !cfg.id) continue;
const existing = currentMap.get(cfg.id);
const isMasked = cfg.apiKey === '***';
if (existing) {
updateAIConfig(cfg.id, {
name: cfg.name,
baseUrl: cfg.baseUrl,
model: cfg.model,
customPrompt: cfg.customPrompt,
useCustomPrompt: cfg.useCustomPrompt,
concurrency: cfg.concurrency,
// 仅当备份未掩码时才覆盖 apiKey
apiKey: isMasked ? existing.apiKey : cfg.apiKey,
// 保留现有 isActive 状态
isActive: existing.isActive,
});
} else {
addAIConfig({
...cfg,
apiKey: isMasked ? '' : cfg.apiKey,
isActive: false,
});
}
}
}
} catch (e) {
console.warn('恢复 AI 配置时发生问题:', e);
}
// 4) 合并 WebDAV 配置(保留现有密码;备份中密码为***时不覆盖)
try {
if (Array.isArray(backupData.webdavConfigs)) {
const currentMap = new Map(webdavConfigs.map((c: WebDAVConfig) => [c.id, c]));
for (const cfg of backupData.webdavConfigs as WebDAVConfig[]) {
if (!cfg || !cfg.id) continue;
const existing = currentMap.get(cfg.id);
const isMasked = cfg.password === '***';
if (existing) {
updateWebDAVConfig(cfg.id, {
name: cfg.name,
url: cfg.url,
username: cfg.username,
path: cfg.path,
// 仅当备份未掩码时才覆盖密码
password: isMasked ? existing.password : cfg.password,
// 保留现有 isActive 状态
isActive: existing.isActive,
});
} else {
addWebDAVConfig({
...cfg,
password: isMasked ? '' : cfg.password,
isActive: false,
});
}
}
}
} catch (e) {
console.warn('恢复 WebDAV 配置时发生问题:', e);
}
alert(t(
`找到备份文件: ${latestBackup}。请注意:完整的数据恢复功能需要在实际部署中实现`,
`Found backup file: ${latestBackup}. Note: Full data restoration needs to be implemented in actual deployment.`
`已从备份恢复数据:仓库 ${backupData.repositories?.length ?? 0},发布 ${backupData.releases?.length ?? 0},自定义分类 ${backupData.customCategories?.length ?? 0}`,
`Data restored from backup: repositories ${backupData.repositories?.length ?? 0}, releases ${backupData.releases?.length ?? 0}, custom categories ${backupData.customCategories?.length ?? 0}.`
));
}
} catch (error) {
@@ -369,7 +471,7 @@ Focus on practicality and accurate categorization to help users quickly understa
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
{t('当前版本: v0.1.3', 'Current Version: v0.1.3')}
{t('当前版本: v0.1.7', 'Current Version: v0.1.7')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
{t('检查是否有新版本可用', 'Check if a new version is available')}
@@ -531,6 +633,24 @@ Focus on practicality and accurate categorization to help users quickly understa
placeholder="gpt-4"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('并发数', 'Concurrency')}
</label>
<input
type="number"
min="1"
max="10"
value={aiForm.concurrency}
onChange={(e) => setAIForm(prev => ({ ...prev, concurrency: Math.max(1, Math.min(10, parseInt(e.target.value) || 1)) }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="1"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('同时进行AI分析的仓库数量 (1-10)', 'Number of repositories to analyze simultaneously (1-10)')}
</p>
</div>
</div>
{/* Custom Prompt Section */}
@@ -631,7 +751,7 @@ Focus on practicality and accurate categorization to help users quickly understa
)}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{config.baseUrl} {config.model}
{config.baseUrl} {config.model} {t('并发数', 'Concurrency')}: {config.concurrency || 1}
</p>
</div>
</div>
@@ -906,4 +1026,4 @@ Focus on practicality and accurate categorization to help users quickly understa
</div>
</div>
);
};
};

View File

@@ -9,4 +9,14 @@
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.hd-drag {
-webkit-app-region: drag;
-webkit-user-select: none;
user-select: none;
}
.hd-btns {
-webkit-app-region: no-drag;
}

View File

@@ -58,8 +58,8 @@ export class AIService {
return this.parseAIResponse(content);
} catch (error) {
console.error('AI analysis failed:', error);
// Fallback to basic analysis
return this.fallbackAnalysis(repository);
// 抛出错误,让调用方处理失败状态
throw error;
}
}

View File

@@ -17,7 +17,7 @@ export class UpdateService {
private static getCurrentVersion(): string {
// 在实际应用中,这个版本号应该在构建时注入
// 这里暂时硬编码,你可以通过构建脚本或环境变量来动态设置
return '0.1.3';
return '0.1.7';
}
static async checkForUpdates(): Promise<UpdateCheckResult> {
@@ -116,4 +116,4 @@ export class UpdateService {
static openDownloadUrl(url: string): void {
window.open(url, '_blank');
}
}
}

View File

@@ -7,6 +7,71 @@ export class WebDAVService {
this.config = config;
}
// 压缩JSON数据减少传输大小
private compressData(content: string): string {
try {
const data = JSON.parse(content);
return JSON.stringify(data);
} catch (e) {
console.warn('JSON压缩失败使用原始内容:', e);
return content;
}
}
// 检测文件是否过大,提供优化建议
private analyzeFileSize(content: string): { sizeKB: number; isLarge: boolean; suggestions: string[] } {
const sizeKB = Math.round(content.length / 1024);
const isLarge = sizeKB > 1024; // 超过1MB认为是大文件
const suggestions: string[] = [];
if (isLarge) {
suggestions.push('考虑减少备份数据量');
if (content.length > 5 * 1024 * 1024) { // 5MB
suggestions.push('文件过大,建议启用数据筛选或分片备份');
}
}
return { sizeKB, isLarge, suggestions };
}
// 重试机制
private async retryUpload<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delay: number = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
throw lastError;
}
// 对特定错误进行重试
const shouldRetry =
error.message.includes('超时') ||
error.message.includes('timeout') ||
error.message.includes('NetworkError') ||
error.message.includes('fetch');
if (!shouldRetry) {
throw lastError;
}
console.warn(`上传失败,第${attempt}次重试 (${delay}ms后):`, error.message);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // 指数退避
}
}
throw lastError!;
}
private getAuthHeader(): string {
const credentials = btoa(`${this.config.username}:${this.config.password}`);
return `Basic ${credentials}`;
@@ -68,13 +133,16 @@ export class WebDAVService {
throw new Error('WebDAV URL必须以 http:// 或 https:// 开头');
}
// 首先尝试OPTIONS请求检查CORS
// 构建用于测试的目录URL优先测试配置中的 path
const dirUrl = `${this.config.url}${this.config.path}`;
// 先尝试 HEAD 请求检测基本可达性(某些服务器对 PROPFIND/OPTIONS 支持较差)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
try {
const optionsResponse = await fetch(this.config.url, {
method: 'OPTIONS',
const headResponse = await fetch(dirUrl, {
method: 'HEAD',
headers: {
'Authorization': this.getAuthHeader(),
},
@@ -83,13 +151,10 @@ export class WebDAVService {
clearTimeout(timeoutId);
// 如果OPTIONS成功说明CORS配置正确
if (optionsResponse.ok) {
return true;
}
if (headResponse.ok) return true;
// 如果OPTIONS失败尝试PROPFIND某些服务器不支持OPTIONS
const propfindResponse = await fetch(this.config.url, {
// HEAD 不可用时,尝试 PROPFIND不少服务器返回 207 Multi-Status 表示成功
const propfindResponse = await fetch(dirUrl, {
method: 'PROPFIND',
headers: {
'Authorization': this.getAuthHeader(),
@@ -119,51 +184,70 @@ export class WebDAVService {
throw new Error('WebDAV URL必须以 http:// 或 https:// 开头');
}
// 分析文件大小并压缩数据
const fileAnalysis = this.analyzeFileSize(content);
const compressedContent = this.compressData(content);
if (fileAnalysis.isLarge) {
console.warn(`大文件备份 (${fileAnalysis.sizeKB}KB):`, fileAnalysis.suggestions.join(', '));
}
console.log(`文件大小: ${fileAnalysis.sizeKB}KB压缩后: ${Math.round(compressedContent.length / 1024)}KB`);
// 确保目录存在
await this.ensureDirectoryExists();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时
// 动态计算超时时间基于压缩后文件大小最小60秒最大300秒
const finalSizeKB = Math.round(compressedContent.length / 1024);
const dynamicTimeout = Math.max(60000, Math.min(300000, finalSizeKB * 100)); // 每KB 100ms
console.log(`设置超时时间: ${dynamicTimeout}ms`);
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);
const uploadOperation = async (): Promise<boolean> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), dynamicTimeout);
if (!response.ok) {
if (response.status === 401) {
throw new Error('身份验证失败。请检查用户名和密码。');
try {
const response = await fetch(this.getFullPath(filename), {
method: 'PUT',
headers: {
'Authorization': this.getAuthHeader(),
'Content-Type': 'application/json',
},
body: compressedContent,
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}`);
}
if (response.status === 403) {
throw new Error('访问被拒绝。请检查指定路径的权限。');
return true;
} catch (fetchError) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error(`上传超时 (${finalSizeKB}KB文件${dynamicTimeout/1000}秒限制)。建议检查网络连接或联系管理员优化服务器配置。`);
}
if (response.status === 404) {
throw new Error('路径未找到。请验证WebDAV URL和路径是否正确。');
}
if (response.status === 507) {
throw new Error('服务器存储空间不足。');
}
throw new Error(`上传失败HTTP状态码 ${response.status}: ${response.statusText}`);
throw fetchError;
}
return true;
} catch (fetchError) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error('上传超时。文件可能太大或网络连接缓慢。');
}
throw fetchError;
}
};
return await this.retryUpload(uploadOperation);
} catch (error) {
if (error.message.includes('身份验证失败') ||
error.message.includes('访问被拒绝') ||
@@ -184,17 +268,32 @@ export class WebDAVService {
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('无法创建目录,可能已存在或权限不足');
// 逐级创建目录,避免服务器因中间目录不存在而返回 409/403
const cleanedPath = this.config.path.replace(/\/+$/, ''); // 去掉末尾斜杠
const segments = cleanedPath.split('/').filter(Boolean); // 去掉空段
let currentPath = '';
for (const seg of segments) {
currentPath += `/${seg}`;
const full = `${this.config.url}${currentPath}`;
try {
const res = await fetch(full, {
method: 'MKCOL',
headers: { 'Authorization': this.getAuthHeader() },
});
// 201 Created新建或 405 Method Not Allowed已存在都视为成功
if (!res.ok && res.status !== 405) {
// 某些服务器对已存在目录返回 409 Conflict
if (res.status !== 409) {
console.warn(`无法创建目录 ${currentPath},状态码: ${res.status}`);
break; // 不再继续往下建
}
}
} catch (e) {
console.warn(`创建目录 ${currentPath} 发生异常:`, e);
break;
}
}
} catch (error) {
console.warn('目录创建检查失败:', error);
@@ -279,7 +378,11 @@ export class WebDAVService {
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时
try {
const response = await fetch(this.config.url + this.config.path, {
// 确保目录URL以斜杠结尾避免部分服务器对集合路径的歧义
const basePath = this.config.path.endsWith('/') ? this.config.path : `${this.config.path}/`;
const collectionUrl = `${this.config.url}${basePath}`;
const response = await fetch(collectionUrl, {
method: 'PROPFIND',
headers: {
'Authorization': this.getAuthHeader(),
@@ -301,12 +404,59 @@ export class WebDAVService {
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'));
// 优先用 DOMParser 解析(更可靠,兼容 displayname 缺失的服务端)
try {
const parser = new DOMParser();
const xml = parser.parseFromString(xmlText, 'application/xml');
const responses = Array.from(xml.getElementsByTagNameNS('DAV:', 'response'));
const results: string[] = [];
for (const res of responses) {
const hrefEl = res.getElementsByTagNameNS('DAV:', 'href')[0];
if (!hrefEl || !hrefEl.textContent) continue;
let href = hrefEl.textContent;
// 过滤掉集合自身(目录本身)
// 有的服务返回绝对URL有的返回相对路径统一去比较末尾路径
const normalizedCollection = collectionUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '/');
const normalizedHref = href.replace(/^https?:\/\//, '');
if (normalizedHref.endsWith(normalizedCollection)) continue;
// 提取文件名
try {
// 去掉末尾斜杠(目录)
href = href.replace(/\/+$/, '');
const parts = href.split('/').filter(Boolean);
if (parts.length === 0) continue;
const last = decodeURIComponent(parts[parts.length - 1]);
if (last.toLowerCase().endsWith('.json')) {
results.push(last.trim());
}
} catch (_e) {
// 忽略单个条目解析失败
}
}
if (results.length > 0) return results;
} catch (_e) {
// DOMParser 失败时降级为正则提取 href/displayname
const namesFromDisplay = (xmlText.match(/<D:displayname>([^<]+)<\/D:displayname>/gi) || [])
.map(m => m.replace(/<\/?D:displayname>/gi, ''))
.map(s => s.trim())
.filter(name => name.toLowerCase().endsWith('.json'));
if (namesFromDisplay.length > 0) return namesFromDisplay;
const namesFromHref = (xmlText.match(/<D:href>([^<]+)<\/D:href>/gi) || [])
.map(m => m.replace(/<\/?D:href>/gi, ''))
.map(s => s.replace(/\/+$/, ''))
.map(s => decodeURIComponent(s.split('/').filter(Boolean).pop() || ''))
.map(s => s.trim())
.filter(name => name.toLowerCase().endsWith('.json'));
if (namesFromHref.length > 0) return namesFromHref;
}
} else if (response.status === 401) {
throw new Error('身份验证失败。请检查用户名和密码。');
@@ -381,4 +531,4 @@ export class WebDAVService {
return {};
}
}
}

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { AppState, Repository, Release, AIConfig, WebDAVConfig, SearchFilters, GitHubUser, Category, AssetFilter, UpdateNotification } from '../types';
import { AppState, Repository, Release, AIConfig, WebDAVConfig, SearchFilters, GitHubUser, Category, AssetFilter, UpdateNotification, AnalysisProgress } from '../types';
interface AppActions {
// Auth actions
@@ -56,6 +56,9 @@ interface AppActions {
// Update actions
setUpdateNotification: (notification: UpdateNotification | null) => void;
dismissUpdateNotification: () => void;
// Update Analysis Progress
setAnalysisProgress: (newProgress: AnalysisProgress) => void;
}
const initialSearchFilters: SearchFilters = {
@@ -182,6 +185,7 @@ export const useAppStore = create<AppState & AppActions>()(
currentView: 'repositories',
language: 'zh',
updateNotification: null,
analysisProgress: { current: 0, total: 0 },
// Auth actions
setUser: (user) => {
@@ -316,6 +320,7 @@ export const useAppStore = create<AppState & AppActions>()(
// Update actions
setUpdateNotification: (notification) => set({ updateNotification: notification }),
dismissUpdateNotification: () => set({ updateNotification: null }),
setAnalysisProgress: (newProgress) => set({ analysisProgress: newProgress })
}),
{
name: 'github-stars-manager',
@@ -352,6 +357,12 @@ export const useAppStore = create<AppState & AppActions>()(
// 持久化UI设置
theme: state.theme,
language: state.language,
// 持久化搜索排序设置
searchFilters: {
sortBy: state.searchFilters.sortBy,
sortOrder: state.searchFilters.sortOrder,
},
}),
onRehydrateStorage: () => (state) => {
if (state) {
@@ -374,8 +385,14 @@ export const useAppStore = create<AppState & AppActions>()(
// 初始化搜索结果为所有仓库
state.searchResults = state.repositories || [];
// 重置搜索过滤器
state.searchFilters = initialSearchFilters;
// 重置搜索过滤器,但保留排序设置
const savedSortBy = state.searchFilters?.sortBy || 'stars';
const savedSortOrder = state.searchFilters?.sortOrder || 'desc';
state.searchFilters = {
...initialSearchFilters,
sortBy: savedSortBy,
sortOrder: savedSortOrder,
};
// 确保语言设置存在
if (!state.language) {

View File

@@ -20,6 +20,7 @@ export interface Repository {
ai_tags?: string[];
ai_platforms?: string[]; // 新增:支持的平台类型
analyzed_at?: string;
analysis_failed?: boolean; // 新增AI分析是否失败
// Release subscription
subscribed_to_releases?: boolean;
// Manual editing fields
@@ -74,6 +75,7 @@ export interface AIConfig {
isActive: boolean;
customPrompt?: string; // 自定义提示词
useCustomPrompt?: boolean; // 是否使用自定义提示词
concurrency?: number; // AI分析并发数默认为1
}
export interface WebDAVConfig {
@@ -155,6 +157,9 @@ export interface AppState {
// Update
updateNotification: UpdateNotification | null;
// Analysis Progress
analysisProgress: AnalysisProgress
}
export interface UpdateNotification {
@@ -163,4 +168,9 @@ export interface UpdateNotification {
changelog: string[];
downloadUrl: string;
dismissed: boolean;
}
export interface AnalysisProgress {
current: number;
total: number;
}

28
test-docker.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>GitHub Stars Manager Docker Test</title>
</head>
<body>
<h1>GitHub Stars Manager Docker Test</h1>
<p>This is a simple test page to verify the Docker deployment.</p>
<h2>Test Results:</h2>
<div id="results"></div>
<script>
// This script tests that the app is accessible
fetch('http://localhost:8080')
.then(response => {
if (response.ok) {
document.getElementById('results').innerHTML = '<p style="color: green;">✓ Application is accessible</p>';
} else {
document.getElementById('results').innerHTML = '<p style="color: red;">✗ Application is not accessible</p>';
}
})
.catch(error => {
document.getElementById('results').innerHTML = '<p style="color: orange;">⚠️ Test cannot be completed from this context: ' + error.message + '</p>';
});
</script>
</body>
</html>

107
test-sort-persistence.html Normal file
View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>排序持久化测试</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
.test-section {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.success {
color: #22c55e;
font-weight: bold;
}
.info {
color: #3b82f6;
}
code {
background: #e5e7eb;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
}
</style>
</head>
<body>
<h1>🎯 仓库排序持久化功能测试</h1>
<div class="test-section">
<h2>✅ 功能实现完成</h2>
<p class="success">已成功实现仓库页面排序设置的持久化功能!</p>
<h3>🔧 实现的修改:</h3>
<ul>
<li><strong>状态持久化</strong>:在 <code>useAppStore.ts</code><code>partialize</code> 函数中添加了排序设置的持久化</li>
<li><strong>状态恢复</strong>:在 <code>onRehydrateStorage</code> 中保留用户上次设置的排序方式,而不是每次都重置为默认值</li>
<li><strong>向下兼容</strong>:如果没有保存的排序设置,会使用默认的"按星标排序"</li>
</ul>
<h3>📋 具体修改内容:</h3>
<ol>
<li><strong>持久化配置</strong>
<pre><code>// 持久化搜索排序设置
searchFilters: {
sortBy: state.searchFilters.sortBy,
sortOrder: state.searchFilters.sortOrder,
},</code></pre>
</li>
<li><strong>状态恢复逻辑</strong>
<pre><code>// 重置搜索过滤器,但保留排序设置
const savedSortBy = state.searchFilters?.sortBy || 'stars';
const savedSortOrder = state.searchFilters?.sortOrder || 'desc';
state.searchFilters = {
...initialSearchFilters,
sortBy: savedSortBy,
sortOrder: savedSortOrder,
};</code></pre>
</li>
</ol>
</div>
<div class="test-section">
<h2>🎮 如何测试功能</h2>
<ol>
<li>启动应用:<code>npm run dev</code></li>
<li>进入仓库页面</li>
<li>修改排序方式(例如:从"按星标排序"改为"按更新排序"</li>
<li>修改排序顺序(点击 ↓/↑ 按钮)</li>
<li>刷新页面或重新打开应用</li>
<li class="success">✅ 应该看到排序设置被保留了!</li>
</ol>
</div>
<div class="test-section">
<h2>🚀 功能特点</h2>
<ul>
<li class="info"><strong>智能持久化</strong>:只保存排序相关设置,其他搜索条件仍然会重置</li>
<li class="info"><strong>用户友好</strong>:记住用户的偏好设置,提升使用体验</li>
<li class="info"><strong>向下兼容</strong>:对于没有保存设置的用户,使用合理的默认值</li>
<li class="info"><strong>轻量级</strong>:只增加了最小必要的存储内容</li>
</ul>
</div>
<div class="test-section">
<h2>📝 支持的排序选项</h2>
<ul>
<li><strong>按星标排序</strong> (stars) - 默认选项</li>
<li><strong>按更新排序</strong> (updated) - 按最后更新时间</li>
<li><strong>按名称排序</strong> (name) - 按仓库名称字母顺序</li>
<li><strong>按加星时间排序</strong> (starred) - 按用户加星的时间</li>
</ul>
<p>每种排序都支持升序 (↑) 和降序 (↓) 两种顺序。</p>
</div>
<p class="success">🎉 功能已完成!现在用户的排序偏好会被记住,不再每次都重置为按星标排序了。</p>
</body>
</html>

View File

@@ -64,7 +64,7 @@
<h1>GitHub Stars Manager - 更新功能测试</h1>
<div>
<h2>当前版本: v0.1.3</h2>
<h2>当前版本: v0.1.4</h2>
<button id="checkUpdate" onclick="checkForUpdates()">检查更新</button>
<div id="status"></div>
<div id="updateInfo"></div>

View File

@@ -18,4 +18,41 @@
</changelog>
<downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/tag/v0.1.3</downloadUrl>
</version>
</versions>
<version>
<number>0.1.4</number>
<releaseDate>2025-08-12</releaseDate>
<changelog>
<item>Persistent sorting for repository list</item>
<item>Added AI detection concurrency configuration</item>
<item>Unified repository card tag styles</item>
<item>Improved AI analysis failure indicators and added a retry button for failed items</item>
</changelog>
<downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/tag/v0.1.4</downloadUrl>
</version>
<version>
<number>0.1.5</number>
<releaseDate>2025-09-23</releaseDate>
<changelog>
<item>fix: Fixed the issue where the analysis progress becomes 0 due to switching headers during AI analysis</item>
<item>Add drag and no-drag regions for desktop app</item>
<item>switch to infinite scroll with consistent card widths</item>
</changelog>
<downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/tag/v0.1.5</downloadUrl>
</version>
<version>
<number>0.1.6</number>
<releaseDate>2025-10-27</releaseDate>
<changelog>
<item>fix: Sidebar now scrolls vertically when its content exceeds the screen height, preventing hidden or inaccessible items and improving navigation on smaller screens or long lists.</item>
</changelog>
<downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/tag/v0.1.6</downloadUrl>
</version>
<version>
<number>0.1.7</number>
<releaseDate>2025-11-16</releaseDate>
<changelog>
<item>fix: Fixed webdav backup recovery issue.</item>
</changelog>
<downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/tag/v0.1.7</downloadUrl>
</version>
</versions>

View File

@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],