mirror of
https://github.com/AmintaCCCP/GithubStarsManager.git
synced 2025-11-25 10:38:18 +08:00
Compare commits
39 Commits
dev
...
fd41b4504a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd41b4504a | ||
|
|
4c3ba04a25 | ||
|
|
008a3250bf | ||
|
|
593a319f38 | ||
|
|
1a2e61b257 | ||
|
|
56453a728f | ||
|
|
094db2697c | ||
|
|
6136d6ee29 | ||
|
|
627667750a | ||
|
|
b0982f8358 | ||
|
|
9ed8583daa | ||
|
|
eaefc7f351 | ||
|
|
8c5f71ea77 | ||
|
|
d78bcd75d6 | ||
|
|
e0af19dd2e | ||
|
|
0678fe9b04 | ||
|
|
69f4a0788c | ||
|
|
b2c49460ab | ||
|
|
b7ad4558ef | ||
|
|
e095d955e1 | ||
|
|
babe33e616 | ||
|
|
1b914584e3 | ||
|
|
deb015ca8c | ||
|
|
36636c5d31 | ||
|
|
07684356b4 | ||
|
|
d4475a644d | ||
|
|
f5d7819fc7 | ||
|
|
724bce3ff4 | ||
|
|
e49d20dcdb | ||
|
|
da13c7b759 | ||
|
|
7cddb5e480 | ||
|
|
83bf2d9334 | ||
|
|
3272ff2d66 | ||
|
|
ca65dc53ec | ||
|
|
3783e120ad | ||
|
|
3372552391 | ||
|
|
4ef03f9dec | ||
|
|
0b5d01fbb2 | ||
|
|
83bbc588db |
13
.github/workflows/build-desktop.yml
vendored
13
.github/workflows/build-desktop.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
66
DOCKER.md
Normal 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.
|
||||
103
DOCKER_IMPLEMENTATION_SUMMARY.md
Normal file
103
DOCKER_IMPLEMENTATION_SUMMARY.md
Normal 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
31
Dockerfile
Normal 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;"]
|
||||
@@ -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 文件管理和下载体验。
|
||||
@@ -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 it’s for
|
||||
|
||||
|
||||
@@ -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
4
dist/index.html
vendored
@@ -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
11
docker-compose.yml
Normal 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
76
nginx.conf
Normal 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
2
package-lock.json
generated
@@ -4162,4 +4162,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "github-stars-manager",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
28
test-docker.html
Normal 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
107
test-sort-persistence.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
|
||||
Reference in New Issue
Block a user