From c1bcbfd632f401ec1c081e0f4c5700c69ea998f6 Mon Sep 17 00:00:00 2001 From: lostiv <30612717+lostiv@users.noreply.github.com> Date: Fri, 8 May 2026 12:46:14 +0800 Subject: [PATCH 01/47] Add configuration for auto review in coderabbit --- .coderabbit.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..27177a8 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,10 @@ +language: zh-CN # 中文评论 +reviews: + auto_review: + enabled: true # 新建PR自动审核 + drafts: false # 不审核草稿PR + min_lines: 1 # 至少1行改动才触发 +path_filters: # 忽略的文件/目录 + - "!**/*.md" + - "!node_modules/**" + - "!dist/**" From 919b0f9794c138817c85b7dcec9fd6679eabf511 Mon Sep 17 00:00:00 2001 From: lostiv Date: Fri, 8 May 2026 12:52:39 +0800 Subject: [PATCH 02/47] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Docker=20Bui?= =?UTF-8?q?ld=20&=20Push=20GitHub=20Actions=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 release 发布和手动触发,前后端镜像分开构建并推送到 Docker Hub。 --- .github/workflows/docker-build.yml | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/docker-build.yml diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..fff1685 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,84 @@ +name: Docker Build & Push + +on: + release: + types: [published] + workflow_dispatch: + +env: + FRONTEND_IMAGE: lost4/gitstars + BACKEND_IMAGE: lost4/gitstars + +jobs: + generate-tags: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.tags.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Generate tags + id: tags + run: | + VER=$(node -p "require('./package.json').version") + echo "version=$VER" >> $GITHUB_OUTPUT + + build-frontend: + needs: generate-tags + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push frontend + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + provenance: false + tags: | + ${{ env.FRONTEND_IMAGE }}:${{ needs.generate-tags.outputs.version }} + ${{ env.FRONTEND_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + build-backend: + needs: generate-tags + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push backend + uses: docker/build-push-action@v6 + with: + context: ./server + file: ./server/Dockerfile + platforms: linux/amd64 + push: true + provenance: false + tags: | + ${{ env.BACKEND_IMAGE }}:${{ needs.generate-tags.outputs.version }}-backend + ${{ env.BACKEND_IMAGE }}:backend + cache-from: type=gha + cache-to: type=gha,mode=max From 2be06ec0e18ed1825cfeec98272669e21f52fd29 Mon Sep 17 00:00:00 2001 From: lostiv Date: Fri, 8 May 2026 13:12:03 +0800 Subject: [PATCH 03/47] =?UTF-8?q?fix:=20=E5=89=8D=E7=AB=AF=20Dockerfile=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=20Node=2018=20=E2=86=92=2022=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Actions=20=E6=9E=84=E5=BB=BA=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index db3976f..1fb17cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:18-alpine AS build +FROM node:22-alpine AS build WORKDIR /app From 96ce87d302d037f6a90e8ad9c98c5d8f4a16bd32 Mon Sep 17 00:00:00 2001 From: lostiv <30612717+lostiv@users.noreply.github.com> Date: Fri, 8 May 2026 13:20:31 +0800 Subject: [PATCH 04/47] Refactor CodeRabbit configuration in .coderabbit.yaml Updated CodeRabbit configuration with new review settings and ignore patterns. --- .coderabbit.yaml | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 27177a8..ba74855 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,10 +1,34 @@ -language: zh-CN # 中文评论 +# CodeRabbit 全局配置 +language: Chinese reviews: - auto_review: - enabled: true # 新建PR自动审核 - drafts: false # 不审核草稿PR - min_lines: 1 # 至少1行改动才触发 -path_filters: # 忽略的文件/目录 - - "!**/*.md" - - "!node_modules/**" - - "!dist/**" + auto_review: true + profile: balanced + high_level_summary: true + review_status: true + poem: false + walkthrough: true + +# 自动总结 PR、代码解释 +knowledge_base: + enabled: true + +# 忽略不需要审查的文件 +ignore: + - "*.md" + - "docs/**" + - "static/**" + - "assets/**" + - "node_modules/**" + - "dist/**" + - "build/**" + +# 审查规则:风格、安全、性能 +rules: + style: + enabled: true + security: + enabled: true + performance: + enabled: true + best_practices: + enabled: true From 81054ddd2fd7fd3f30b81b99d42fcbd05dd9424f Mon Sep 17 00:00:00 2001 From: lostiv Date: Fri, 8 May 2026 13:22:35 +0800 Subject: [PATCH 05/47] =?UTF-8?q?chore:=20build-desktop=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BB=85=E6=89=8B=E5=8A=A8=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-desktop.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index f26a193..8feb429 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1,11 +1,11 @@ name: Build Desktop App on: - push: - branches: [ main, master ] - tags: [ 'v*' ] - pull_request: - branches: [ main, master ] + # push: + # branches: [ main, master ] + # tags: [ 'v*' ] + # pull_request: + # branches: [ main, master ] workflow_dispatch: jobs: From 43b8fcad94718916f95b7f10e39cce0efadb5d4f Mon Sep 17 00:00:00 2001 From: lostiv Date: Fri, 8 May 2026 16:14:33 +0800 Subject: [PATCH 06/47] =?UTF-8?q?fix:=20WebDAV=20=E4=BC=98=E5=85=88?= =?UTF-8?q?=E8=B5=B0=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=90=86=EF=BC=8C=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=E6=B5=8F=E8=A7=88=E5=99=A8=20CORS=20=E8=B7=A8?= =?UTF-8?q?=E5=9F=9F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当后端可用时,WebDAVService 通过 POST /api/proxy/webdav 代理请求, 避免浏览器直接跨域访问 WebDAV 服务器。后端不可用时回退到浏览器直连。 --- src/services/webdavService.ts | 355 ++++++++++++++++++++++------------ 1 file changed, 229 insertions(+), 126 deletions(-) diff --git a/src/services/webdavService.ts b/src/services/webdavService.ts index 2d9e631..64a9603 100644 --- a/src/services/webdavService.ts +++ b/src/services/webdavService.ts @@ -1,4 +1,5 @@ import { WebDAVConfig } from '../types'; +import { backend } from './backendAdapter'; export class WebDAVService { private config: WebDAVConfig; @@ -7,6 +8,24 @@ export class WebDAVService { this.config = config; } + private get useProxy(): boolean { + return backend.isAvailable; + } + + private get basePath(): string { + return this.config.path.endsWith('/') ? this.config.path : `${this.config.path}/`; + } + + // 通过后端代理发送请求,避免浏览器 CORS 限制 + private async proxyFetch( + method: string, + fullPath: string, + body?: string, + headers?: Record, + ): Promise { + return backend.proxyWebDAV(this.config.id, method, fullPath, body, headers); + } + // 压缩JSON数据,减少传输大小 private compressData(content: string): string { try { @@ -78,13 +97,16 @@ export class WebDAVService { } private getFullPath(filename: string): string { - const basePath = this.config.path.endsWith('/') ? this.config.path : `${this.config.path}/`; - return `${this.config.url}${basePath}${filename}`; + return `${this.config.url}${this.basePath}${filename}`; + } + + private getRelativePath(filename: string): string { + return `${this.basePath}${filename}`; } private handleNetworkError(error: unknown, operation: string): never { console.error(`WebDAV ${operation} failed:`, error); - + const err = error as Error; const isCorsError = ( (err.name === 'TypeError' && err.message.includes('Failed to fetch')) || @@ -122,7 +144,7 @@ export class WebDAVService { 技术详情: ${err.message}`); } - + throw new Error(`WebDAV ${operation} 失败: ${err.message || '未知错误'}`); } @@ -133,12 +155,27 @@ export class WebDAVService { throw new Error('WebDAV URL必须以 http:// 或 https:// 开头'); } - // 构建用于测试的目录URL(优先测试配置中的 path) + // 优先走后端代理,避免 CORS + if (this.useProxy) { + try { + // HEAD 请求检测可达性 + const headResponse = await this.proxyFetch('HEAD', this.config.path); + if (headResponse.ok) return true; + + // 回退 PROPFIND + const propfindResponse = await this.proxyFetch('PROPFIND', this.config.path, undefined, { Depth: '0' }); + return propfindResponse.ok || propfindResponse.status === 207; + } catch (proxyErr) { + console.warn('代理连接测试失败,回退到直连:', proxyErr); + // 回退到浏览器直连 + } + } + + // 浏览器直连(回退方案) const dirUrl = `${this.config.url}${this.config.path}`; - // 先尝试 HEAD 请求检测基本可达性(某些服务器对 PROPFIND/OPTIONS 支持较差) const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 + const timeoutId = setTimeout(() => controller.abort(), 10000); try { const headResponse = await fetch(dirUrl, { @@ -153,7 +190,6 @@ export class WebDAVService { if (headResponse.ok) return true; - // HEAD 不可用时,尝试 PROPFIND(不少服务器返回 207 Multi-Status 表示成功) const propfindResponse = await fetch(dirUrl, { method: 'PROPFIND', headers: { @@ -165,11 +201,11 @@ export class WebDAVService { return propfindResponse.ok || propfindResponse.status === 207; } catch (fetchError: unknown) { clearTimeout(timeoutId); - + if ((fetchError as Error).name === 'AbortError') { throw new Error('连接超时。请检查WebDAV服务器是否可访问。'); } - + throw fetchError; } } catch (error: unknown) { @@ -197,12 +233,30 @@ export class WebDAVService { // 确保目录存在 await this.ensureDirectoryExists(); - // 动态计算超时时间:基于压缩后文件大小,最小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`); - const uploadOperation = async (): Promise => { + if (this.useProxy) { + const response = await this.proxyFetch( + 'PUT', + this.getRelativePath(filename), + compressedContent, + { 'Content-Type': 'application/json' }, + ); + + 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}`); + } + + return true; + } + + // 浏览器直连(回退方案) + const finalSizeKB = Math.round(compressedContent.length / 1024); + const dynamicTimeout = Math.max(60000, Math.min(300000, finalSizeKB * 100)); + const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), dynamicTimeout); @@ -220,18 +274,10 @@ export class WebDAVService { 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('服务器存储空间不足。'); - } + 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}`); } @@ -250,8 +296,8 @@ export class WebDAVService { return await this.retryUpload(uploadOperation); } catch (error: unknown) { const err = error as Error; - if (err.message.includes('身份验证失败') || - err.message.includes('访问被拒绝') || + if (err.message.includes('身份验证失败') || + err.message.includes('访问被拒绝') || err.message.includes('路径未找到') || err.message.includes('存储空间不足') || err.message.includes('上传失败,HTTP状态码') || @@ -266,46 +312,72 @@ export class WebDAVService { private async ensureDirectoryExists(): Promise { try { if (!this.config.path || this.config.path === '/') { - return; // 根目录总是存在 + return; } - // 逐级创建目录,避免服务器因中间目录不存在而返回 409/403 - const cleanedPath = this.config.path.replace(/\/+$/, ''); // 去掉末尾斜杠 - const segments = cleanedPath.split('/').filter(Boolean); // 去掉空段 + 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) { + if (this.useProxy) { + try { + const res = await this.proxyFetch('MKCOL', currentPath); + if (!res.ok && res.status !== 405 && res.status !== 409) { console.warn(`无法创建目录 ${currentPath},状态码: ${res.status}`); - break; // 不再继续往下建 + break; + } + } catch (e) { + console.warn(`创建目录 ${currentPath} 发生异常:`, e); + break; + } + } else { + const full = `${this.config.url}${currentPath}`; + try { + const res = await fetch(full, { + method: 'MKCOL', + headers: { 'Authorization': this.getAuthHeader() }, + }); + + if (!res.ok && res.status !== 405) { + if (res.status !== 409) { + console.warn(`无法创建目录 ${currentPath},状态码: ${res.status}`); + break; + } } + } catch (e) { + console.warn(`创建目录 ${currentPath} 发生异常:`, e); + break; } - } catch (e) { - console.warn(`创建目录 ${currentPath} 发生异常:`, e); - break; } } } catch (error) { console.warn('目录创建检查失败:', error); - // 不在这里抛出错误,因为目录可能已经存在 } } async downloadFile(filename: string): Promise { try { + if (this.useProxy) { + const response = await this.proxyFetch('GET', this.getRelativePath(filename)); + + if (response.ok) { + const data: unknown = await response.json(); + // 后端代理返回的是 JSON 包装的数据,可能是已解析的对象或字符串 + if (typeof data === 'string') return data; + return JSON.stringify(data); + } + + if (response.status === 404) return null; + if (response.status === 401) throw new Error('身份验证失败。请检查用户名和密码。'); + throw new Error(`下载失败,HTTP状态码 ${response.status}`); + } + + // 浏览器直连(回退方案) const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时 + const timeoutId = setTimeout(() => controller.abort(), 30000); try { const response = await fetch(this.getFullPath(filename), { @@ -315,34 +387,34 @@ export class WebDAVService { }, signal: controller.signal, }); - + clearTimeout(timeoutId); if (response.ok) { return await response.text(); } - + if (response.status === 404) { - return null; // 文件未找到是预期行为 + return null; } - + if (response.status === 401) { throw new Error('身份验证失败。请检查用户名和密码。'); } - + throw new Error(`下载失败,HTTP状态码 ${response.status}: ${response.statusText}`); } catch (fetchError: unknown) { clearTimeout(timeoutId); - + if ((fetchError as Error).name === 'AbortError') { throw new Error('下载超时。请检查网络连接。'); } - + throw fetchError; } } catch (error: unknown) { const err = error as Error; - if (err.message.includes('身份验证失败') || + if (err.message.includes('身份验证失败') || err.message.includes('下载超时')) { throw error; } @@ -355,8 +427,13 @@ export class WebDAVService { async fileExists(filename: string): Promise { try { + if (this.useProxy) { + const response = await this.proxyFetch('HEAD', this.getRelativePath(filename)); + return response.ok; + } + const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 + const timeoutId = setTimeout(() => controller.abort(), 10000); const response = await fetch(this.getFullPath(filename), { method: 'HEAD', @@ -376,13 +453,36 @@ export class WebDAVService { async listFiles(): Promise { try { + if (this.useProxy) { + const response = await this.proxyFetch( + 'PROPFIND', + this.config.path, + ` + + + + + + + `, + { Depth: '1', 'Content-Type': 'application/xml' }, + ); + + if (response.ok || response.status === 207) { + const data: unknown = await response.json(); + const xmlText = typeof data === 'string' ? data : JSON.stringify(data); + return this.parsePropfindXml(xmlText); + } + if (response.status === 401) throw new Error('身份验证失败。请检查用户名和密码。'); + throw new Error(`列出文件失败,HTTP状态码 ${response.status}`); + } + + // 浏览器直连(回退方案) const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时 + const timeoutId = setTimeout(() => controller.abort(), 15000); try { - // 确保目录URL以斜杠结尾,避免部分服务器对集合路径的歧义 - const basePath = this.config.path.endsWith('/') ? this.config.path : `${this.config.path}/`; - const collectionUrl = `${this.config.url}${basePath}`; + const collectionUrl = `${this.config.url}${this.basePath}`; const response = await fetch(collectionUrl, { method: 'PROPFIND', @@ -406,78 +506,24 @@ export class WebDAVService { if (response.ok || response.status === 207) { const xmlText = await response.text(); - - // 优先用 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 { - // 忽略单个条目解析失败 - } - } - - if (results.length > 0) return results; - } catch { - // DOMParser 失败时降级为正则提取 href/displayname - const namesFromDisplay = (xmlText.match(/([^<]+)<\/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>/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) { + return this.parsePropfindXml(xmlText); + } + if (response.status === 401) { throw new Error('身份验证失败。请检查用户名和密码。'); - } else { - throw new Error(`列出文件失败,HTTP状态码 ${response.status}: ${response.statusText}`); } - return []; + throw new Error(`列出文件失败,HTTP状态码 ${response.status}: ${response.statusText}`); } catch (fetchError: unknown) { clearTimeout(timeoutId); - + if ((fetchError as Error).name === 'AbortError') { throw new Error('列出文件超时。请检查网络连接。'); } - + throw fetchError; } } catch (error: unknown) { const err = error as Error; - if (err.message.includes('身份验证失败') || + if (err.message.includes('身份验证失败') || err.message.includes('列出文件超时')) { throw error; } @@ -485,7 +531,64 @@ export class WebDAVService { } } - // 新增:验证配置的静态方法 + private parsePropfindXml(xmlText: string): string[] { + const collectionUrl = `${this.config.url}${this.basePath}`; + + // 优先用 DOMParser 解析 + 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; + + 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 { + // 忽略单个条目解析失败 + } + } + + if (results.length > 0) return results; + } catch { + // DOMParser 失败时降级为正则提取 + } + + const namesFromDisplay = (xmlText.match(/([^<]+)<\/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>/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; + + return []; + } + + // 验证配置的静态方法 static validateConfig(config: Partial): string[] { const errors: string[] = []; @@ -512,7 +615,7 @@ export class WebDAVService { return errors; } - // 新增:获取服务器信息 + // 获取服务器信息 async getServerInfo(): Promise<{ server?: string; davLevel?: string }> { try { const response = await fetch(this.config.url, { @@ -531,7 +634,7 @@ export class WebDAVService { } catch (error) { console.warn('无法获取服务器信息:', error); } - + return {}; } } From b0ed119d3e81faf6c8738d6881fff063ce0045e0 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 10:42:08 +0800 Subject: [PATCH 07/47] =?UTF-8?q?@=20chore:=20=E4=BD=BF=E7=94=A8=E9=A2=84?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E9=95=9C=E5=83=8F=E6=9B=BF=E4=BB=A3=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端使用 lost4/gitstars:latest,后端使用 lost4/gitstars:backend @ --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 85c99be..87b4c18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: frontend: - build: . + image: lost4/gitstars:latest ports: - "8080:80" depends_on: @@ -10,7 +10,7 @@ services: restart: unless-stopped backend: - build: ./server + image: lost4/gitstars:backend expose: - "3000" environment: From 839af40caabc64178d4357ef38e09a0156d0a424 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 11:29:00 +0800 Subject: [PATCH 08/47] =?UTF-8?q?@=20fix:=20=E4=BF=AE=E5=A4=8D=20PUT=20/ap?= =?UTF-8?q?i/releases=20=E7=BC=BA=E5=B0=91=E4=B8=80=E4=B8=AA=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E7=AC=A6=E5=AF=BC=E8=87=B4=E6=8F=92=E5=85=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VALUES 子句只有 14 个 ?,但表有 15 列,补全为 15 个 @ --- server/src/routes/releases.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/releases.ts b/server/src/routes/releases.ts index 840c1bd..1aedd09 100644 --- a/server/src/routes/releases.ts +++ b/server/src/routes/releases.ts @@ -100,7 +100,7 @@ router.put('/api/releases', (req, res) => { prerelease, draft, is_read, assets, repo_id, repo_full_name, repo_name, zipball_url, tarball_url - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const upsert = db.transaction(() => { From fb9ec53d496d6636af7379c4da2c2c1411937284 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 11:44:18 +0800 Subject: [PATCH 09/47] =?UTF-8?q?@=20fix:=20=E4=BF=AE=E5=A4=8D=20PUT=20/ap?= =?UTF-8?q?i/settings=20=E5=AD=98=E5=82=A8=E9=9D=9E=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=E4=B8=B2=E5=80=BC=E6=97=B6=E6=8A=A5=E5=8F=82=E6=95=B0=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动将数组/对象序列化为 JSON 字符串存储,读取时自动解析回原始类型 @ --- server/src/routes/configs.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index f3838a5..2185595 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -426,7 +426,12 @@ router.get('/api/settings', (_req, res) => { settings.github_token_status = status; } - settings[key] = value; + // Try to parse JSON values back to objects/arrays + if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) { + try { settings[key] = JSON.parse(value); } catch { settings[key] = value; } + } else { + settings[key] = value; + } } res.json(settings); @@ -456,7 +461,7 @@ router.put('/api/settings', (req, res) => { value = encrypt(value, config.encryptionKey); } - stmt.run(key, value ?? null); + stmt.run(key, typeof value === 'string' ? value : value != null ? JSON.stringify(value) : null); } }); From 82a0927ee55d4448c96f23b6fd253f285cea3196 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 11:57:13 +0800 Subject: [PATCH 10/47] =?UTF-8?q?@=20chore:=20=E9=80=82=E9=85=8D=20CodeRab?= =?UTF-8?q?bit=20=E9=85=8D=E7=BD=AE=E5=88=B0=20GithubStarsManager=20?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 技术栈:React 18 + Express + better-sqlite3 + Tailwind CSS @ --- .coderabbit.yaml | 366 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 339 insertions(+), 27 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ba74855..a37f28e 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,34 +1,346 @@ -# CodeRabbit 全局配置 -language: Chinese +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +# ────────────────────────────────────────────── +# CodeRabbit 配置 — GithubStarsManager +# 文档:https://docs.coderabbit.ai/guides/configure-coderabbit +# ────────────────────────────────────────────── + +language: "zh-CN" + +tone_instructions: > + 请用简洁专业的中文回复,重点关注代码质量、安全性和性能优化。 + 前端 React 18 + TypeScript + Vite + Tailwind CSS + Zustand。 + 后端 Express + better-sqlite3 (Node.js + TypeScript)。 + 注意暗色/亮色双主题兼容(dark: 前缀),以及移动端响应式布局。 + +early_access: true +enable_free_tier: true + +# ────────────────────────────────────────────── +# 审查设置 +# ────────────────────────────────────────────── reviews: - auto_review: true - profile: balanced + profile: "chill" + request_changes_workflow: false + high_level_summary: true + high_level_summary_placeholder: "@coderabbitai summary" + high_level_summary_in_walkthrough: true + changed_files_summary: true + + sequence_diagrams: true + estimate_code_review_effort: true + assess_linked_issues: true + related_prs: true + + suggested_labels: true + auto_apply_labels: true + suggested_reviewers: false + auto_assign_reviewers: false + review_status: true + commit_status: true + fail_commit_status: false + + collapse_walkthrough: true + review_details: true + poem: false - walkthrough: true + in_progress_fortune: false -# 自动总结 PR、代码解释 -knowledge_base: - enabled: true - -# 忽略不需要审查的文件 -ignore: - - "*.md" - - "docs/**" - - "static/**" - - "assets/**" - - "node_modules/**" - - "dist/**" - - "build/**" - -# 审查规则:风格、安全、性能 -rules: - style: - enabled: true - security: - enabled: true - performance: + enable_prompt_for_ai_agents: true + + # ── 标签规则 ── + labeling_instructions: + - label: "frontend" + instructions: "当 PR 包含 src/ 目录下的文件变更时应用此标签" + - label: "backend" + instructions: "当 PR 包含 server/ 目录下的文件变更时应用此标签" + - label: "docs" + instructions: "当 PR 仅包含 *.md 文件变更时应用此标签" + - label: "styles" + instructions: "当 PR 主要修改 CSS 文件或 Tailwind 样式时应用此标签" + - label: "bug" + instructions: "当 PR 标题或描述包含 fix/bugfix/修复 关键词时应用此标签" + - label: "feature" + instructions: "当 PR 标题或描述包含 feat/feature/新增 关键词时应用此标签" + - label: "docker" + instructions: "当 PR 包含 Dockerfile 或 docker-compose.yml 变更时应用此标签" + - label: "ci" + instructions: "当 PR 包含 .github/workflows/ 目录下的文件变更时应用此标签" + + # ── 文件过滤 ── + path_filters: + - "!**/node_modules/**" + - "!**/dist/**" + - "!**/build/**" + - "!**/data/**" + - "!**/package-lock.json" + - "!**/*.log" + - "!upload/**" + - "!templates/**" + - "!versions/**" + + # ── 路径级审查指令 ── + path_instructions: + - path: "src/components/**" + instructions: > + React 组件目录(TypeScript + Tailwind CSS)。审查时请关注: + 1. 是否同时兼容暗色(dark: 前缀)和亮色主题 + 2. 响应式布局是否完整(Tailwind 断点系统:sm/md/lg/xl) + 3. Props 类型定义是否完整(TypeScript interface) + 4. 组件是否保持单一职责 + 5. 无障碍访问(aria-label、role、键盘导航) + 6. React.memo / useMemo / useCallback 的使用是否合理 + + - path: "src/components/settings/**" + instructions: > + 设置面板组件。审查时请关注: + 1. 表单状态管理和输入校验 + 2. 多语言文本处理(t(zh, en) 函数) + 3. API 密钥/密码等敏感信息的输入框类型(type="password") + 4. 异步操作的状态反馈(loading/error/success) + + - path: "src/components/ui/**" + instructions: > + 通用 UI 组件。审查时请关注: + 1. 组件的可复用性和可组合性 + 2. Props 接口的通用性 + 3. 暗色/亮色主题兼容 + + - path: "src/store/**" + instructions: > + Zustand 状态管理。审查时请关注: + 1. 持久化存储(IndexedDB)的数据结构和版本迁移 + 2. Selector 的细粒度订阅(避免不必要的重渲染) + 3. 敏感数据是否被持久化到客户端 + 4. 状态更新的原子性和一致性 + + - path: "src/services/**" + instructions: > + API 服务层。审查时请关注: + 1. fetch 请求的超时处理(AbortController) + 2. 错误处理和用户友好的错误信息翻译 + 3. API 密钥/Token 的传输安全性 + 4. 请求重试和降级策略 + 5. WebDAV/后端代理的 CORS 处理 + + - path: "src/hooks/**" + instructions: > + 自定义 React Hooks。审查时请关注: + 1. 依赖数组的正确性(useEffect/useCallback/useMemo) + 2. 清理函数是否正确(避免内存泄漏) + 3. 闭包陷阱(stale closure) + + - path: "src/types/**" + instructions: > + TypeScript 类型定义。审查时请关注: + 1. 类型定义的准确性和完整性 + 2. 是否合理使用 interface vs type + 3. 泛型约束是否恰当 + + - path: "server/src/routes/**" + instructions: > + Express 路由层。审查时请关注: + 1. 输入验证(req.body/req.params/req.query 的类型和范围检查) + 2. SQL 注入防护(全部使用 ? 参数化查询,禁止字符串拼接) + 3. 事务使用是否正确(批量操作应使用 db.transaction()) + 4. 错误处理是否完善(try-catch + 统一错误响应格式) + 5. 认证中间件是否正确应用(authMiddleware) + 6. JSON 响应格式统一:{ data/error, code } + + - path: "server/src/db/**" + instructions: > + better-sqlite3 数据访问层。审查时请关注: + 1. 全部使用 ? 参数化查询,禁止字符串拼接 + 2. 数据库迁移的兼容性(addColumnIfMissing 模式) + 3. WAL 模式和外键约束是否正确 + 4. 事务边界是否合理 + 5. 批量 INSERT 是否使用 db.transaction() 包裹 + + - path: "server/src/middleware/**" + instructions: > + Express 中间件。审查时请关注: + 1. 认证逻辑的安全性(timingSafeEqual 防时序攻击) + 2. 错误处理中间件的完整性 + 3. 中间件执行顺序是否正确 + + - path: "server/src/services/**" + instructions: > + 后端服务层。审查时请关注: + 1. 加密解密逻辑的正确性(AES-256-GCM) + 2. 密钥管理和随机性 + 3. 错误处理不泄露敏感信息 + + - path: "nginx.conf" + instructions: > + Nginx 配置文件。审查时请关注: + 1. 反向代理配置的正确性(/api/ → backend:3000) + 2. CORS 头是否安全(避免反射任意 Origin) + 3. 安全头(X-Frame-Options/X-XSS-Protection/X-Content-Type-Options) + 4. 上传大小限制(client_max_body_size) + 5. 代理超时设置 + + - path: "Dockerfile" + instructions: > + 前端 Docker 构建(多阶段:node:18-alpine → nginx:alpine)。审查时请关注: + 1. 构建阶段是否缓存了依赖层(npm ci) + 2. 最终镜像体积 + 3. 非 root 用户运行(如适用) + + - path: "server/Dockerfile" + instructions: > + 后端 Docker 构建(多阶段:node:22-alpine → node:22-alpine)。审查时请关注: + 1. npm prune --omit=dev 是否正确剥离开发依赖 + 2. 数据卷挂载是否正确(/app/data) + 3. 非 root 用户运行(USER node) + 4. 端口暴露(EXPOSE 3000) + + - path: "docker-compose.yml" + instructions: > + Docker Compose 配置。审查时请关注: + 1. 环境变量安全性(API_SECRET/ENCRYPTION_KEY 不应硬编码) + 2. 卷挂载是否正确(数据持久化) + 3. 服务依赖关系(depends_on) + + - path: ".github/workflows/**" + instructions: > + GitHub Actions 工作流。审查时请关注: + 1. 密钥/expose安全性(不应在日志中外泄) + 2. 构建步骤的幂等性 + 3. 缓存策略是否合理 + + # ── 自动审查 ── + auto_review: enabled: true - best_practices: + auto_incremental_review: true + auto_pause_after_reviewed_commits: 5 + drafts: false + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + - "wip" + base_branches: + - "main" + + # ── Anti-Slop 检测 ── + slop_detection: enabled: true + + # ── Finishing Touches ── + finishing_touches: + docstrings: + enabled: true + unit_tests: + enabled: true + simplify: + enabled: true + + # ── Pre-merge 检查 ── + pre_merge_checks: + title: + mode: "warning" + requirements: > + PR 标题应遵循 Conventional Commits 格式: + type(scope): description + 类型包括:feat, fix, docs, style, refactor, perf, test, chore + description: + mode: "warning" + docstrings: + mode: "off" + + # ── 工具配置 ── + tools: + # Node.js / TypeScript + eslint: + enabled: true + + # Shell 脚本 + shellcheck: + enabled: true + + # Dockerfile + hadolint: + enabled: true + + # Markdown 文档 + markdownlint: + enabled: true + + # YAML 配置 + yamllint: + enabled: true + + # GitHub Actions + actionlint: + enabled: true + + # AST 语义分析 + ast-grep: + essential_rules: true + + # 安全扫描 + gitleaks: + enabled: true + trufflehog: + enabled: true + + # GitHub 集成 + github-checks: + enabled: true + timeout_ms: 120000 + + # 关闭不相关的语言/框架工具 + biome: + enabled: false + ruff: + enabled: false + flake8: + enabled: false + phpstan: + enabled: false + phpmd: + enabled: false + phpcs: + enabled: false + golangci-lint: + enabled: false + swiftlint: + enabled: false + checkov: + enabled: false + tflint: + enabled: false + detekt: + enabled: false + rubocop: + enabled: false + buf: + enabled: false + regal: + enabled: false + pmd: + enabled: false + clang: + enabled: false + cppcheck: + enabled: false + fortitudeLint: + enabled: false + +# ────────────────────────────────────────────── +# 聊天设置 +# ────────────────────────────────────────────── +chat: + auto_reply: true + +# ────────────────────────────────────────────── +# 知识库 +# ────────────────────────────────────────────── +knowledge_base: + learnings: + scope: "auto" + issues: + scope: "auto" + pull_requests: + scope: "auto" From d240aba3ced52f79e57b7b1cb482dec3f721d7c5 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 13:42:25 +0800 Subject: [PATCH 11/47] =?UTF-8?q?@=20feat:=20=E5=B0=86=20AI=20=E5=88=86?= =?UTF-8?q?=E6=9E=90=E7=A7=BB=E5=88=B0=E5=90=8E=E7=AB=AF=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E5=89=8D=E7=AB=AF=E4=BB=85=E5=8F=91=E8=B5=B7=E5=92=8C?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增 analysisService(README 获取 → AI 调用 → 解析入库)和 analysis 路由。 前端新增 backendAnalysisService 轮询服务,RepositoryList/RepositoryCard/ DiscoveryView/SubscriptionRepoCard 均采用后端优先、前端降级策略。 修复了 openai-responses 响应解析缺失、DiscoveryView 停止按钮无效、 批量分析 toast 不区分停止/完成 三个 bug。 --- server/src/index.ts | 3 + server/src/routes/analysis.ts | 89 ++++ server/src/services/analysisService.ts | 549 ++++++++++++++++++++++++ src/components/DiscoveryView.tsx | 103 +++++ src/components/RepositoryCard.tsx | 30 ++ src/components/RepositoryList.tsx | 104 ++++- src/components/SubscriptionRepoCard.tsx | 64 ++- src/services/backendAdapter.ts | 29 ++ src/services/backendAnalysisService.ts | 148 +++++++ 9 files changed, 1114 insertions(+), 5 deletions(-) create mode 100644 server/src/routes/analysis.ts create mode 100644 server/src/services/analysisService.ts create mode 100644 src/services/backendAnalysisService.ts diff --git a/server/src/index.ts b/server/src/index.ts index 42ba420..c609aba 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ import categoriesRouter from './routes/categories.js'; import configsRouter from './routes/configs.js'; import syncRouter from './routes/sync.js'; import proxyRouter from './routes/proxy.js'; +import analysisRouter from './routes/analysis.js'; export function createApp(): express.Express { const app = express(); @@ -40,6 +41,8 @@ export function createApp(): express.Express { // Wave 3: Proxy routes app.use(proxyRouter); + app.use(analysisRouter); + // Global error handler app.use(errorHandler); diff --git a/server/src/routes/analysis.ts b/server/src/routes/analysis.ts new file mode 100644 index 0000000..820f60a --- /dev/null +++ b/server/src/routes/analysis.ts @@ -0,0 +1,89 @@ +import { Router } from 'express'; +import { getDb } from '../db/connection.js'; +import * as analysisService from '../services/analysisService.js'; + +const router = Router(); + +// POST /api/analysis/batch — start analysis +router.post('/api/analysis/batch', (req, res) => { + try { + const { repositoryIds, configId, language, categoryNames } = req.body as { + repositoryIds?: unknown; + configId?: unknown; + language?: unknown; + categoryNames?: unknown; + }; + + if (!Array.isArray(repositoryIds) || repositoryIds.length === 0) { + res.status(400).json({ error: 'repositoryIds must be a non-empty array', code: 'ANALYSIS_INVALID_REQUEST' }); + return; + } + if (!repositoryIds.every((id) => typeof id === 'number' && id > 0)) { + res.status(400).json({ error: 'repositoryIds must be positive integers', code: 'ANALYSIS_INVALID_REQUEST' }); + return; + } + if (!configId || typeof configId !== 'string') { + res.status(400).json({ error: 'configId is required', code: 'ANALYSIS_INVALID_REQUEST' }); + return; + } + + // Verify config exists + const db = getDb(); + const aiConfig = db.prepare('SELECT id FROM ai_configs WHERE id = ?').get(configId); + if (!aiConfig) { + res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); + return; + } + + const lang = typeof language === 'string' && language === 'en' ? 'en' : 'zh'; + const cats = Array.isArray(categoryNames) ? categoryNames.filter((c) => typeof c === 'string') : []; + + const batch = analysisService.createBatch( + repositoryIds as number[], + configId, + lang, + cats, + ); + + res.status(202).json({ + batchId: batch.batchId, + status: batch.status, + total: batch.total, + }); + } catch (err) { + console.error('POST /api/analysis/batch error:', err); + res.status(500).json({ error: 'Failed to start analysis', code: 'ANALYSIS_START_FAILED' }); + } +}); + +// GET /api/analysis/batch/:batchId — check progress +router.get('/api/analysis/batch/:batchId', (req, res) => { + try { + const batch = analysisService.getBatchStatus(req.params.batchId); + if (!batch) { + res.status(404).json({ error: 'Batch not found', code: 'ANALYSIS_BATCH_NOT_FOUND' }); + return; + } + res.json(batch); + } catch (err) { + console.error('GET /api/analysis/batch error:', err); + res.status(500).json({ error: 'Failed to get analysis progress', code: 'ANALYSIS_PROGRESS_FAILED' }); + } +}); + +// POST /api/analysis/batch/:batchId/cancel — cancel analysis +router.post('/api/analysis/batch/:batchId/cancel', (_req, res) => { + try { + const cancelled = analysisService.cancelBatch(_req.params.batchId); + if (!cancelled) { + res.status(404).json({ error: 'Batch not found or already completed', code: 'ANALYSIS_BATCH_NOT_FOUND' }); + return; + } + res.json({ batchId: _req.params.batchId, status: 'cancelled' }); + } catch (err) { + console.error('POST /api/analysis/batch/cancel error:', err); + res.status(500).json({ error: 'Failed to cancel analysis', code: 'ANALYSIS_CANCEL_FAILED' }); + } +}); + +export default router; diff --git a/server/src/services/analysisService.ts b/server/src/services/analysisService.ts new file mode 100644 index 0000000..840ad66 --- /dev/null +++ b/server/src/services/analysisService.ts @@ -0,0 +1,549 @@ +import { getDb } from '../db/connection.js'; +import { decrypt } from './crypto.js'; +import { config } from '../config.js'; +import { proxyRequest } from './proxyService.js'; + +// ── Types ── + +interface AnalysisBatch { + batchId: string; + repositoryIds: number[]; + configId: string; + language: string; + categoryNames: string[]; + status: 'running' | 'completed' | 'cancelled' | 'failed'; + total: number; + completed: number; + failed: number; + startedAt: string; + completedAt: string | null; + cancelRequested: boolean; +} + +interface RepoInfo { + id: number; + full_name: string; + name: string; + description: string | null; + language: string | null; + stargazers_count: number; + topics: string | null; + owner_login: string; +} + +// ── In-memory state ── + +const batches = new Map(); + +function generateId(): string { + return `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +// ── Public API ── + +export function createBatch( + repositoryIds: number[], + configId: string, + language: string, + categoryNames: string[], +): AnalysisBatch { + const batch: AnalysisBatch = { + batchId: generateId(), + repositoryIds, + configId, + language, + categoryNames, + status: 'running', + total: repositoryIds.length, + completed: 0, + failed: 0, + startedAt: new Date().toISOString(), + completedAt: null, + cancelRequested: false, + }; + batches.set(batch.batchId, batch); + + // Fire and forget — don't await + runBatch(batch.batchId).catch((err) => { + console.error(`[analysis] Batch ${batch.batchId} failed:`, err); + const b = batches.get(batch.batchId); + if (b && b.status === 'running') { + b.status = 'failed'; + b.completedAt = new Date().toISOString(); + } + }); + + return batch; +} + +export function getBatchStatus(batchId: string): AnalysisBatch | undefined { + return batches.get(batchId); +} + +export function cancelBatch(batchId: string): boolean { + const batch = batches.get(batchId); + if (!batch || batch.status !== 'running') return false; + batch.cancelRequested = true; + batch.status = 'cancelled'; + batch.completedAt = new Date().toISOString(); + return true; +} + +// ── Core execution ── + +async function runBatch(batchId: string): Promise { + const batch = batches.get(batchId); + if (!batch) return; + + const db = getDb(); + const aiConfig = db.prepare('SELECT * FROM ai_configs WHERE id = ?').get(batch.configId) as Record | undefined; + if (!aiConfig) { + batch.status = 'failed'; + batch.completedAt = new Date().toISOString(); + return; + } + + const concurrency = Math.max(1, Math.min((aiConfig.concurrency as number) || 1, 10)); + + const queue = [...batch.repositoryIds]; + let activeWorkers = 0; + + const processNext = async (): Promise => { + while (true) { + if (batch.cancelRequested) return; + + const repoId = queue.shift(); + if (repoId === undefined) return; + + activeWorkers++; + try { + await processRepository(batch, repoId, aiConfig); + batch.completed++; + } catch (err) { + console.error(`[analysis] Failed to analyze repo ${repoId}:`, err); + batch.failed++; + } finally { + activeWorkers--; + } + } + }; + + const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => processNext()); + await Promise.all(workers); + + if (!batch.cancelRequested) { + batch.status = 'completed'; + batch.completedAt = new Date().toISOString(); + } +} + +async function processRepository( + batch: AnalysisBatch, + repoId: number, + aiConfig: Record, +): Promise { + if (batch.cancelRequested) return; + + const db = getDb(); + const repo = db.prepare('SELECT id, full_name, name, description, language, stargazers_count, topics, owner_login FROM repositories WHERE id = ?').get(repoId) as RepoInfo | undefined; + if (!repo) { + markAnalysisFailed(repoId); + return; + } + + // 1. Fetch README + let readmeContent = ''; + try { + readmeContent = await fetchReadme(repo.owner_login, repo.name); + } catch { + // README fetch failure is not fatal — analyze without it + } + + // 2. Build prompt + const customPrompt = aiConfig.custom_prompt as string | undefined; + const useCustomPrompt = !!(aiConfig.use_custom_prompt as number); + const language = batch.language; + + const prompt = useCustomPrompt && customPrompt + ? buildCustomPrompt(customPrompt, repo, readmeContent, batch.categoryNames, language) + : buildAnalysisPrompt(repo, readmeContent, batch.categoryNames, language); + + // 3. Call AI + const systemPrompt = language === 'zh' + ? '你是一个专业的GitHub仓库分析助手。请严格按照用户指定的语言进行分析,无论原始内容是什么语言。请用中文简洁地分析仓库,提供实用的概述、分类标签和支持的平台类型。' + : 'You are a professional GitHub repository analysis assistant. Please strictly analyze in the language specified by the user, regardless of the original content language. Please analyze repositories concisely in English, providing practical overviews, category tags, and supported platform types.'; + + try { + const content = await callAI(aiConfig, systemPrompt, prompt); + const result = parseAIResponse(content, language); + + db.prepare(` + UPDATE repositories + SET ai_summary = ?, ai_tags = ?, ai_platforms = ?, analyzed_at = ?, analysis_failed = 0 + WHERE id = ? + `).run( + result.summary, + JSON.stringify(result.tags), + JSON.stringify(result.platforms), + new Date().toISOString(), + repoId, + ); + } catch (err) { + console.error(`[analysis] AI call failed for repo ${repoId}:`, err); + markAnalysisFailed(repoId); + } +} + +function markAnalysisFailed(repoId: number): void { + const db = getDb(); + db.prepare('UPDATE repositories SET analyzed_at = ?, analysis_failed = 1 WHERE id = ?').run( + new Date().toISOString(), + repoId, + ); +} + +// ── README fetching ── + +async function fetchReadme(owner: string, repo: string): Promise { + const db = getDb(); + const tokenRow = db.prepare('SELECT value FROM settings WHERE key = ?').get('github_token') as { value: string } | undefined; + let token = ''; + if (tokenRow?.value) { + try { + token = decrypt(tokenRow.value, config.encryptionKey); + } catch { + // token decryption failed, proceed without auth + } + } + + const headers: Record = { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'GithubStarsManager', + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const result = await proxyRequest({ + url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/readme`, + method: 'GET', + headers, + timeout: 15000, + }); + + if (result.status !== 200 || !result.data) { + throw new Error(`GitHub README fetch failed: ${result.status}`); + } + + const data = result.data as { encoding?: string; content?: string }; + if (data.encoding === 'base64' && data.content) { + return Buffer.from(data.content, 'base64').toString('utf-8'); + } + return data.content || ''; +} + +// ── AI API call ── + +function buildApiUrl(baseUrl: string, pathWithVersion: string): string { + const baseUrlWithSlash = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + try { + return new URL(pathWithVersion, baseUrlWithSlash).toString(); + } catch { + return `${baseUrlWithSlash}${pathWithVersion}`; + } +} + +async function callAI( + aiConfig: Record, + systemPrompt: string, + userPrompt: string, +): Promise { + const apiKey = decrypt(aiConfig.api_key_encrypted as string, config.encryptionKey); + const apiType = (aiConfig.api_type as string) || 'openai'; + const baseUrl = aiConfig.base_url as string; + const model = aiConfig.model as string; + const reasoningEffort = aiConfig.reasoning_effort === 'minimal' + ? 'low' + : aiConfig.reasoning_effort as string | null | undefined; + + let targetUrl: string; + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + let requestBody: Record; + + if (apiType === 'claude') { + targetUrl = buildApiUrl(baseUrl, 'v1/messages'); + headers['x-api-key'] = apiKey; + headers['anthropic-version'] = '2023-06-01'; + requestBody = { + model, + max_tokens: 700, + temperature: 0.3, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }; + } else if (apiType === 'gemini') { + const rawModel = model.trim(); + const modelName = rawModel.startsWith('models/') ? rawModel.slice('models/'.length) : rawModel; + const path = `v1beta/models/${encodeURIComponent(modelName)}:generateContent`; + targetUrl = buildApiUrl(baseUrl, path); + const urlObj = new URL(targetUrl); + urlObj.searchParams.set('key', apiKey); + targetUrl = urlObj.toString(); + requestBody = { + contents: [{ role: 'user', parts: [{ text: userPrompt }] }], + generationConfig: { temperature: 0.3, maxOutputTokens: 700 }, + systemInstruction: { parts: [{ text: systemPrompt }] }, + }; + } else { + // openai / openai-responses / openai-compatible + targetUrl = apiType === 'openai-compatible' + ? baseUrl.replace(/\/$/, '') + : buildApiUrl(baseUrl, apiType === 'openai-responses' ? 'v1/responses' : 'v1/chat/completions'); + headers['Authorization'] = `Bearer ${apiKey}`; + requestBody = { + model, + temperature: 0.3, + max_tokens: 700, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }; + if (reasoningEffort && (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible')) { + (requestBody as Record).reasoning = { effort: reasoningEffort }; + } + } + + const timeout = apiType === 'openai-responses' || !!reasoningEffort ? 600000 : 120000; + + const result = await proxyRequest({ + url: targetUrl, + method: 'POST', + headers, + body: requestBody, + timeout, + }); + + if (result.status !== 200) { + const errData = result.data as Record | undefined; + throw new Error(`AI API returned ${result.status}: ${JSON.stringify(errData)}`); + } + + return extractTextContent(apiType, result.data as Record); +} + +function extractTextContent(apiType: string, data: Record): string { + if (apiType === 'claude') { + const content = (data as { content?: Array<{ type: string; text: string }> }).content; + if (content && content.length > 0) return content[0].text || ''; + } else if (apiType === 'gemini') { + const candidates = (data as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }).candidates; + if (candidates?.[0]?.content?.parts?.[0]?.text) return candidates[0].content.parts[0].text; + } else if (apiType === 'openai-responses') { + const outputText = (data as { output_text?: string }).output_text; + if (outputText) return outputText; + const output = (data as { output?: Array<{ content?: Array<{ text?: string }> }> }).output; + if (Array.isArray(output)) { + return output + .flatMap((item) => (Array.isArray(item?.content) ? item.content : [])) + .map((part) => part?.text || '') + .join(''); + } + } else { + // openai / openai-compatible + const choices = (data as { choices?: Array<{ message?: { content?: string } }> }).choices; + if (choices?.[0]?.message?.content) return choices[0].message.content; + } + return ''; +} + +// ── Prompt building (ported from aiService.ts) ── + +function repoInfoString(repo: RepoInfo, readmeContent: string, language: string): string { + const isZh = language === 'zh'; + return [ + `${isZh ? '仓库名称' : 'Repository Name'}: ${repo.full_name}`, + `${isZh ? '描述' : 'Description'}: ${repo.description || (isZh ? '无描述' : 'No description')}`, + `${isZh ? '编程语言' : 'Programming Language'}: ${repo.language || (isZh ? '未知' : 'Unknown')}`, + `${isZh ? 'Star数' : 'Stars'}: ${repo.stargazers_count}`, + `${isZh ? '主题标签' : 'Topics'}: ${(repo.topics ? JSON.parse(repo.topics).join(', ') : '') || (isZh ? '无' : 'None')}`, + '', + `${isZh ? 'README内容 (前2000字符)' : 'README Content (first 2000 characters)'}:`, + readmeContent.substring(0, 2000), + ].join('\n'); +} + +function categoriesInfoString(categoryNames: string[], language: string): string { + if (categoryNames.length === 0) return ''; + const label = language === 'zh' ? '可用的应用分类' : 'Available Application Categories'; + return `\n\n${label}: ${categoryNames.join(', ')}`; +} + +function buildCustomPrompt( + customPrompt: string, + repo: RepoInfo, + readmeContent: string, + categoryNames: string[], + language: string, +): string { + const repoInfo = repoInfoString(repo, readmeContent, language); + const catsInfo = categoriesInfoString(categoryNames, language); + return customPrompt + .replace(/\{REPO_INFO\}/g, repoInfo) + .replace(/\{CATEGORIES_INFO\}/g, catsInfo) + .replace(/\{LANGUAGE\}/g, language); +} + +function buildAnalysisPrompt( + repo: RepoInfo, + readmeContent: string, + categoryNames: string[], + language: string, +): string { + const repoInfo = repoInfoString(repo, readmeContent, language); + const catsInfo = categoriesInfoString(categoryNames, language); + const catsHint = categoryNames.length > 0 + ? (language === 'zh' ? ',请优先从提供的分类中选择' : ', please prioritize from the provided categories') + : ''; + + if (language === 'zh') { + return ` +请分析这个GitHub仓库并提供: + +1. 一个简洁的中文概述(不超过50字),说明这个仓库的主要功能和用途 +2. 3-5个相关的应用类型标签(用中文,类似应用商店的分类,如:开发工具、Web应用、移动应用、数据库、AI工具等${catsHint}) +3. 支持的平台类型(从以下选择:mac、windows、linux、ios、android、docker、web、cli) + +重要:请严格使用中文进行分析和回复,无论原始README是什么语言。 + +请以JSON格式回复: +{ + "summary": "你的中文概述", + "tags": ["标签1", "标签2", "标签3", "标签4", "标签5"], + "platforms": ["platform1", "platform2", "platform3"] +} + +仓库信息: +${repoInfo}${catsInfo} + +重点关注实用性和准确的分类,帮助用户快速理解仓库的用途和支持的平台。 + `.trim(); + } + + return ` +Please analyze this GitHub repository and provide: + +1. A concise English overview (no more than 50 words) explaining the main functionality and purpose of this repository +2. 3-5 relevant application type tags (in English, similar to app store categories, such as: development tools, web apps, mobile apps, database, AI tools, etc.${catsHint}) +3. Supported platform types (choose from: mac, windows, linux, ios, android, docker, web, cli) + +Important: Please strictly use English for analysis and response, regardless of the original README language. + +Please reply in JSON format: +{ + "summary": "Your English overview", + "tags": ["tag1", "tag2", "tag3", "tag4", "tag5"], + "platforms": ["platform1", "platform2", "platform3"] +} + +Repository information: +${repoInfo}${catsInfo} + +Focus on practicality and accurate categorization to help users quickly understand the repository's purpose and supported platforms. + `.trim(); +} + +// ── Response parsing (ported from aiService.ts) ── + +function parseAIResponse(content: string, language: string): { summary: string; tags: string[]; platforms: string[] } { + try { + const cleaned = content + .trim() + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/i, '') + .trim(); + + const parsed = extractAndParseAIJson(cleaned); + if (parsed) { + return { + summary: typeof parsed.summary === 'string' && parsed.summary.trim() + ? parsed.summary.trim() + : (language === 'zh' ? '无法生成概述' : 'Unable to generate summary'), + tags: Array.isArray(parsed.tags) ? parsed.tags.filter((v) => typeof v === 'string').slice(0, 5) : [], + platforms: Array.isArray(parsed.platforms) ? parsed.platforms.filter((v) => typeof v === 'string').slice(0, 8) : [], + }; + } + + return { + summary: cleaned.substring(0, 50) + (cleaned.length > 50 ? '...' : ''), + tags: [], + platforms: [], + }; + } catch { + return { + summary: language === 'zh' ? '分析失败' : 'Analysis failed', + tags: [], + platforms: [], + }; + } +} + +function extractAndParseAIJson(content: string): Record | null { + const direct = tryParseJsonObject(content); + if (direct) return direct; + + const start = content.indexOf('{'); + if (start === -1) return null; + + let inString = false; + let escaped = false; + let depth = 0; + + for (let i = start; i < content.length; i++) { + const char = content[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (char === '{') depth++; + if (char === '}') { + depth--; + if (depth === 0) { + return tryParseJsonObject(content.slice(start, i + 1)); + } + } + } + + return null; +} + +function tryParseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null; + + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index dc3d1b6..5df89d5 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -19,9 +19,12 @@ import { Calendar } from 'lucide-react'; import { useAppStore } from '../store/useAppStore'; +import type { Repository } from '../types'; import { GitHubApiService } from '../services/githubApi'; import { AIService } from '../services/aiService'; import { AIAnalysisOptimizer } from '../services/aiAnalysisOptimizer'; +import { backend } from '../services/backendAdapter'; +import { backendAnalysis } from '../services/backendAnalysisService'; import { resolveCategoryAssignment } from '../utils/categoryUtils'; import { discoveryAnalysisStorage } from '../services/discoveryAnalysisStorage'; import { DiscoverySidebar } from './DiscoverySidebar'; @@ -494,6 +497,7 @@ export const DiscoveryView: React.FC = React.memo(() => { const [analysisOptimizer, setAnalysisOptimizer] = useState(null); const [, setAnalysisState] = useState<{ paused: boolean; aborted: boolean }>({ paused: false, aborted: false }); const [searchInput, setSearchInput] = useState(discoverySearchQuery); + const shouldStopRef = useRef(false); const scrollContainerRef = useRef(null); const sidebarRef = useRef(null); @@ -770,6 +774,100 @@ export const DiscoveryView: React.FC = React.memo(() => { setIsAnalyzing(true); setAnalysisState({ paused: false, aborted: false }); + + // Backend-first: use server-side analysis if available + if (backend.isAvailable) { + shouldStopRef.current = false; + const storeState = useAppStore.getState(); + const allCategoryNames = storeState.customCategories.map(c => c.name); + const categoryNames = [ + ...allCategoryNames, + ...(language === 'zh' + ? ['全部分类', 'Web应用', '移动应用', '桌面应用', '数据库', 'AI/机器学习', '开发工具', '安全工具', '游戏', '设计工具', '效率工具', '教育学习', '社交网络', '数据分析'] + : ['All', 'Web Apps', 'Mobile Apps', 'Desktop Apps', 'Database', 'AI/ML', 'Dev Tools', 'Security Tools', 'Games', 'Design Tools', 'Productivity', 'Education', 'Social Networks', 'Data Analysis']), + ]; + + setAnalysisProgress({ current: 0, total: unanalyzed.length }); + + try { + await backend.syncRepositories(unanalyzed as Repository[]); + } catch { + // Non-fatal: backend may already have these repos + } + + try { + await backendAnalysis.startBatchAnalysis({ + repositoryIds: unanalyzed.map(r => r.id), + configId: activeConfig.id, + language, + categoryNames, + onProgress: (current, total) => { + setAnalysisProgress({ current, total }); + }, + onComplete: async (completed, failed) => { + // Refresh discovery repos from backend to get AI results + try { + const { repositories: backendRepos } = await backend.fetchRepositories(); + const repoMap = new Map(backendRepos.map(r => [r.id, r])); + for (const dRepo of unanalyzed) { + const updated = repoMap.get(dRepo.id); + if (updated) { + const storeState = useAppStore.getState(); + const allCategoriesForResolution = [...storeState.customCategories]; + const resolvedCategory = resolveCategoryAssignment( + updated, + updated.ai_tags || [], + allCategoriesForResolution + ); + const wasCategoryLocked = !!dRepo.category_locked; + updateDiscoveryRepo({ + ...dRepo, + ai_summary: updated.ai_summary, + ai_tags: updated.ai_tags, + ai_platforms: updated.ai_platforms, + custom_category: resolvedCategory, + category_locked: wasCategoryLocked, + analyzed_at: updated.analyzed_at, + analysis_failed: updated.analysis_failed || false, + }); + discoveryAnalysisStorage.saveAnalysis(dRepo.id, { + ai_summary: updated.ai_summary, + ai_tags: updated.ai_tags, + ai_platforms: updated.ai_platforms, + analyzed_at: updated.analyzed_at, + analysis_failed: updated.analysis_failed || false, + }); + } + } + } catch { + // Non-fatal + } + toast( + shouldStopRef.current + ? t( + `AI分析已停止!成功 ${completed} 个${failed > 0 ? `,失败 ${failed} 个` : ''}`, + `AI analysis stopped! ${completed} succeeded${failed > 0 ? `, ${failed} failed` : ''}` + ) + : t( + `AI分析完成!成功 ${completed} 个${failed > 0 ? `,失败 ${failed} 个` : ''}`, + `AI analysis complete! ${completed} succeeded${failed > 0 ? `, ${failed} failed` : ''}` + ), + completed === 0 ? 'error' : failed > 0 ? 'info' : 'success' + ); + }, + }); + } catch (err) { + console.error('Backend AI analysis error:', err); + toast(t('AI分析启动失败,请检查后端连接和AI配置。', 'AI analysis failed to start. Please check backend connection and AI configuration.'), 'error'); + } finally { + setIsAnalyzing(false); + setAnalysisOptimizer(null); + setAnalysisProgress({ current: 0, total: 0 }); + } + return; + } + + // Frontend fallback const storeState = useAppStore.getState(); const allCategoriesForResolution = [ ...storeState.customCategories, @@ -875,6 +973,11 @@ export const DiscoveryView: React.FC = React.memo(() => { const handleAbortAnalysis = useCallback(() => { + if (backendAnalysis.isRunning) { + shouldStopRef.current = true; + backendAnalysis.cancelBatchAnalysis(); + return; + } analysisOptimizer?.abort(); setAnalysisState(prev => ({ ...prev, aborted: true })); }, [analysisOptimizer]); diff --git a/src/components/RepositoryCard.tsx b/src/components/RepositoryCard.tsx index f02243c..78495c3 100644 --- a/src/components/RepositoryCard.tsx +++ b/src/components/RepositoryCard.tsx @@ -6,6 +6,8 @@ import { useAppStore } from '../store/useAppStore'; import { getAICategory, getDefaultCategory } from '../utils/categoryUtils'; import { analyzeRepository, createFailedAnalysisResult } from '../services/aiAnalysisHelper'; import { forceSyncToBackend } from '../services/autoSync'; +import { backend } from '../services/backendAdapter'; +import { backendAnalysis } from '../services/backendAnalysisService'; import { GitHubApiService } from '../services/githubApi'; import { formatDistanceToNow } from 'date-fns'; import { RepositoryEditModal } from './RepositoryEditModal'; @@ -302,6 +304,34 @@ const RepositoryCardComponent: React.FC = ({ } } + if (isAnalyzing) return; + + // Backend-first: use server-side analysis if available + if (backend.isAvailable) { + const categoryNames = allCategories.filter(cat => cat.id !== 'all').map(cat => cat.name); + + try { + await backendAnalysis.startBatchAnalysis({ + repositoryIds: [repoId], + configId: activeConfig.id, + language, + categoryNames, + onComplete: (completed, failed) => { + const reanalysis = !!repository.analyzed_at; + const message = reanalysis + ? t('AI重新分析完成!', 'AI re-analysis completed!') + : t('AI分析完成!', 'AI analysis completed!'); + toast(message, 'success'); + }, + }); + } catch (error) { + console.error('Backend AI analysis failed:', error); + toast(t('AI分析启动失败,请检查后端连接和AI配置。', 'AI analysis failed to start. Please check backend connection and AI configuration.'), 'error'); + } + return; + } + + // Frontend fallback abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx index d474ffc..af474fd 100644 --- a/src/components/RepositoryList.tsx +++ b/src/components/RepositoryList.tsx @@ -10,6 +10,8 @@ import { useAppStore, getAllCategories } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; import { AIService } from '../services/aiService'; import { AIAnalysisOptimizer, AnalysisResult } from '../services/aiAnalysisOptimizer'; +import { backend } from '../services/backendAdapter'; +import { backendAnalysis } from '../services/backendAnalysisService'; import { resolveCategoryAssignment, getAICategory, getDefaultCategory, computeCustomCategory } from '../utils/categoryUtils'; import { forceSyncToBackend } from '../services/autoSync'; import { useDialog } from '../hooks/useDialog'; @@ -323,6 +325,48 @@ export const RepositoryList: React.FC = ({ ); if (!confirmed) return; + // Backend-first: use server-side analysis if available + if (backend.isAvailable) { + shouldStopRef.current = false; + isAnalyzingRef.current = true; + setLoading(true); + setAnalysisProgress({ current: 0, total: targetRepos.length }); + setShowDropdown(false); + setIsPaused(false); + + const categoryNames = allCategories.filter(cat => cat.id !== 'all').map(cat => cat.name); + const repoIds = targetRepos.map(r => r.id); + + try { + await backendAnalysis.startBatchAnalysis({ + repositoryIds: repoIds, + configId: activeConfig.id, + language, + categoryNames, + onProgress: (current, total) => { + setAnalysisProgress({ current, total }); + }, + onComplete: (completed, failed) => { + const message = shouldStopRef.current + ? t(`AI分析已停止!成功: ${completed}, 失败: ${failed}`, `AI analysis stopped! Success: ${completed}, Failed: ${failed}`) + : t(`AI分析完成!成功: ${completed}, 失败: ${failed}`, `AI analysis completed! Success: ${completed}, Failed: ${failed}`); + toast(message, 'success'); + }, + }); + } catch (error) { + console.error('Backend AI analysis failed:', error); + toast(t('AI分析启动失败,请检查后端连接和AI配置。', 'AI analysis failed to start. Please check backend connection and AI configuration.'), 'error'); + } finally { + isAnalyzingRef.current = false; + shouldStopRef.current = false; + setLoading(false); + setAnalysisProgress({ current: 0, total: 0 }); + setIsPaused(false); + } + return; + } + + // Frontend fallback // 重置状态 shouldStopRef.current = false; isAnalyzingRef.current = true; @@ -427,6 +471,15 @@ export const RepositoryList: React.FC = ({ const handlePauseResume = () => { if (!isAnalyzingRef.current) return; + + // Backend analysis: pause = cancel + if (backendAnalysis.isRunning) { + shouldStopRef.current = true; + backendAnalysis.cancelBatchAnalysis(); + setIsPaused(false); + return; + } + const newPausedState = !isPaused; setIsPaused(newPausedState); @@ -455,6 +508,13 @@ export const RepositoryList: React.FC = ({ { type: 'warning' } ); if (confirmed) { + if (backendAnalysis.isRunning) { + shouldStopRef.current = true; + backendAnalysis.cancelBatchAnalysis(); + setIsPaused(false); + return; + } + shouldStopRef.current = true; // 中止优化器 if (optimizerRef.current) { @@ -672,12 +732,51 @@ export const RepositoryList: React.FC = ({ return; } - // 设置加载状态 + // Backend-first: use server-side analysis if available + if (backend.isAvailable) { + shouldStopRef.current = false; + isAnalyzingRef.current = true; + setLoading(true); + setAnalysisProgress({ current: 0, total: repos.length }); + + const categoryNames = allCategories.filter(cat => cat.id !== 'all').map(cat => cat.name); + const repoIds = repos.map(r => r.id); + + try { + await backendAnalysis.startBatchAnalysis({ + repositoryIds: repoIds, + configId: activeConfig.id, + language, + categoryNames, + onProgress: (current, total) => { + setAnalysisProgress({ current, total }); + }, + onComplete: (completed, failed) => { + toast( + shouldStopRef.current + ? t(`AI分析已停止!成功: ${completed}, 失败: ${failed}`, `AI analysis stopped! Success: ${completed}, Failed: ${failed}`) + : t(`AI分析完成!成功: ${completed}, 失败: ${failed}`, `AI analysis completed! Success: ${completed}, Failed: ${failed}`), + 'success' + ); + }, + }); + } catch (error) { + console.error('Backend AI analysis failed:', error); + toast(t('AI分析启动失败,请检查后端连接和AI配置。', 'AI analysis failed to start. Please check backend connection and AI configuration.'), 'error'); + } finally { + isAnalyzingRef.current = false; + shouldStopRef.current = false; + setLoading(false); + setAnalysisProgress({ current: 0, total: 0 }); + } + break; + } + + // Frontend fallback setLoading(true); isAnalyzingRef.current = true; setAnalysisProgress({ current: 0, total: repos.length }); - // 创建优化器实例并保存到 ref optimizerRef.current = new AIAnalysisOptimizer({ initialConcurrency: activeConfig.concurrency || 3, maxConcurrency: 10, @@ -756,7 +855,6 @@ export const RepositoryList: React.FC = ({ console.error('Bulk AI analysis failed:', error); toast(language === 'zh' ? '批量AI分析失败' : 'Bulk AI analysis failed', 'error'); } finally { - // 确保状态重置 optimizerRef.current = null; isAnalyzingRef.current = false; shouldStopRef.current = false; diff --git a/src/components/SubscriptionRepoCard.tsx b/src/components/SubscriptionRepoCard.tsx index aee5f74..0c51f0b 100644 --- a/src/components/SubscriptionRepoCard.tsx +++ b/src/components/SubscriptionRepoCard.tsx @@ -1,9 +1,11 @@ import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { Star, StarOff, ExternalLink, Bot, GitFork, Monitor, Smartphone, Globe, Terminal, Package, Sparkles, BookOpen, AlertTriangle } from 'lucide-react'; -import type { DiscoveryRepo } from '../types'; +import type { DiscoveryRepo, Repository } from '../types'; import { useAppStore, getAllCategories } from '../store/useAppStore'; import { analyzeRepository, createFailedAnalysisResult } from '../services/aiAnalysisHelper'; import { forceSyncToBackend } from '../services/autoSync'; +import { backend } from '../services/backendAdapter'; +import { backendAnalysis } from '../services/backendAnalysisService'; import { GitHubApiService } from '../services/githubApi'; import { ReadmeModal } from './ReadmeModal'; import { Modal } from './Modal'; @@ -224,6 +226,64 @@ export const SubscriptionRepoCard: React.FC = ({ repo if (isAnalyzing) return; + // Backend-first: use server-side analysis if available + if (backend.isAvailable) { + setIsAnalyzing(true); + + const allCategories = getAllCategories(customCategories, language); + const categoryNames = allCategories.map(c => c.name); + + try { + await backend.syncRepositories([repo as Repository]); + } catch { + // Non-fatal + } + + try { + await backendAnalysis.startBatchAnalysis({ + repositoryIds: [repo.id], + configId: activeConfig.id, + language, + categoryNames, + onComplete: async () => { + try { + const { repositories: backendRepos } = await backend.fetchRepositories(); + const updated = backendRepos.find(r => r.id === repo.id); + if (updated) { + const updatedRepo: DiscoveryRepo = { + ...repo, + ai_summary: updated.ai_summary, + ai_tags: updated.ai_tags, + ai_platforms: updated.ai_platforms, + analyzed_at: updated.analyzed_at, + analysis_failed: updated.analysis_failed || false, + }; + updateDiscoveryRepo(updatedRepo); + if (onAnalyze) { + onAnalyze(updatedRepo); + } + } + } catch { + // Non-fatal + } + }, + }); + } catch (err) { + console.error('Backend AI analysis error:', err); + const failedRepo: DiscoveryRepo = { + ...repo, + analyzed_at: new Date().toISOString(), + analysis_failed: true, + }; + updateDiscoveryRepo(failedRepo); + toast(t('AI分析启动失败,请检查后端连接和AI配置。', 'AI analysis failed to start. Please check backend connection and AI configuration.'), 'error'); + } finally { + setIsAnalyzing(false); + } + return; + } + + // Frontend fallback abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; @@ -253,7 +313,7 @@ export const SubscriptionRepoCard: React.FC = ({ repo analysis_failed: result.analysis_failed, }; updateDiscoveryRepo(updatedRepo); - + if (onAnalyze) { onAnalyze(updatedRepo); } diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index dba2406..608a332 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -356,6 +356,35 @@ class BackendAdapter { } } + // === AI Analysis === + + async startAnalysis(repositoryIds: number[], configId: string, language: string, categoryNames: string[]): Promise<{ batchId: string; status: string; total: number }> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await this.fetchWithTimeout(`${this._backendUrl}/analysis/batch`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ repositoryIds, configId, language, categoryNames }), + }); + if (!res.ok) await this.throwTranslatedError(res, 'Start analysis error'); + return res.json() as Promise<{ batchId: string; status: string; total: number }>; + } + + async getAnalysisProgress(batchId: string): Promise<{ batchId: string; status: string; total: number; completed: number; failed: number }> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await this.fetchWithTimeout(`${this._backendUrl}/analysis/batch/${batchId}`); + if (!res.ok) await this.throwTranslatedError(res, 'Analysis progress error'); + return res.json() as Promise<{ batchId: string; status: string; total: number; completed: number; failed: number }>; + } + + async cancelAnalysis(batchId: string): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await this.fetchWithTimeout(`${this._backendUrl}/analysis/batch/${batchId}/cancel`, { method: 'POST', headers: this.getAuthHeaders() }); + if (!res.ok) await this.throwTranslatedError(res, 'Cancel analysis error'); + } + // === GitHub Search Proxy === async searchRepositories(queryParams: Record): Promise<{ items: Repository[] }> { diff --git a/src/services/backendAnalysisService.ts b/src/services/backendAnalysisService.ts new file mode 100644 index 0000000..497c990 --- /dev/null +++ b/src/services/backendAnalysisService.ts @@ -0,0 +1,148 @@ +import { backend } from './backendAdapter'; +import { useAppStore } from '../store/useAppStore'; + +interface BatchAnalysisOptions { + repositoryIds: number[]; + configId: string; + language: string; + categoryNames: string[]; + onProgress?: (current: number, total: number) => void; + onComplete?: (completed: number, failed: number) => void; + onRepoResult?: (repoId: number) => void; +} + +class BackendAnalysisService { + private pollingTimeout: ReturnType | null = null; + private currentBatchId: string | null = null; + private _isRunning = false; + + async startBatchAnalysis(options: BatchAnalysisOptions): Promise { + if (this._isRunning) { + throw new Error('Analysis already in progress'); + } + + const { repositoryIds, configId, language, categoryNames, onProgress, onComplete, onRepoResult } = options; + + const store = useAppStore.getState(); + + this._isRunning = true; + + // Mark all repos as analyzing + for (const id of repositoryIds) { + store.setAnalyzingRepository(id, true); + } + store.setAnalysisProgress({ current: 0, total: repositoryIds.length }); + + try { + const { batchId } = await backend.startAnalysis(repositoryIds, configId, language, categoryNames); + this.currentBatchId = batchId; + + return new Promise((resolve) => { + let lastReportedCount = 0; + + const poll = async (): Promise => { + // Guard: if a newer batch has replaced this one, silently exit + if (this.currentBatchId !== batchId) return; + + try { + const progress = await backend.getAnalysisProgress(batchId); + + // Re-check after await — another batch may have been started + if (this.currentBatchId !== batchId) return; + + store.setAnalysisProgress({ current: progress.completed + progress.failed, total: progress.total }); + onProgress?.(progress.completed + progress.failed, progress.total); + + if (progress.completed + progress.failed > lastReportedCount) { + lastReportedCount = progress.completed + progress.failed; + onRepoResult?.(lastReportedCount); + } + + if (progress.status === 'completed' || progress.status === 'cancelled' || progress.status === 'failed') { + this.finishBatch(batchId, repositoryIds); + + if (progress.status === 'completed') { + try { + const repoData = await backend.fetchRepositories(); + const repoMap = new Map(repoData.repositories.map((r) => [r.id, r])); + + const currentRepos = useAppStore.getState().repositories; + const updatedRepos = currentRepos.map((r) => { + const updated = repoMap.get(r.id); + if (updated) { + return { + ...r, + ai_summary: updated.ai_summary, + ai_tags: updated.ai_tags, + ai_platforms: updated.ai_platforms, + analyzed_at: updated.analyzed_at, + analysis_failed: updated.analysis_failed, + }; + } + return r; + }); + useAppStore.getState().setRepositories(updatedRepos); + } catch { + // Refresh failed — UI can resync manually + } + } + + onComplete?.(progress.completed, progress.failed); + resolve(); + return; + } + } catch { + // Poll errors are non-fatal — keep polling + } + + // Schedule next poll only if still the current batch + if (this.currentBatchId === batchId) { + this.pollingTimeout = setTimeout(poll, 2000); + } + }; + + poll(); + }); + } catch (err) { + // Start failed — clear all states + this.finishBatch(null, repositoryIds); + store.setAnalysisProgress({ current: 0, total: 0 }); + throw err; + } + } + + async cancelBatchAnalysis(): Promise { + if (this.currentBatchId) { + try { + await backend.cancelAnalysis(this.currentBatchId); + } catch { + // Non-fatal + } + // Don't call finishBatch — the poll will detect 'cancelled' and clean up + } + } + + get isRunning(): boolean { + return this._isRunning; + } + + private finishBatch(batchId: string | null, repositoryIds: number[]): void { + // Only clean up instance state if this batch is still current + if (batchId === null || this.currentBatchId === batchId) { + this._isRunning = false; + if (this.pollingTimeout !== null) { + clearTimeout(this.pollingTimeout); + this.pollingTimeout = null; + } + this.currentBatchId = null; + } + + // Always clear analyzing states for this batch's repos + const store = useAppStore.getState(); + for (const id of repositoryIds) { + store.setAnalyzingRepository(id, false); + } + } +} + +export const backendAnalysis = new BackendAnalysisService(); From f4eb3344775002515f74532be5805e0273d747fa Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 13:50:38 +0800 Subject: [PATCH 12/47] =?UTF-8?q?@=20fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbi?= =?UTF-8?q?t=20=E6=8C=87=E5=87=BA=E7=9A=84=204=20=E4=B8=AA=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getAnalysisProgress 缺少认证头导致轮询失败 (Critical) - batches Map 无 TTL 清理导致内存泄漏 (Major) - JSON.parse(repo.topics) 无异常处理 (Minor) - 后端分析时暂停按钮行为误导,改为禁用 (Minor) --- server/src/services/analysisService.ts | 16 +++++++++++++++- src/components/RepositoryList.tsx | 14 +++++--------- src/services/backendAdapter.ts | 4 +++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/server/src/services/analysisService.ts b/server/src/services/analysisService.ts index 840ad66..710769b 100644 --- a/server/src/services/analysisService.ts +++ b/server/src/services/analysisService.ts @@ -34,6 +34,20 @@ interface RepoInfo { // ── In-memory state ── const batches = new Map(); +const BATCH_TTL_MS = 60 * 60 * 1000; // 1 hour + +function cleanupBatches(): void { + const now = Date.now(); + for (const [id, batch] of batches) { + if (batch.status !== 'running' && batch.completedAt) { + if (now - new Date(batch.completedAt).getTime() > BATCH_TTL_MS) { + batches.delete(id); + } + } + } +} + +setInterval(cleanupBatches, 10 * 60 * 1000); // every 10 minutes function generateId(): string { return `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -369,7 +383,7 @@ function repoInfoString(repo: RepoInfo, readmeContent: string, language: string) `${isZh ? '描述' : 'Description'}: ${repo.description || (isZh ? '无描述' : 'No description')}`, `${isZh ? '编程语言' : 'Programming Language'}: ${repo.language || (isZh ? '未知' : 'Unknown')}`, `${isZh ? 'Star数' : 'Stars'}: ${repo.stargazers_count}`, - `${isZh ? '主题标签' : 'Topics'}: ${(repo.topics ? JSON.parse(repo.topics).join(', ') : '') || (isZh ? '无' : 'None')}`, + `${isZh ? '主题标签' : 'Topics'}: ${(() => { try { return repo.topics ? JSON.parse(repo.topics).join(', ') : ''; } catch { return ''; } })() || (isZh ? '无' : 'None')}`, '', `${isZh ? 'README内容 (前2000字符)' : 'README Content (first 2000 characters)'}:`, readmeContent.substring(0, 2000), diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx index af474fd..dbe80be 100644 --- a/src/components/RepositoryList.tsx +++ b/src/components/RepositoryList.tsx @@ -472,13 +472,8 @@ export const RepositoryList: React.FC = ({ const handlePauseResume = () => { if (!isAnalyzingRef.current) return; - // Backend analysis: pause = cancel - if (backendAnalysis.isRunning) { - shouldStopRef.current = true; - backendAnalysis.cancelBatchAnalysis(); - setIsPaused(false); - return; - } + // Backend analysis doesn't support pause — use Stop instead + if (backendAnalysis.isRunning) return; const newPausedState = !isPaused; setIsPaused(newPausedState); @@ -1196,8 +1191,9 @@ export const RepositoryList: React.FC = ({ diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 608a332..a03674c 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -373,7 +373,9 @@ class BackendAdapter { async getAnalysisProgress(batchId: string): Promise<{ batchId: string; status: string; total: number; completed: number; failed: number }> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await this.fetchWithTimeout(`${this._backendUrl}/analysis/batch/${batchId}`); + const res = await this.fetchWithTimeout(`${this._backendUrl}/analysis/batch/${batchId}`, { + headers: this.getAuthHeaders(), + }); if (!res.ok) await this.throwTranslatedError(res, 'Analysis progress error'); return res.json() as Promise<{ batchId: string; status: string; total: number; completed: number; failed: number }>; } From de6617a5bc3bd011edc0e29f613b44cb87efa81b Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 13:57:51 +0800 Subject: [PATCH 13/47] =?UTF-8?q?@=20fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbi?= =?UTF-8?q?t=20=E7=AC=AC=E4=BA=8C=E8=BD=AE=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI API 错误消息不再暴露完整响应体,仅提取安全字段 (Minor) - 暂停按钮添加 aria-label 无障碍属性 (Minor) --- server/src/services/analysisService.ts | 4 +++- src/components/RepositoryList.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/services/analysisService.ts b/server/src/services/analysisService.ts index 710769b..7259880 100644 --- a/server/src/services/analysisService.ts +++ b/server/src/services/analysisService.ts @@ -343,7 +343,9 @@ async function callAI( if (result.status !== 200) { const errData = result.data as Record | undefined; - throw new Error(`AI API returned ${result.status}: ${JSON.stringify(errData)}`); + console.error(`[analysis] AI API error details:`, errData); + const errorMsg = errData?.error?.message || errData?.message || 'Unknown error'; + throw new Error(`AI API returned ${result.status}: ${typeof errorMsg === 'string' ? errorMsg : 'Request failed'}`); } return extractTextContent(apiType, result.data as Record); diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx index dbe80be..a56a0a3 100644 --- a/src/components/RepositoryList.tsx +++ b/src/components/RepositoryList.tsx @@ -1194,6 +1194,7 @@ export const RepositoryList: React.FC = ({ disabled={backendAnalysis.isRunning} className="p-1 sm:p-1.5 rounded-lg bg-gray-100 dark:bg-white/[0.04] text-gray-700 dark:text-text-secondary dark:bg-status-amber/20 dark:text-status-amber hover:bg-gray-100 dark:bg-white/[0.04] dark:hover:bg-status-amber/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" title={backendAnalysis.isRunning ? t('后端分析不支持暂停', 'Backend analysis does not support pause') : (isPaused ? t('继续', 'Resume') : t('暂停', 'Pause'))} + aria-label={backendAnalysis.isRunning ? t('后端分析不支持暂停', 'Backend analysis does not support pause') : (isPaused ? t('继续', 'Resume') : t('暂停', 'Pause'))} > {isPaused ? : } From 1fc67224e1d05ecd12f055acbca85327cd5b4680 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 14:17:29 +0800 Subject: [PATCH 14/47] =?UTF-8?q?@=20@=20fix:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7=E7=AE=A1=E7=90=86=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - health.ts 改为运行时读取 server/package.json 版本号,不再硬编码 - update-version.cjs 同步更新前后端两个 package.json,移除无效的 updateServiceVersion - docker-build.yml 前后端镜像使用各自独立的版本号 tag @ --- .github/workflows/docker-build.yml | 13 ++++++---- scripts/update-version.cjs | 39 +++++++++++------------------- server/src/routes/health.ts | 8 +++++- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index fff1685..589b661 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -13,15 +13,18 @@ jobs: generate-tags: runs-on: ubuntu-24.04 outputs: - version: ${{ steps.tags.outputs.version }} + frontend_version: ${{ steps.tags.outputs.frontend_version }} + backend_version: ${{ steps.tags.outputs.backend_version }} steps: - name: Checkout uses: actions/checkout@v4 - name: Generate tags id: tags run: | - VER=$(node -p "require('./package.json').version") - echo "version=$VER" >> $GITHUB_OUTPUT + FRONTEND_VER=$(node -p "require('./package.json').version") + BACKEND_VER=$(node -p "require('./server/package.json').version") + echo "frontend_version=$FRONTEND_VER" >> $GITHUB_OUTPUT + echo "backend_version=$BACKEND_VER" >> $GITHUB_OUTPUT build-frontend: needs: generate-tags @@ -48,7 +51,7 @@ jobs: push: true provenance: false tags: | - ${{ env.FRONTEND_IMAGE }}:${{ needs.generate-tags.outputs.version }} + ${{ env.FRONTEND_IMAGE }}:${{ needs.generate-tags.outputs.frontend_version }} ${{ env.FRONTEND_IMAGE }}:latest cache-from: type=gha cache-to: type=gha,mode=max @@ -78,7 +81,7 @@ jobs: push: true provenance: false tags: | - ${{ env.BACKEND_IMAGE }}:${{ needs.generate-tags.outputs.version }}-backend + ${{ env.BACKEND_IMAGE }}:${{ needs.generate-tags.outputs.backend_version }}-backend ${{ env.BACKEND_IMAGE }}:backend cache-from: type=gha cache-to: type=gha,mode=max diff --git a/scripts/update-version.cjs b/scripts/update-version.cjs index 5bf2938..e059995 100644 --- a/scripts/update-version.cjs +++ b/scripts/update-version.cjs @@ -68,15 +68,12 @@ function updateVersionInfo() { } try { - // 更新 package.json + // 更新 package.json(前端) updatePackageJson(newVersion); // 更新 version-info.xml updateVersionXML(newVersion, changelog, customDownloadUrl); - // 更新 UpdateService 中的版本号 - updateServiceVersion(newVersion); - console.log(`✅ 版本已更新到 ${newVersion}`); console.log('📝 更新内容:'); changelog.forEach((item, index) => { @@ -94,13 +91,19 @@ function updateVersionInfo() { } function updatePackageJson(version) { - const packagePath = path.join(__dirname, '../package.json'); - const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - - packageJson.version = version; - - fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2) + '\n'); - console.log(`📦 已更新 package.json 版本到 ${version}`); + // 前端 package.json + const frontPath = path.join(__dirname, '../package.json'); + const frontPkg = JSON.parse(fs.readFileSync(frontPath, 'utf8')); + frontPkg.version = version; + fs.writeFileSync(frontPath, JSON.stringify(frontPkg, null, 2) + '\n'); + + // 后端 package.json(Docker 镜像 tag 和 /api/health 的版本来源) + const serverPath = path.join(__dirname, '../server/package.json'); + const serverPkg = JSON.parse(fs.readFileSync(serverPath, 'utf8')); + serverPkg.version = version; + fs.writeFileSync(serverPath, JSON.stringify(serverPkg, null, 2) + '\n'); + + console.log(`📦 已更新 package.json 和 server/package.json 版本到 ${version}`); } function updateVersionXML(version, changelog, customDownloadUrl) { @@ -136,20 +139,6 @@ ${changelog.map(item => ` ${escapeXml(item)}`).join('\n')} console.log(`📄 已更新 version-info.xml`); } -function updateServiceVersion(version) { - const servicePath = path.join(__dirname, '../src/services/updateService.ts'); - let serviceContent = fs.readFileSync(servicePath, 'utf8'); - - // 更新版本号 - serviceContent = serviceContent.replace( - /return '\d+\.\d+\.\d+';/, - `return '${version}';` - ); - - fs.writeFileSync(servicePath, serviceContent); - console.log(`🔧 已更新 UpdateService 版本到 ${version}`); -} - function escapeXml(text) { return text .replace(/&/g, '&') diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 8f5f8d5..1a0fb87 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,11 +1,17 @@ import { Router } from 'express'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const { version } = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')); const router = Router(); router.get('/api/health', (_req, res) => { res.json({ status: 'ok', - version: '0.1.0', + version, timestamp: new Date().toISOString(), }); }); From d75a28c328f1e99ac4de809e0118408b353204c9 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 14:18:43 +0800 Subject: [PATCH 15/47] =?UTF-8?q?@=20@=20chore:=20=E6=9B=B4=E6=96=B0=20ver?= =?UTF-8?q?sions/README.md=20=E5=8F=8D=E6=98=A0=E5=BD=93=E5=89=8D=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=AE=A1=E7=90=86=E6=B5=81=E7=A8=8B=20@?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- versions/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/versions/README.md b/versions/README.md index fd8f99c..bf2ada7 100644 --- a/versions/README.md +++ b/versions/README.md @@ -16,9 +16,9 @@ npm run update-version 0.1.3 "修复搜索功能bug" "添加新的过滤选项" ``` 这个命令会: -- 更新 `package.json` 中的版本号 +- 更新 `package.json`(前端)和 `server/package.json`(后端)中的版本号 - 在 `version-info.xml` 中添加新版本记录 -- 更新 `src/services/updateService.ts` 中的当前版本号 +- 后端 `/api/health` 接口会从 `server/package.json` 动态读取版本号 ### 2. 手动更新(不推荐) @@ -45,7 +45,7 @@ npm run update-version 0.1.3 "修复搜索功能bug" "添加新的过滤选项" 1. 使用 `npm run update-version` 更新版本信息 2. 提交更改到 Git 仓库: ```bash - git add . + git add package.json server/package.json versions/version-info.xml git commit -m "chore: bump version to v0.1.3" git push origin main ``` From 523c54b3062d273304364d02c3edc7c151c1a59a Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 14:24:12 +0800 Subject: [PATCH 16/47] =?UTF-8?q?@=20@=20fix:=20=E6=B7=BB=E5=8A=A0=20@type?= =?UTF-8?q?s/node=20=E8=A7=A3=E5=86=B3=20server=20TypeScript=2063=20?= =?UTF-8?q?=E4=B8=AA=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/package.json devDependencies 新增 @types/node - 修复 analysisService.ts 唯一的类型错误 (errData?.error?.message) - tsc --noEmit 现在零错误通过 @ --- package-lock.json | 89 ++++++-------------------- package.json | 1 + server/package-lock.json | 19 +++--- server/package.json | 1 + server/src/services/analysisService.ts | 2 +- 5 files changed, 30 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4620d13..357c212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-stars-manager", - "version": "0.5.6", + "version": "0.5.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-stars-manager", - "version": "0.5.6", + "version": "0.5.7", "dependencies": { "@types/query-string": "^6.2.0", "date-fns": "^3.3.1", @@ -28,6 +28,7 @@ "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", + "@types/node": "^25.6.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-legacy": "^8.0.1", @@ -1205,9 +1206,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1225,9 +1223,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1245,9 +1240,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1265,9 +1257,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1285,9 +1274,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1305,9 +1291,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1462,9 +1445,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1479,9 +1459,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1496,9 +1473,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1513,9 +1487,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1530,9 +1501,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1547,9 +1515,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1564,9 +1529,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1581,9 +1543,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1598,9 +1557,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1615,9 +1571,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1632,9 +1585,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1649,9 +1599,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1666,9 +1613,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1942,6 +1886,15 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "dev": true, + "dependencies": { + "undici-types": "~7.19.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -6671,9 +6624,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6695,9 +6645,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6719,9 +6666,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6743,9 +6687,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10238,6 +10179,12 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", diff --git a/package.json b/package.json index 6a640e2..54565f8 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", + "@types/node": "^25.6.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-legacy": "^8.0.1", diff --git a/server/package-lock.json b/server/package-lock.json index 32a2bd5..e86b8af 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,6 +19,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", + "@types/node": "^22.0.0", "@types/supertest": "^6.0.2", "supertest": "^6.3.4", "tsx": "^4.7.0", @@ -981,13 +982,12 @@ } }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", "dev": true, - "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/qs": { @@ -3422,11 +3422,10 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/server/package.json b/server/package.json index eb19c2e..fce8562 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "morgan": "^1.10.0" }, "devDependencies": { + "@types/node": "^22.0.0", "@types/express": "^4.17.21", "@types/better-sqlite3": "^7.6.8", "@types/cors": "^2.8.17", diff --git a/server/src/services/analysisService.ts b/server/src/services/analysisService.ts index 7259880..8d69cf6 100644 --- a/server/src/services/analysisService.ts +++ b/server/src/services/analysisService.ts @@ -344,7 +344,7 @@ async function callAI( if (result.status !== 200) { const errData = result.data as Record | undefined; console.error(`[analysis] AI API error details:`, errData); - const errorMsg = errData?.error?.message || errData?.message || 'Unknown error'; + const errorMsg = (errData?.error as Record)?.message || errData?.message || 'Unknown error'; throw new Error(`AI API returned ${result.status}: ${typeof errorMsg === 'string' ? errorMsg : 'Request failed'}`); } From 15ae3053788c64fb31c765ab77342f74da06fde2 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 14:32:08 +0800 Subject: [PATCH 17/47] =?UTF-8?q?@=20@=20fix:=20=E4=BF=AE=E5=A4=8D=20CodeR?= =?UTF-8?q?abbit=20=E6=8C=87=E5=87=BA=E7=9A=84=202=20=E4=B8=AA=E5=AE=B9?= =?UTF-8?q?=E9=94=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - health.ts: readFileSync 添加 try-catch,文件缺失时回退 unknown - analysisService.ts: decrypt 添加 try-catch,防止敏感信息泄露 @ --- server/src/routes/health.ts | 8 +++++++- server/src/services/analysisService.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 1a0fb87..16f41f3 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -4,7 +4,13 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const { version } = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')); +let version = 'unknown'; +try { + const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')); + version = pkg.version ?? 'unknown'; +} catch { + version = 'unknown'; +} const router = Router(); diff --git a/server/src/services/analysisService.ts b/server/src/services/analysisService.ts index 8d69cf6..ea4153d 100644 --- a/server/src/services/analysisService.ts +++ b/server/src/services/analysisService.ts @@ -272,7 +272,13 @@ async function callAI( systemPrompt: string, userPrompt: string, ): Promise { - const apiKey = decrypt(aiConfig.api_key_encrypted as string, config.encryptionKey); + let apiKey: string; + try { + apiKey = decrypt(aiConfig.api_key_encrypted as string, config.encryptionKey); + } catch { + console.error('[analysis] Failed to decrypt AI API key'); + throw new Error('Failed to decrypt AI API key'); + } const apiType = (aiConfig.api_type as string) || 'openai'; const baseUrl = aiConfig.base_url as string; const model = aiConfig.model as string; From 9bcf2de1ee10ff67e47d8a9d37b681478774488f Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 14:34:56 +0800 Subject: [PATCH 18/47] =?UTF-8?q?@=20@=20fix:=20=E5=90=8C=E6=AD=A5=20serve?= =?UTF-8?q?r/package.json=20=E7=89=88=E6=9C=AC=E5=8F=B7=E4=B8=BA=200.5.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 server/package.json 停留在 0.1.0,导致 Docker 后端镜像 tag 和 /api/health 返回的版本号与前端不一致 @ --- server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/package.json b/server/package.json index fce8562..2a55b22 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "github-stars-manager-server", - "version": "0.1.0", + "version": "0.5.7", "private": true, "type": "module", "scripts": { From 8a7b744bdac53c01902d5b215e9594cb61e6f707 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 14:37:14 +0800 Subject: [PATCH 19/47] @ @ chore: bump version to 0.6.0 @ --- package.json | 2 +- server/package.json | 2 +- versions/version-info.xml | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 54565f8..64a9ae5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "github-stars-manager", "private": true, - "version": "0.5.7", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/package.json b/server/package.json index 2a55b22..c29ac9e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "github-stars-manager-server", - "version": "0.5.7", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/versions/version-info.xml b/versions/version-info.xml index f3cd078..57c0bbc 100644 --- a/versions/version-info.xml +++ b/versions/version-info.xml @@ -318,4 +318,15 @@ https://github.com/AmintaCCCP/GithubStarsManager/releases/tag/v0.5.7 + + 0.6.0 + 2026-05-11 + + AI 分析改为后端执行,刷新页面不中断,进度不丢失 + 前后端 Docker 镜像使用独立版本号 tag + 修复 /api/health 硬编码版本号,改为动态读取 + 修复版本管理脚本,同步更新前后端版本 + + https://github.com/AmintaCCCP/GithubStarsManager/releases/download/v0.6.0/github-stars-manager-0.6.0.dmg + From 8fb14b2725d47f41fe97f73a432d841cbcac3360 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 14:42:55 +0800 Subject: [PATCH 20/47] =?UTF-8?q?@=20@=20fix:=20AI=20API=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=97=A5=E5=BF=97=E5=8F=AA=E8=AE=B0=E5=BD=95=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=AD=97=E6=AE=B5=EF=BC=8C=E9=81=BF=E5=85=8D=E6=B3=84?= =?UTF-8?q?=E9=9C=B2=E6=95=8F=E6=84=9F=E4=BF=A1=E6=81=AF=20@?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/services/analysisService.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/src/services/analysisService.ts b/server/src/services/analysisService.ts index ea4153d..80a3f6d 100644 --- a/server/src/services/analysisService.ts +++ b/server/src/services/analysisService.ts @@ -349,8 +349,13 @@ async function callAI( if (result.status !== 200) { const errData = result.data as Record | undefined; - console.error(`[analysis] AI API error details:`, errData); - const errorMsg = (errData?.error as Record)?.message || errData?.message || 'Unknown error'; + const errObj = errData?.error && typeof errData.error === 'object' + ? (errData.error as Record) + : undefined; + const errorMsg = errObj?.message || errData?.message || 'Unknown error'; + const requestId = typeof errData?.request_id === 'string' ? errData.request_id : undefined; + const errorCode = typeof errObj?.code === 'string' ? errObj.code : undefined; + console.error('[analysis] AI API error', { status: result.status, requestId, errorCode }); throw new Error(`AI API returned ${result.status}: ${typeof errorMsg === 'string' ? errorMsg : 'Request failed'}`); } From adf5bb2fbced3b7b1d8d2d2624d14e7e572afdb5 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 15:03:53 +0800 Subject: [PATCH 21/47] =?UTF-8?q?@=20@=20fix:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=20AI=20=E5=88=86=E6=9E=90=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildApiUrl 改为从 origin 拼接路径,避免 base URL 已有 /v1 时叠加成 /v1/v1 - syncSettings 添加 github_token,确保后端分析能获取 GitHub Token 避免 429 限流 @ --- server/src/services/analysisService.ts | 5 ++--- src/services/autoSync.ts | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/services/analysisService.ts b/server/src/services/analysisService.ts index 80a3f6d..aeb4ae2 100644 --- a/server/src/services/analysisService.ts +++ b/server/src/services/analysisService.ts @@ -259,11 +259,10 @@ async function fetchReadme(owner: string, repo: string): Promise { // ── AI API call ── function buildApiUrl(baseUrl: string, pathWithVersion: string): string { - const baseUrlWithSlash = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; try { - return new URL(pathWithVersion, baseUrlWithSlash).toString(); + return new URL(pathWithVersion, new URL(baseUrl).origin).toString(); } catch { - return `${baseUrlWithSlash}${pathWithVersion}`; + return `${baseUrl.replace(/\/$/, '')}/${pathWithVersion}`; } } diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index cb10f5e..3cc271e 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -229,6 +229,7 @@ export async function syncToBackend(): Promise { customCategories: state.customCategories, assetFilters: state.assetFilters, collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount, + github_token: state.githubToken, }), ]); const [reposSync, releasesSync, aiSync, webdavSync, settingsSync] = results; From b6f54b472ca3e71cca5115d4746ba705fe39c8b4 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 15:16:43 +0800 Subject: [PATCH 22/47] =?UTF-8?q?@=20@=20fix:=20=E4=BF=AE=E5=A4=8D=20CodeR?= =?UTF-8?q?abbit=20=E6=8C=87=E5=87=BA=E7=9A=84=20syncSettings=20=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 失败日志改为只输出分片名称,避免泄露敏感信息 - githubToken 变更检测和 hash 中加入 github_token,确保 Token 变更会触发同步 @ --- src/services/autoSync.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 3cc271e..29521c3 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -236,7 +236,14 @@ export async function syncToBackend(): Promise { const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { - console.warn(`⚠️ Synced to backend with ${failures.length} error(s):`, failures.map(f => (f as PromiseRejectedResult).reason)); + const failedSlices = [ + reposSync.status === 'rejected' ? 'repositories' : null, + releasesSync.status === 'rejected' ? 'releases' : null, + aiSync.status === 'rejected' ? 'aiConfigs' : null, + webdavSync.status === 'rejected' ? 'webdavConfigs' : null, + settingsSync.status === 'rejected' ? 'settings' : null, + ].filter(Boolean); + console.warn(`⚠️ Synced to backend with ${failures.length} error(s):`, failedSlices); _hasPendingLocalChanges = true; } else { console.log('✅ Synced to backend'); @@ -257,6 +264,7 @@ export async function syncToBackend(): Promise { customCategories: state.customCategories, assetFilters: state.assetFilters, collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount, + github_token: state.githubToken, }); } } catch (err) { @@ -319,7 +327,8 @@ export function startAutoSync(): () => void { state.categoryOrder !== prevState.categoryOrder || state.customCategories !== prevState.customCategories || state.assetFilters !== prevState.assetFilters || - state.collapsedSidebarCategoryCount !== prevState.collapsedSidebarCategoryCount; + state.collapsedSidebarCategoryCount !== prevState.collapsedSidebarCategoryCount || + state.githubToken !== prevState.githubToken; if (!changed) return; From daa72d50fde83f1ed1fbc7ebd6eece1e7b8db14c Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 15:44:49 +0800 Subject: [PATCH 23/47] =?UTF-8?q?@=20fix:=20=E5=88=B7=E6=96=B0=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=90=8E=E6=81=A2=E5=A4=8D=20AI=20=E5=88=86=E6=9E=90?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1=E5=92=8C=E5=81=9C=E6=AD=A2=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 GET /api/analysis/batches/active 返回运行中的批次 - 前端将 batchId 持久化到 localStorage,刷新后通过 resumeBatchAnalysis 恢复轮询 - App 启动时自动重连运行中的分析批次,恢复进度条和仓库分析状态 @ --- server/src/routes/analysis.ts | 11 ++ server/src/services/analysisService.ts | 10 ++ src/App.tsx | 5 + src/services/backendAdapter.ts | 10 ++ src/services/backendAnalysisService.ts | 152 +++++++++++++++++++++++++ 5 files changed, 188 insertions(+) diff --git a/server/src/routes/analysis.ts b/server/src/routes/analysis.ts index 820f60a..25e6fbe 100644 --- a/server/src/routes/analysis.ts +++ b/server/src/routes/analysis.ts @@ -56,6 +56,17 @@ router.post('/api/analysis/batch', (req, res) => { } }); +// GET /api/analysis/batches/active — list running batches (for page-refresh recovery) +router.get('/api/analysis/batches/active', (_req, res) => { + try { + const active = analysisService.getRunningBatches(); + res.json(active); + } catch (err) { + console.error('GET /api/analysis/batches/active error:', err); + res.status(500).json({ error: 'Failed to get active batches', code: 'ANALYSIS_ACTIVE_FAILED' }); + } +}); + // GET /api/analysis/batch/:batchId — check progress router.get('/api/analysis/batch/:batchId', (req, res) => { try { diff --git a/server/src/services/analysisService.ts b/server/src/services/analysisService.ts index aeb4ae2..ad2b77d 100644 --- a/server/src/services/analysisService.ts +++ b/server/src/services/analysisService.ts @@ -94,6 +94,16 @@ export function getBatchStatus(batchId: string): AnalysisBatch | undefined { return batches.get(batchId); } +export function getRunningBatches(): AnalysisBatch[] { + const result: AnalysisBatch[] = []; + for (const batch of batches.values()) { + if (batch.status === 'running') { + result.push(batch); + } + } + return result; +} + export function cancelBatch(batchId: string): boolean { const batch = batches.get(batchId); if (!batch || batch.status !== 'running') return false; diff --git a/src/App.tsx b/src/App.tsx index 6e7ac4a..4526fa8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { useAutoUpdateCheck } from './components/UpdateChecker'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; +import { backendAnalysis } from './services/backendAnalysisService'; import type { AppState } from './types'; const RepositoriesView = React.memo(({ @@ -88,6 +89,10 @@ function App() { if (!cancelled) { unsubscribe = startAutoSync(); } + // Reconnect to any analysis batch that was running before page refresh + if (!cancelled) { + void backendAnalysis.resumeBatchAnalysis(); + } } } catch (err) { console.error('Failed to initialize backend:', err); diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index a03674c..869a761 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -387,6 +387,16 @@ class BackendAdapter { if (!res.ok) await this.throwTranslatedError(res, 'Cancel analysis error'); } + async getActiveBatches(): Promise> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await this.fetchWithTimeout(`${this._backendUrl}/analysis/batches/active`, { + headers: this.getAuthHeaders(), + }); + if (!res.ok) await this.throwTranslatedError(res, 'Active batches error'); + return res.json() as Promise>; + } + // === GitHub Search Proxy === async searchRepositories(queryParams: Record): Promise<{ items: Repository[] }> { diff --git a/src/services/backendAnalysisService.ts b/src/services/backendAnalysisService.ts index 497c990..beea3e6 100644 --- a/src/services/backendAnalysisService.ts +++ b/src/services/backendAnalysisService.ts @@ -1,6 +1,13 @@ import { backend } from './backendAdapter'; import { useAppStore } from '../store/useAppStore'; +const STORAGE_KEY = 'gsm:analysis:batches'; + +interface SavedBatch { + batchId: string; + repositoryIds: number[]; +} + interface BatchAnalysisOptions { repositoryIds: number[]; configId: string; @@ -11,6 +18,32 @@ interface BatchAnalysisOptions { onRepoResult?: (repoId: number) => void; } +function loadSavedBatches(): SavedBatch[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed; + } + } catch { /* corrupted */ } + return []; +} + +function saveBatch(batchId: string, repositoryIds: number[]): void { + const batches = loadSavedBatches(); + batches.push({ batchId, repositoryIds }); + localStorage.setItem(STORAGE_KEY, JSON.stringify(batches)); +} + +function removeBatch(batchId: string): void { + const batches = loadSavedBatches().filter(b => b.batchId !== batchId); + if (batches.length === 0) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(batches)); + } +} + class BackendAnalysisService { private pollingTimeout: ReturnType | null = null; private currentBatchId: string | null = null; @@ -37,6 +70,9 @@ class BackendAnalysisService { const { batchId } = await backend.startAnalysis(repositoryIds, configId, language, categoryNames); this.currentBatchId = batchId; + // Persist to localStorage for page-refresh recovery + saveBatch(batchId, repositoryIds); + return new Promise((resolve) => { let lastReportedCount = 0; @@ -60,6 +96,7 @@ class BackendAnalysisService { if (progress.status === 'completed' || progress.status === 'cancelled' || progress.status === 'failed') { this.finishBatch(batchId, repositoryIds); + removeBatch(batchId); if (progress.status === 'completed') { try { @@ -126,6 +163,121 @@ class BackendAnalysisService { return this._isRunning; } + /** + * Called on app startup to reconnect to any batch that was running + * when the user last refreshed the page. + */ + async resumeBatchAnalysis( + onProgress?: (current: number, total: number) => void, + onComplete?: () => void, + ): Promise { + if (this._isRunning) return; + if (!backend.isAvailable) return; + + const savedBatches = loadSavedBatches(); + if (savedBatches.length === 0) return; + + // Fetch active batches from backend to see what's still running + let activeBatches: Array<{ batchId: string; status: string; total: number; completed: number; failed: number; repositoryIds: number[] }> = []; + try { + activeBatches = await backend.getActiveBatches(); + } catch { + // Backend unreachable — keep saved batches for next attempt + return; + } + + const activeIds = new Set(activeBatches.map(b => b.batchId)); + + // Clean up completed batches from localStorage + let cleaned = false; + for (const saved of savedBatches) { + if (!activeIds.has(saved.batchId)) { + removeBatch(saved.batchId); + cleaned = true; + } + } + if (cleaned) { + // Reload after cleaning + const remaining = loadSavedBatches(); + if (remaining.length === 0) return; + } + + // If no active batches on backend, nothing to resume + if (activeBatches.length === 0) return; + + // Resume the first active batch that we have in localStorage + // (normally there's only one batch running at a time) + const remainingSaved = loadSavedBatches(); + for (const active of activeBatches) { + const saved = remainingSaved.find(s => s.batchId === active.batchId); + if (!saved) continue; + + // Restore analyzing state for all repositories in this batch + const store = useAppStore.getState(); + for (const id of saved.repositoryIds) { + store.setAnalyzingRepository(id, true); + } + store.setAnalysisProgress({ current: active.completed + active.failed, total: active.total }); + + this._isRunning = true; + this.currentBatchId = active.batchId; + + const poll = async (): Promise => { + if (this.currentBatchId !== active.batchId) return; + + try { + const progress = await backend.getAnalysisProgress(active.batchId); + if (this.currentBatchId !== active.batchId) return; + + store.setAnalysisProgress({ current: progress.completed + progress.failed, total: progress.total }); + onProgress?.(progress.completed + progress.failed, progress.total); + + if (progress.status === 'completed' || progress.status === 'cancelled' || progress.status === 'failed') { + this.finishBatch(active.batchId, saved.repositoryIds); + removeBatch(active.batchId); + + if (progress.status === 'completed') { + try { + const repoData = await backend.fetchRepositories(); + const repoMap = new Map(repoData.repositories.map((r) => [r.id, r])); + const currentRepos = useAppStore.getState().repositories; + const updatedRepos = currentRepos.map((r) => { + const updated = repoMap.get(r.id); + if (updated) { + return { + ...r, + ai_summary: updated.ai_summary, + ai_tags: updated.ai_tags, + ai_platforms: updated.ai_platforms, + analyzed_at: updated.analyzed_at, + analysis_failed: updated.analysis_failed, + }; + } + return r; + }); + useAppStore.getState().setRepositories(updatedRepos); + } catch { + // Refresh failed — UI can resync manually + } + } + + onComplete?.(); + return; + } + } catch { + // Poll errors are non-fatal + } + + if (this.currentBatchId === active.batchId) { + this.pollingTimeout = setTimeout(poll, 2000); + } + }; + + poll(); + return; // Only resume one batch + } + } + private finishBatch(batchId: string | null, repositoryIds: number[]): void { // Only clean up instance state if this batch is still current if (batchId === null || this.currentBatchId === batchId) { From b16a06c895ea8a219dc309f46b6ba166dc1cefa5 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 17:15:38 +0800 Subject: [PATCH 24/47] =?UTF-8?q?@=20refactor:=20=E4=BB=A5=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E4=B8=BA=E5=8D=95=E4=B8=80=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=8F=8C=E5=90=91=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 autoSync.ts 中所有 push-to-backend 逻辑,仅保留 5s 轮询拉取 - backendAdapter 新增 updateRepository(PATCH) 和 deleteRepository(DELETE) 方法 - 18 处 forceSyncToBackend() 替换为精确的 API 调用 - 后端 upsert 时按字段保留已有 AI 数据,防止覆盖分析结果 - 修复 SubscriptionRepoCard 中 existingRepo 可能 undefined 的崩溃隐患 - 清理 autoSync.ts 中死变量和过时注释 --- server/src/routes/repositories.ts | 81 +++++++++- src/App.tsx | 4 +- src/components/CategorySidebar.tsx | 12 +- src/components/ReleaseTimeline.tsx | 5 +- src/components/RepositoryCard.tsx | 5 +- src/components/RepositoryEditModal.tsx | 10 +- src/components/RepositoryList.tsx | 14 +- src/components/SubscriptionRepoCard.tsx | 14 +- src/services/autoSync.ts | 197 +----------------------- src/services/backendAdapter.ts | 27 +++- 10 files changed, 138 insertions(+), 231 deletions(-) diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts index 66b7833..aed6d58 100644 --- a/server/src/routes/repositories.ts +++ b/server/src/routes/repositories.ts @@ -90,7 +90,7 @@ router.put('/api/repositories', (req, res) => { return; } - // 验证每个仓库的ID + // Validate each repository for (const repo of repositories) { if (!repo.id || typeof repo.id !== 'number' || repo.id <= 0) { res.status(400).json({ error: 'Each repository must have a valid positive integer id', code: 'INVALID_REPOSITORY_ID' }); @@ -156,9 +156,74 @@ router.put('/api/repositories', (req, res) => { deleteRepositoriesNotIn(placeholders).run(...repoIds); } + // Pre-fetch existing AI data to preserve it when incoming repo has no AI data + const existingAI = new Map(); + const ids = repositories.map(r => r.id).filter((id): id is number => typeof id === 'number'); + if (ids.length > 0) { + const phs = ids.map(() => '?').join(', '); + const rows = db.prepare( + `SELECT id, ai_summary, ai_tags, ai_platforms, analyzed_at, analysis_failed + FROM repositories WHERE id IN (${phs})` + ).all(...ids) as Array<{ + id: number; + ai_summary: string | null; + ai_tags: string | null; + ai_platforms: string | null; + analyzed_at: string | null; + analysis_failed: number | null; + }>; + for (const row of rows) { + existingAI.set(row.id, { + ai_summary: row.ai_summary, + ai_tags: row.ai_tags ?? '[]', + ai_platforms: row.ai_platforms ?? '[]', + analyzed_at: row.analyzed_at, + analysis_failed: row.analysis_failed ?? 0, + }); + } + } + let count = 0; for (const repo of repositories) { const owner = repo.owner as { login?: string; avatar_url?: string } | undefined; + const existing = existingAI.get(repo.id as number); + + // Preserve existing AI data field-by-field when incoming value is empty + const hasAnyIncomingAI = + (repo.ai_summary != null && repo.ai_summary !== '') || + (Array.isArray(repo.ai_tags) && repo.ai_tags.length > 0) || + (Array.isArray(repo.ai_platforms) && repo.ai_platforms.length > 0) || + repo.analyzed_at != null || + repo.analysis_failed === true || + repo.analysis_failed === 1; + + const hasIncomingSummary = repo.ai_summary != null && repo.ai_summary !== ''; + const hasIncomingTags = Array.isArray(repo.ai_tags) && repo.ai_tags.length > 0; + const hasIncomingPlatforms = Array.isArray(repo.ai_platforms) && repo.ai_platforms.length > 0; + const hasIncomingAnalyzedAt = repo.analyzed_at != null; + + const aiSummary = hasIncomingSummary + ? repo.ai_summary + : (existing?.ai_summary ?? null); + const aiTagsJson = hasIncomingTags + ? JSON.stringify(repo.ai_tags) + : (existing?.ai_tags ?? '[]'); + const aiPlatformsJson = hasIncomingPlatforms + ? JSON.stringify(repo.ai_platforms) + : (existing?.ai_platforms ?? '[]'); + const analyzedAt = hasIncomingAnalyzedAt + ? repo.analyzed_at + : (existing?.analyzed_at ?? null); + const analysisFailed = hasAnyIncomingAI + ? ((repo.analysis_failed === true || repo.analysis_failed === 1) ? 1 : 0) + : (existing?.analysis_failed ?? 0); + stmt.run( repo.id, repo.name, repo.full_name, repo.description ?? null, repo.html_url, repo.stargazers_count ?? 0, repo.language ?? null, @@ -166,10 +231,10 @@ router.put('/api/repositories', (req, res) => { repo.starred_at ?? null, owner?.login ?? '', owner?.avatar_url ?? null, JSON.stringify(Array.isArray(repo.topics) ? repo.topics : []), - repo.ai_summary ?? null, - JSON.stringify(Array.isArray(repo.ai_tags) ? repo.ai_tags : []), - JSON.stringify(Array.isArray(repo.ai_platforms) ? repo.ai_platforms : []), - repo.analyzed_at ?? null, (repo.analysis_failed === true || repo.analysis_failed === 1) ? 1 : 0, + aiSummary, + aiTagsJson, + aiPlatformsJson, + analyzedAt, analysisFailed, repo.custom_description ?? null, JSON.stringify(Array.isArray(repo.custom_tags) ? repo.custom_tags : []), repo.custom_category ?? null, (repo.category_locked === true || repo.category_locked === 1) ? 1 : 0, repo.last_edited ?? null, @@ -263,7 +328,7 @@ router.delete('/api/repositories/:id', (req, res) => { const deleteAll = db.transaction(() => { const releaseResult = deleteReleases.run(id); const repoResult = deleteRepo.run(id); - + return { releasesDeleted: releaseResult.changes, repoDeleted: repoResult.changes @@ -277,8 +342,8 @@ router.delete('/api/repositories/:id', (req, res) => { return; } - res.json({ - deleted: true, + res.json({ + deleted: true, id, releasesDeleted: result.releasesDeleted }); diff --git a/src/App.tsx b/src/App.tsx index 4526fa8..11cb348 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import { useAppStore } from './store/useAppStore'; import { useAutoUpdateCheck } from './components/UpdateChecker'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; -import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; +import { syncFromBackend, startAutoSync } from './services/autoSync'; import { backendAnalysis } from './services/backendAnalysisService'; import type { AppState } from './types'; @@ -104,7 +104,7 @@ function App() { return () => { cancelled = true; if (unsubscribe) { - stopAutoSync(unsubscribe); + unsubscribe(); } }; }, []); diff --git a/src/components/CategorySidebar.tsx b/src/components/CategorySidebar.tsx index 9a972fd..89909e3 100644 --- a/src/components/CategorySidebar.tsx +++ b/src/components/CategorySidebar.tsx @@ -10,7 +10,7 @@ import { import { Category, Repository } from '../types'; import { useAppStore, getAllCategories, sortCategoriesByOrder } from '../store/useAppStore'; import { CategoryEditModal } from './CategoryEditModal'; -import { forceSyncToBackend } from '../services/autoSync'; +import { backend } from '../services/backendAdapter'; import { getAICategory, getDefaultCategory, computeCustomCategory, matchesCategory } from '../utils/categoryUtils'; import { useDialog } from '../hooks/useDialog'; @@ -211,7 +211,7 @@ export const CategorySidebar: React.FC = ({ deleteCustomCategory(category.id); try { - await forceSyncToBackend(); + await backend.syncSettings({ customCategories: useAppStore.getState().customCategories }); } catch { toast(t('删除分类失败,请检查后端连接。', 'Failed to delete category. Please check backend connection.'), 'error'); } @@ -231,7 +231,7 @@ export const CategorySidebar: React.FC = ({ hideDefaultCategory(category.id); try { - await forceSyncToBackend(); + await backend.syncSettings({ hiddenDefaultCategoryIds: useAppStore.getState().hiddenDefaultCategoryIds }); } catch { showDefaultCategory(category.id); toast(t('隐藏分类失败,请检查后端连接。', 'Failed to hide category. Please check backend connection.'), 'error'); @@ -295,7 +295,11 @@ export const CategorySidebar: React.FC = ({ updateRepository(nextRepo); try { - await forceSyncToBackend(); + await backend.updateRepository(repository.id, { + custom_category: customCategoryValue, + category_locked: customCategoryValue !== undefined && customCategoryValue !== '', + last_edited: nextRepo.last_edited, + }); } catch { handleSyncError(originalRepo); } diff --git a/src/components/ReleaseTimeline.tsx b/src/components/ReleaseTimeline.tsx index d36d979..eb55c46 100644 --- a/src/components/ReleaseTimeline.tsx +++ b/src/components/ReleaseTimeline.tsx @@ -3,7 +3,7 @@ import { Package, Bell, Search, X, RefreshCw, ChevronLeft, ChevronRight, Chevron import { Release } from '../types'; import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; -import { forceSyncToBackend } from '../services/autoSync'; +import { backend } from '../services/backendAdapter'; import { formatDistanceToNow } from 'date-fns'; import { AssetFilterManager } from './AssetFilterManager'; import { PRESET_FILTERS } from '../constants/presetFilters'; @@ -501,7 +501,8 @@ export const ReleaseTimeline: React.FC = () => { removeReleasesByRepoId(repo.id); try { - await forceSyncToBackend(); + await backend.updateRepository(repo.id, { subscribed_to_releases: false }); + await backend.syncReleases(useAppStore.getState().releases); } catch (error) { console.error('Failed to unsubscribe release:', error); updateRepository({ ...repo, subscribed_to_releases: true }); diff --git a/src/components/RepositoryCard.tsx b/src/components/RepositoryCard.tsx index 78495c3..490168b 100644 --- a/src/components/RepositoryCard.tsx +++ b/src/components/RepositoryCard.tsx @@ -5,7 +5,6 @@ import { Repository, Category } from '../types'; import { useAppStore } from '../store/useAppStore'; import { getAICategory, getDefaultCategory } from '../utils/categoryUtils'; import { analyzeRepository, createFailedAnalysisResult } from '../services/aiAnalysisHelper'; -import { forceSyncToBackend } from '../services/autoSync'; import { backend } from '../services/backendAdapter'; import { backendAnalysis } from '../services/backendAnalysisService'; import { GitHubApiService } from '../services/githubApi'; @@ -579,7 +578,9 @@ const RepositoryCardComponent: React.FC = ({ const [owner, repo] = repository.full_name.split('/'); await githubApi.unstarRepository(owner, repo); deleteRepository(repository.id); - await forceSyncToBackend(); + if (backend.isAvailable) { + backend.deleteRepository(repository.id).catch(() => { /* non-critical */ }); + } const successMessage = language === 'zh' ? '已成功取消 Star' : 'Successfully unstarred'; diff --git a/src/components/RepositoryEditModal.tsx b/src/components/RepositoryEditModal.tsx index cd6e56e..f8ca908 100644 --- a/src/components/RepositoryEditModal.tsx +++ b/src/components/RepositoryEditModal.tsx @@ -3,7 +3,7 @@ import { Save, X, Plus, Lock, Unlock, RotateCcw, Bot, Edit3, FileText, Tag, Fold import { Modal } from './Modal'; import { Repository } from '../types'; import { useAppStore, getAllCategories } from '../store/useAppStore'; -import { forceSyncToBackend } from '../services/autoSync'; +import { backend } from '../services/backendAdapter'; import { computeCustomCategory, getAICategory, getDefaultCategory } from '../utils/categoryUtils'; interface RepositoryEditModalProps { @@ -377,7 +377,13 @@ export const RepositoryEditModal: React.FC = ({ updatedRepo.last_edited = new Date().toISOString(); updateRepository(updatedRepo); - await forceSyncToBackend(); + await backend.updateRepository(repository.id, { + custom_description: updatedRepo.custom_description, + custom_tags: updatedRepo.custom_tags, + custom_category: updatedRepo.custom_category, + category_locked: updatedRepo.category_locked, + last_edited: updatedRepo.last_edited, + }); onClose(); }; diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx index a56a0a3..7fa2e88 100644 --- a/src/components/RepositoryList.tsx +++ b/src/components/RepositoryList.tsx @@ -13,7 +13,6 @@ import { AIAnalysisOptimizer, AnalysisResult } from '../services/aiAnalysisOptim import { backend } from '../services/backendAdapter'; import { backendAnalysis } from '../services/backendAnalysisService'; import { resolveCategoryAssignment, getAICategory, getDefaultCategory, computeCustomCategory } from '../utils/categoryUtils'; -import { forceSyncToBackend } from '../services/autoSync'; import { useDialog } from '../hooks/useDialog'; interface RepositoryListProps { @@ -620,8 +619,6 @@ export const RepositoryList: React.FC = ({ } } - await forceSyncToBackend(); - const skipMsg = failedRepos.length > 0 ? (language === 'zh' ? `\n\n失败 (${failedRepos.length} 个):\n${failedRepos.join('\n')}` @@ -688,9 +685,8 @@ export const RepositoryList: React.FC = ({ // 仅删除成功 unstar 的仓库 for (const repoId of successIds) { deleteRepository(repoId); + backend.deleteRepository(repoId).catch(() => {}); } - - await forceSyncToBackend(); toast(language === 'zh' ? `成功取消 ${successIds.length} 个仓库的 Star` : `Successfully unstarred ${successIds.length} repositories`, @@ -840,7 +836,6 @@ export const RepositoryList: React.FC = ({ const stats = optimizerRef.current!.getStats(); console.log('Bulk AI Analysis Stats:', stats); - await forceSyncToBackend(); toast(language === 'zh' ? `成功分析 ${successCount} 个仓库,失败 ${failedCount} 个 (平均响应: ${stats.averageResponseTime}ms)` : `Successfully analyzed ${successCount} repositories, ${failedCount} failed (avg: ${stats.averageResponseTime}ms)`, @@ -877,7 +872,6 @@ export const RepositoryList: React.FC = ({ } } - await forceSyncToBackend(); toast(language === 'zh' ? `成功订阅 ${successCount} 个仓库的版本发布` : `Successfully subscribed to ${successCount} repositories releases`, @@ -910,8 +904,6 @@ export const RepositoryList: React.FC = ({ } } - await forceSyncToBackend(); - // 汇总结果显示 const successCount = subscribedRepos.length - failedRepos.length; if (failedRepos.length > 0) { @@ -954,7 +946,6 @@ export const RepositoryList: React.FC = ({ } } - await forceSyncToBackend(); const skipMsg = skippedCount > 0 ? (language === 'zh' ? `\n\n跳过 ${skippedCount} 个没有自定义分类的仓库` : `\n\nSkipped ${skippedCount} repositories without custom category`) : ''; @@ -992,7 +983,6 @@ export const RepositoryList: React.FC = ({ } } - await forceSyncToBackend(); if (failedRepos.length > 0) { toast(language === 'zh' ? `成功解锁 ${successCount} 个仓库的分类\n\n失败 (${failedRepos.length} 个):\n${failedRepos.join('\n')}` @@ -1051,8 +1041,6 @@ export const RepositoryList: React.FC = ({ } } - await forceSyncToBackend(); - // 汇总结果显示 const successCount = selectedRepos.length - failedRepos.length; if (failedRepos.length > 0) { diff --git a/src/components/SubscriptionRepoCard.tsx b/src/components/SubscriptionRepoCard.tsx index 0c51f0b..a8dee00 100644 --- a/src/components/SubscriptionRepoCard.tsx +++ b/src/components/SubscriptionRepoCard.tsx @@ -3,7 +3,6 @@ import { Star, StarOff, ExternalLink, Bot, GitFork, Monitor, Smartphone, Globe, import type { DiscoveryRepo, Repository } from '../types'; import { useAppStore, getAllCategories } from '../store/useAppStore'; import { analyzeRepository, createFailedAnalysisResult } from '../services/aiAnalysisHelper'; -import { forceSyncToBackend } from '../services/autoSync'; import { backend } from '../services/backendAdapter'; import { backendAnalysis } from '../services/backendAnalysisService'; import { GitHubApiService } from '../services/githubApi'; @@ -116,10 +115,11 @@ export const SubscriptionRepoCard: React.FC = ({ repo const existingRepo = repositories.find(r => r.full_name === repo.full_name); if (existingRepo) { deleteRepository(existingRepo.id); + if (backend.isAvailable) { + backend.deleteRepository(existingRepo.id).catch(() => {}); + } } - - await forceSyncToBackend(); - + // 操作成功,清除乐观状态 setOptimisticStarred(null); } catch (error) { @@ -175,8 +175,10 @@ export const SubscriptionRepoCard: React.FC = ({ repo onStar(repo); } - await forceSyncToBackend(); - + if (backend.isAvailable) { + backend.syncRepositories([repositoryToAdd]).catch(() => {}); + } + // 操作成功,清除乐观状态 setOptimisticStarred(null); diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 29521c3..5cb43a3 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -1,24 +1,9 @@ import { backend } from './backendAdapter'; import { useAppStore } from '../store/useAppStore'; -// Prevent sync loops: when we pull data FROM backend and update store, -// the store subscription would trigger a push TO backend. This flag blocks that. -let _isSyncingFromBackend = false; +// Prevent concurrent syncs: when we're pulling data FROM backend, don't start another pull. let _isSyncingFromBackendActive = false; -// Track store subscription for cleanup on restart -let _storeUnsubscribe: (() => void) | null = null; - -// Prevent overlapping pushes to backend -let _isPushingToBackend = false; -// Queue a push if one is requested while a pull is in-flight -let _hasPendingPush = false; -// Track unsynced local edits so backend polling does not overwrite them. -let _hasPendingLocalChanges = false; - -// Debounce timer for push-to-backend -let _debounceTimer: ReturnType | null = null; - // Polling timer for pull-from-backend let _pollTimer: ReturnType | null = null; @@ -49,13 +34,7 @@ function setRepositorySyncVisualState(isSyncing: boolean): void { * Silent: errors logged to console only. */ export async function syncFromBackend(): Promise { - if ( - !backend.isAvailable || - _isSyncingFromBackendActive || - _isPushingToBackend || - _hasPendingLocalChanges || - _debounceTimer - ) { + if (!backend.isAvailable || _isSyncingFromBackendActive) { return; } @@ -120,7 +99,6 @@ export async function syncFromBackend(): Promise { return; } - _isSyncingFromBackend = true; if (changed.repos || changed.releases) { setRepositorySyncVisualState(true); } @@ -186,197 +164,38 @@ export async function syncFromBackend(): Promise { console.error('Failed to sync from backend:', err); } finally { setRepositorySyncVisualState(false); - _isSyncingFromBackend = false; _isSyncingFromBackendActive = false; - // Drain pending push that was queued during pull - if (_hasPendingPush) { - _hasPendingPush = false; - void syncToBackend(); - } - } -} - -/** - * Push current local state to backend. - * Silent: errors logged to console only. - */ -export async function syncToBackend(): Promise { - if (!backend.isAvailable) return; - // If a pull is in-flight, queue this push for after pull completes - if (_isSyncingFromBackendActive) { - _hasPendingPush = true; - return; - } - if (_isSyncingFromBackend) return; - if (_isPushingToBackend) return; - - _isPushingToBackend = true; - _hasPendingPush = false; - setRepositorySyncVisualState(true); - try { - const state = useAppStore.getState(); - - const results = await Promise.allSettled([ - backend.syncRepositories(state.repositories), - backend.syncReleases(state.releases), - backend.syncAIConfigs(state.aiConfigs), - backend.syncWebDAVConfigs(state.webdavConfigs), - backend.syncSettings({ - activeAIConfig: state.activeAIConfig, - activeWebDAVConfig: state.activeWebDAVConfig, - hiddenDefaultCategoryIds: state.hiddenDefaultCategoryIds, - categoryOrder: state.categoryOrder, - customCategories: state.customCategories, - assetFilters: state.assetFilters, - collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount, - github_token: state.githubToken, - }), - ]); - const [reposSync, releasesSync, aiSync, webdavSync, settingsSync] = results; - - const failures = results.filter(r => r.status === 'rejected'); - if (failures.length > 0) { - const failedSlices = [ - reposSync.status === 'rejected' ? 'repositories' : null, - releasesSync.status === 'rejected' ? 'releases' : null, - aiSync.status === 'rejected' ? 'aiConfigs' : null, - webdavSync.status === 'rejected' ? 'webdavConfigs' : null, - settingsSync.status === 'rejected' ? 'settings' : null, - ].filter(Boolean); - console.warn(`⚠️ Synced to backend with ${failures.length} error(s):`, failedSlices); - _hasPendingLocalChanges = true; - } else { - console.log('✅ Synced to backend'); - _hasPendingLocalChanges = false; - } - - // Only update _lastHash for successfully synced slices - if (reposSync.status === 'fulfilled') _lastHash.repos = quickHash(state.repositories); - if (releasesSync.status === 'fulfilled') _lastHash.releases = quickHash(state.releases); - if (aiSync.status === 'fulfilled') _lastHash.ai = quickHash(state.aiConfigs); - if (webdavSync.status === 'fulfilled') _lastHash.webdav = quickHash(state.webdavConfigs); - if (settingsSync.status === 'fulfilled') { - _lastHash.settings = quickHash({ - activeAIConfig: state.activeAIConfig, - activeWebDAVConfig: state.activeWebDAVConfig, - hiddenDefaultCategoryIds: state.hiddenDefaultCategoryIds, - categoryOrder: state.categoryOrder, - customCategories: state.customCategories, - assetFilters: state.assetFilters, - collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount, - github_token: state.githubToken, - }); - } - } catch (err) { - console.error('Failed to sync to backend:', err); - } finally { - setRepositorySyncVisualState(false); - _isPushingToBackend = false; - } -} - -/** - * Immediately push current local state to backend. - * Used for destructive/high-priority operations such as unstar/delete. - */ -export async function forceSyncToBackend(): Promise { - if (_debounceTimer) { - clearTimeout(_debounceTimer); - _debounceTimer = null; } - _hasPendingLocalChanges = true; - await syncToBackend(); } /** - * Subscribe to Zustand store changes and auto-push to backend with 2s debounce. + * Start polling backend for cross-device data sync. * Returns an unsubscribe function for cleanup. */ export function startAutoSync(): () => void { - // Guard: if already running, stop previous instance first - if (_storeUnsubscribe) { - _storeUnsubscribe(); - _storeUnsubscribe = null; - } if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } - if (_debounceTimer) { - clearTimeout(_debounceTimer); - _debounceTimer = null; - } - // Reset in-flight state flags to prevent permanent sync blocking - _isSyncingFromBackend = false; - _isPushingToBackend = false; _isSyncingFromBackendActive = false; - _hasPendingPush = false; - _hasPendingLocalChanges = false; - // 1. Subscribe to local changes → push to backend (2s debounce) - const unsubscribe = useAppStore.subscribe((state, prevState) => { - if (_isSyncingFromBackend) return; - - const changed = - state.repositories !== prevState.repositories || - state.releases !== prevState.releases || - state.aiConfigs !== prevState.aiConfigs || - state.webdavConfigs !== prevState.webdavConfigs || - state.activeAIConfig !== prevState.activeAIConfig || - state.activeWebDAVConfig !== prevState.activeWebDAVConfig || - state.hiddenDefaultCategoryIds !== prevState.hiddenDefaultCategoryIds || - state.categoryOrder !== prevState.categoryOrder || - state.customCategories !== prevState.customCategories || - state.assetFilters !== prevState.assetFilters || - state.collapsedSidebarCategoryCount !== prevState.collapsedSidebarCategoryCount || - state.githubToken !== prevState.githubToken; - - if (!changed) return; - - _hasPendingLocalChanges = true; - // Debounce: wait 2s after last change before pushing - if (_debounceTimer) { - clearTimeout(_debounceTimer); - } - _debounceTimer = setTimeout(() => { - _debounceTimer = null; - void syncToBackend(); - }, 2000); - }); - _storeUnsubscribe = unsubscribe; - - // 2. Poll backend every 5s → pull fresh data for cross-device sync + // Poll backend every 5s for cross-device sync _pollTimer = setInterval(() => { syncFromBackend(); }, POLL_INTERVAL); - console.log('🔄 Auto-sync started (push debounce: 2s, poll: 5s)'); - return unsubscribe; + console.log('🔄 Auto-sync started (poll: 5s)'); + return stopAutoSync; } /** - * Stop auto-sync: clear debounce timer and unsubscribe from store. + * Stop auto-sync polling. */ -export function stopAutoSync(unsubscribe: () => void): void { - if (_debounceTimer) { - clearTimeout(_debounceTimer); - _debounceTimer = null; - } +export function stopAutoSync(): void { if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } - if (_storeUnsubscribe) { - _storeUnsubscribe(); - _storeUnsubscribe = null; - } else { - unsubscribe(); - } - // Reset in-flight state flags - _isPushingToBackend = false; _isSyncingFromBackendActive = false; - _isSyncingFromBackend = false; - _hasPendingPush = false; - _hasPendingLocalChanges = false; console.log('🔄 Auto-sync stopped'); } diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 869a761..9fc924c 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -198,17 +198,38 @@ class BackendAdapter { // === Data Sync === - async syncRepositories(repos: Repository[]): Promise { + async syncRepositories(repos: Repository[], isFullSync = false): Promise { if (!this._backendUrl) return; const res = await this.fetchWithTimeout(`${this._backendUrl}/repositories`, { method: 'PUT', headers: this.getAuthHeaders(), - body: JSON.stringify({ repositories: repos, isFullSync: true }) + body: JSON.stringify({ repositories: repos, isFullSync }) }); if (!res.ok) await this.throwTranslatedError(res, 'Sync repositories error'); } + async updateRepository(id: number, fields: Record): Promise { + if (!this._backendUrl) return; + + const res = await this.fetchWithTimeout(`${this._backendUrl}/repositories/${id}`, { + method: 'PATCH', + headers: this.getAuthHeaders(), + body: JSON.stringify(fields), + }); + if (!res.ok) await this.throwTranslatedError(res, 'Update repository error'); + } + + async deleteRepository(id: number): Promise { + if (!this._backendUrl) return; + + const res = await this.fetchWithTimeout(`${this._backendUrl}/repositories/${id}`, { + method: 'DELETE', + headers: this.getAuthHeaders(), + }); + if (!res.ok) await this.throwTranslatedError(res, 'Delete repository error'); + } + async fetchRepositories(): Promise<{ repositories: Repository[]; total: number }> { if (!this._backendUrl) throw new Error('Backend not available'); @@ -283,7 +304,7 @@ class BackendAdapter { } - // === Settings (active selections) === + // === Settings === async syncSettings(settings: Record): Promise { if (!this._backendUrl) return; From 383ae4614780dbf2f84b16f29dcc9a93e156d473 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 17:31:02 +0800 Subject: [PATCH 25/47] =?UTF-8?q?@=20fix:=20CodeRabbit=20=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=20=E2=80=94=20=E6=B7=BB=E5=8A=A0=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=9A=84=E5=9B=9E=E6=BB=9A=E5=92=8C?= =?UTF-8?q?=E8=A1=A5=E5=81=BF=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CategorySidebar: deleteCustomCategory 失败时回滚本地 state - RepositoryEditModal: backend.updateRepository 失败时回滚 + toast 提示 - ReleaseTimeline: 取消订阅两步写入失败时补偿恢复后端 subscribed_to_releases --- src/components/CategorySidebar.tsx | 2 ++ src/components/ReleaseTimeline.tsx | 3 +++ src/components/RepositoryEditModal.tsx | 25 +++++++++++++++++-------- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/components/CategorySidebar.tsx b/src/components/CategorySidebar.tsx index 89909e3..9beef7b 100644 --- a/src/components/CategorySidebar.tsx +++ b/src/components/CategorySidebar.tsx @@ -209,10 +209,12 @@ export const CategorySidebar: React.FC = ({ if (!confirmed) return; + const prevCategories = useAppStore.getState().customCategories; deleteCustomCategory(category.id); try { await backend.syncSettings({ customCategories: useAppStore.getState().customCategories }); } catch { + useAppStore.setState({ customCategories: prevCategories }); toast(t('删除分类失败,请检查后端连接。', 'Failed to delete category. Please check backend connection.'), 'error'); } }; diff --git a/src/components/ReleaseTimeline.tsx b/src/components/ReleaseTimeline.tsx index eb55c46..246d974 100644 --- a/src/components/ReleaseTimeline.tsx +++ b/src/components/ReleaseTimeline.tsx @@ -505,6 +505,9 @@ export const ReleaseTimeline: React.FC = () => { await backend.syncReleases(useAppStore.getState().releases); } catch (error) { console.error('Failed to unsubscribe release:', error); + // Revert backend: if the first call succeeded before the second failed, + // restore subscribed_to_releases to keep backend consistent with local rollback. + backend.updateRepository(repo.id, { subscribed_to_releases: true }).catch(() => {}); updateRepository({ ...repo, subscribed_to_releases: true }); const state = useAppStore.getState(); useAppStore.setState({ diff --git a/src/components/RepositoryEditModal.tsx b/src/components/RepositoryEditModal.tsx index f8ca908..0868cde 100644 --- a/src/components/RepositoryEditModal.tsx +++ b/src/components/RepositoryEditModal.tsx @@ -5,6 +5,7 @@ import { Repository } from '../types'; import { useAppStore, getAllCategories } from '../store/useAppStore'; import { backend } from '../services/backendAdapter'; import { computeCustomCategory, getAICategory, getDefaultCategory } from '../utils/categoryUtils'; +import { useDialog } from '../hooks/useDialog'; interface RepositoryEditModalProps { isOpen: boolean; @@ -55,6 +56,7 @@ export const RepositoryEditModal: React.FC = ({ repository }) => { const { updateRepository, language, customCategories, hiddenDefaultCategoryIds, defaultCategoryOverrides, theme } = useAppStore(); + const { toast } = useDialog(); const [formData, setFormData] = useState({ description: '', @@ -376,15 +378,22 @@ export const RepositoryEditModal: React.FC = ({ // 更新编辑时间 updatedRepo.last_edited = new Date().toISOString(); + const originalRepo = { ...repository }; updateRepository(updatedRepo); - await backend.updateRepository(repository.id, { - custom_description: updatedRepo.custom_description, - custom_tags: updatedRepo.custom_tags, - custom_category: updatedRepo.custom_category, - category_locked: updatedRepo.category_locked, - last_edited: updatedRepo.last_edited, - }); - onClose(); + try { + await backend.updateRepository(repository.id, { + custom_description: updatedRepo.custom_description, + custom_tags: updatedRepo.custom_tags, + custom_category: updatedRepo.custom_category, + category_locked: updatedRepo.category_locked, + last_edited: updatedRepo.last_edited, + }); + onClose(); + } catch (err) { + updateRepository(originalRepo); + console.error('Failed to save repository:', err); + toast(t('保存失败,请检查后端连接。', 'Failed to save. Please check backend connection.'), 'error'); + } }; const handleClose = () => { From ff6df11370990b80c65191da2e66e9a1f1139656 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 17:35:27 +0800 Subject: [PATCH 26/47] @ bump version to 0.6.1 --- package.json | 2 +- server/package.json | 2 +- versions/version-info.xml | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 64a9ae5..1153563 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "github-stars-manager", "private": true, - "version": "0.6.0", + "version": "0.6.1", "type": "module", "scripts": { "dev": "vite", diff --git a/server/package.json b/server/package.json index c29ac9e..fd71d3f 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "github-stars-manager-server", - "version": "0.6.0", + "version": "0.6.1", "private": true, "type": "module", "scripts": { diff --git a/versions/version-info.xml b/versions/version-info.xml index 57c0bbc..8680140 100644 --- a/versions/version-info.xml +++ b/versions/version-info.xml @@ -329,4 +329,15 @@ https://github.com/AmintaCCCP/GithubStarsManager/releases/download/v0.6.0/github-stars-manager-0.6.0.dmg + + 0.6.1 + 2026-05-11 + + 架构重构: 以后端为单一数据源,移除双向自动同步,用户操作改为精确 API 调用 + 修复: 后端 upsert 时按字段保留已有 AI 数据,防止 INSERT OR REPLACE 覆盖分析结果 + 修复: CategorySidebar/RepositoryEditModal/ReleaseTimeline 操作失败时的回滚和补偿逻辑 + 修复: SubscriptionRepoCard 中 existingRepo 可能为 undefined 的崩溃隐患 + + https://github.com/AmintaCCCP/GithubStarsManager/releases/download/v0.6.1/github-stars-manager-0.6.1.dmg + From 6320ec21e2c628291e7048effd236de509acf257 Mon Sep 17 00:00:00 2001 From: lostiv Date: Mon, 11 May 2026 17:58:59 +0800 Subject: [PATCH 27/47] =?UTF-8?q?@=20fix:=20AI=E5=88=86=E6=9E=90=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E9=A1=B5=E9=9D=A2=E5=90=8E=E8=BF=9B=E5=BA=A6=E6=9D=A1?= =?UTF-8?q?=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resumeBatchAnalysis 恢复状态时缺少 setLoading(true), 导致进度条 UI 条件 isLoading && analysisProgress.total > 0 为 false。 同时将 resumeBatchAnalysis 提前到 syncFromBackend 之前调用, 避免同步期间批次完成而错过恢复窗口。 @ --- src/App.tsx | 9 +++++---- src/services/backendAnalysisService.ts | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 11cb348..aaab2ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -85,13 +85,14 @@ function App() { try { await backend.init(); if (backend.isAvailable && !cancelled) { - await syncFromBackend(); + // Resume any in-progress analysis before syncing data, + // so the batch doesn't have time to complete during the sync window. if (!cancelled) { - unsubscribe = startAutoSync(); + void backendAnalysis.resumeBatchAnalysis(); } - // Reconnect to any analysis batch that was running before page refresh + await syncFromBackend(); if (!cancelled) { - void backendAnalysis.resumeBatchAnalysis(); + unsubscribe = startAutoSync(); } } } catch (err) { diff --git a/src/services/backendAnalysisService.ts b/src/services/backendAnalysisService.ts index beea3e6..8cac497 100644 --- a/src/services/backendAnalysisService.ts +++ b/src/services/backendAnalysisService.ts @@ -217,6 +217,7 @@ class BackendAnalysisService { for (const id of saved.repositoryIds) { store.setAnalyzingRepository(id, true); } + store.setLoading(true); store.setAnalysisProgress({ current: active.completed + active.failed, total: active.total }); this._isRunning = true; @@ -261,6 +262,8 @@ class BackendAnalysisService { } } + store.setLoading(false); + store.setAnalysisProgress({ current: 0, total: 0 }); onComplete?.(); return; } From a2f11c5999b9fd39885b6f97718a99517a284de2 Mon Sep 17 00:00:00 2001 From: lostiv <30612717+lostiv@users.noreply.github.com> Date: Mon, 11 May 2026 23:35:36 +0800 Subject: [PATCH 28/47] =?UTF-8?q?@=20@=20fix:=20=E4=BF=AE=E5=A4=8D=20AI=20?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=81=9C=E6=AD=A2=E6=8C=89=E9=92=AE=E6=97=A0?= =?UTF-8?q?=E6=95=88=E3=80=81=E5=88=86=E7=B1=BB=E6=97=A0=E6=B3=95=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E3=80=81=E8=BD=AE=E8=AF=A2=E6=97=A0=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 停止按钮:页面刷新后 isAnalyzingRef 为 false 导致 handleStop 直接返回, 改用 backendAnalysis.isRunning 作为额外判断条件 - cancelBatchAnalysis:立即清理本地状态而非等待轮询检测 - 轮询保护:增加 404 检测、连续失败 5 次、超时 10 分钟三层退出机制 防止后端重启后前端永久转圈 - 分类匹配:custom_category 为 null 时 != null 判断为 false, 正确穿透到 AI 标签匹配逻辑 @ --- src/components/BulkRestoreModal.tsx | 2 +- src/components/RepositoryList.tsx | 8 ++- src/services/backendAnalysisService.ts | 89 ++++++++++++++++++++++---- src/utils/categoryUtils.ts | 2 +- 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/components/BulkRestoreModal.tsx b/src/components/BulkRestoreModal.tsx index 691974c..e729589 100644 --- a/src/components/BulkRestoreModal.tsx +++ b/src/components/BulkRestoreModal.tsx @@ -70,7 +70,7 @@ export const BulkRestoreModal: React.FC = ({ for (const repo of repositories) { if (repo.custom_description !== undefined && repo.custom_description !== null) hasCustomDesc++; if (repo.custom_tags !== undefined) hasCustomTags++; - if (repo.custom_category !== undefined && repo.custom_category !== '') hasCustomCategory++; + if (repo.custom_category != null && repo.custom_category !== '') hasCustomCategory++; if (repo.ai_summary && repo.ai_summary.trim() !== '') hasAiSummary++; if (repo.ai_tags && repo.ai_tags.length > 0) hasAiTags++; if (getAICategory(repo, allCategories) !== '') hasAiCategory++; diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx index 7fa2e88..fb96493 100644 --- a/src/components/RepositoryList.tsx +++ b/src/components/RepositoryList.tsx @@ -78,7 +78,7 @@ export const RepositoryList: React.FC = ({ if (!selectedCategoryObj) return []; return repositories.filter(repo => { - if (repo.custom_category !== undefined) { + if (repo.custom_category != null) { if (repo.custom_category === '') { return false; } @@ -490,7 +490,8 @@ export const RepositoryList: React.FC = ({ }; const handleStop = async () => { - if (!isAnalyzingRef.current) return; + // 页面刷新后 isAnalyzingRef 重置为 false, 但后端批次可能正在运行 + if (!isAnalyzingRef.current && !backendAnalysis.isRunning) return; const confirmMessage = language === 'zh' ? '确定要停止 AI 分析吗?已分析的结果将会保存。' @@ -506,6 +507,9 @@ export const RepositoryList: React.FC = ({ shouldStopRef.current = true; backendAnalysis.cancelBatchAnalysis(); setIsPaused(false); + // 立即清理 UI 状态,不等轮询检测到 cancelled + setLoading(false); + setAnalysisProgress({ current: 0, total: 0 }); return; } diff --git a/src/services/backendAnalysisService.ts b/src/services/backendAnalysisService.ts index 8cac497..03a5b99 100644 --- a/src/services/backendAnalysisService.ts +++ b/src/services/backendAnalysisService.ts @@ -75,6 +75,10 @@ class BackendAnalysisService { return new Promise((resolve) => { let lastReportedCount = 0; + let consecutiveFailures = 0; + const MAX_CONSECUTIVE_FAILURES = 5; + const MAX_POLL_TIME_MS = 10 * 60 * 1000; // 10分钟后强制退出 + const startTime = Date.now(); const poll = async (): Promise => { // Guard: if a newer batch has replaced this one, silently exit @@ -86,6 +90,8 @@ class BackendAnalysisService { // Re-check after await — another batch may have been started if (this.currentBatchId !== batchId) return; + consecutiveFailures = 0; // 成功后重置 + store.setAnalysisProgress({ current: progress.completed + progress.failed, total: progress.total }); onProgress?.(progress.completed + progress.failed, progress.total); @@ -128,8 +134,27 @@ class BackendAnalysisService { resolve(); return; } - } catch { - // Poll errors are non-fatal — keep polling + } catch (err) { + consecutiveFailures++; + + // 批次已被后端清理(如重启后丢失),视为已完成 + const errMsg = err instanceof Error ? err.message : String(err); + if (errMsg.includes('404') || errMsg.includes('BATCH_NOT_FOUND') || errMsg.includes('Batch not found')) { + this.finishBatch(batchId, repositoryIds); + removeBatch(batchId); + store.setAnalysisProgress({ current: 0, total: 0 }); + resolve(); // 调用方的 finally 块会处理 UI 清理 + return; + } + + // 连续失败或超时,强制退出防止永久转圈 + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES || Date.now() - startTime > MAX_POLL_TIME_MS) { + this.finishBatch(batchId, repositoryIds); + removeBatch(batchId); + store.setAnalysisProgress({ current: 0, total: 0 }); + resolve(); // 调用方的 finally 块会处理 UI 清理 + return; + } } // Schedule next poll only if still the current batch @@ -149,14 +174,28 @@ class BackendAnalysisService { } async cancelBatchAnalysis(): Promise { - if (this.currentBatchId) { - try { - await backend.cancelAnalysis(this.currentBatchId); - } catch { - // Non-fatal - } - // Don't call finishBatch — the poll will detect 'cancelled' and clean up + const batchId = this.currentBatchId; + if (!batchId) return; + + // 从 localStorage 中获取该批次的仓库 ID 以清理 analyzing 状态 + const savedBatches = loadSavedBatches(); + const saved = savedBatches.find((b) => b.batchId === batchId); + const repositoryIds = saved?.repositoryIds ?? []; + + // 先向服务端发送取消请求 + try { + await backend.cancelAnalysis(batchId); + } catch { + // Non-fatal — 即使服务端请求失败也要清理本地状态 } + + // 立即清理本地状态 + this.finishBatch(batchId, repositoryIds); + removeBatch(batchId); + + const store = useAppStore.getState(); + store.setLoading(false); + store.setAnalysisProgress({ current: 0, total: 0 }); } get isRunning(): boolean { @@ -223,6 +262,11 @@ class BackendAnalysisService { this._isRunning = true; this.currentBatchId = active.batchId; + let consecutiveFailures = 0; + const MAX_CONSECUTIVE_FAILURES = 5; + const MAX_POLL_TIME_MS = 10 * 60 * 1000; // 10分钟后强制退出 + const startTime = Date.now(); + const poll = async (): Promise => { if (this.currentBatchId !== active.batchId) return; @@ -230,6 +274,8 @@ class BackendAnalysisService { const progress = await backend.getAnalysisProgress(active.batchId); if (this.currentBatchId !== active.batchId) return; + consecutiveFailures = 0; // 成功后重置 + store.setAnalysisProgress({ current: progress.completed + progress.failed, total: progress.total }); onProgress?.(progress.completed + progress.failed, progress.total); @@ -267,8 +313,29 @@ class BackendAnalysisService { onComplete?.(); return; } - } catch { - // Poll errors are non-fatal + } catch (err) { + consecutiveFailures++; + + // 批次已被后端清理(如重启后丢失),视为已完成 + const errMsg = err instanceof Error ? err.message : String(err); + if (errMsg.includes('404') || errMsg.includes('BATCH_NOT_FOUND') || errMsg.includes('Batch not found')) { + this.finishBatch(active.batchId, saved.repositoryIds); + removeBatch(active.batchId); + store.setLoading(false); + store.setAnalysisProgress({ current: 0, total: 0 }); + onComplete?.(); + return; + } + + // 连续失败或超时,强制退出防止永久转圈 + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES || Date.now() - startTime > MAX_POLL_TIME_MS) { + this.finishBatch(active.batchId, saved.repositoryIds); + removeBatch(active.batchId); + store.setLoading(false); + store.setAnalysisProgress({ current: 0, total: 0 }); + onComplete?.(); + return; + } } if (this.currentBatchId === active.batchId) { diff --git a/src/utils/categoryUtils.ts b/src/utils/categoryUtils.ts index d36a909..9d35ca8 100644 --- a/src/utils/categoryUtils.ts +++ b/src/utils/categoryUtils.ts @@ -83,7 +83,7 @@ export const computeCustomCategory = ( export const matchesCategory = (repo: Repository, category: Category): boolean => { if (category.id === 'all') return true; - if (repo.custom_category !== undefined) { + if (repo.custom_category != null) { if (repo.custom_category === '') { return false; } From f63581d2f803185f95ed152ff39df4dc026b90d1 Mon Sep 17 00:00:00 2001 From: lostiv <30612717+lostiv@users.noreply.github.com> Date: Tue, 12 May 2026 01:07:43 +0800 Subject: [PATCH 29/47] =?UTF-8?q?@=20refactor:=20Star/Unstar=E3=80=81Fork?= =?UTF-8?q?=E3=80=81=E7=BF=BB=E8=AF=91=E6=93=8D=E4=BD=9C=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "后端为唯一数据源"架构整改: - Star/Unstar: 4处调用点改用 backend 代理 - Fork: 6种操作 (getUserForks/checkForkSyncNeeded/getBranches/ getRepositoryWorkflows/syncFork/triggerWorkflowRun) 迁移到后端 - 翻译: MS auth token 管理移到后端,前端仅保留 chunking/retry - 同步按钮: 优先使用 backend.fetchStarredRepos 代理 - 后端通配符代理支持 body 转发 @ --- package-lock.json | 4 +- server/src/routes/proxy.ts | 98 ++++++++- src/components/ForkTimeline.tsx | 42 ++-- src/components/Header.tsx | 70 +++++- src/components/RepositoryCard.tsx | 13 +- src/components/RepositoryList.tsx | 7 +- src/components/SubscriptionRepoCard.tsx | 32 ++- src/services/backendAdapter.ts | 206 +++++++++++++++++- src/services/translateService.ts | 278 +++--------------------- src/types/index.ts | 5 + 10 files changed, 439 insertions(+), 316 deletions(-) diff --git a/package-lock.json b/package-lock.json index 357c212..afda07a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-stars-manager", - "version": "0.5.7", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-stars-manager", - "version": "0.5.7", + "version": "0.6.1", "dependencies": { "@types/query-string": "^6.2.0", "date-fns": "^3.3.1", diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index cb62bcf..5e71a29 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -67,17 +67,17 @@ router.post('/api/proxy/github/*', async (req, res) => { const queryString = new URL(req.url, 'http://localhost').search; const targetUrl = `https://api.github.com/${githubPath}${queryString}`; - const body = req.body as { method?: string; headers?: Record }; - const method = body.method || 'GET'; - + const proxyBody = req.body as { method?: string; headers?: Record; body?: unknown }; + const method = proxyBody.method || 'GET'; + const headers: Record = { 'Authorization': `Bearer ${token}`, - 'Accept': body.headers?.Accept || 'application/vnd.github.v3+json', + 'Accept': proxyBody.headers?.Accept || 'application/vnd.github.v3+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'GithubStarsManager-Backend', }; - const result = await proxyRequest({ url: targetUrl, method, headers }); + const result = await proxyRequest({ url: targetUrl, method, headers, body: proxyBody.body as string | object | undefined }); res.status(result.status).json(result.data); } catch (err) { console.error('GitHub proxy error:', err); @@ -297,4 +297,92 @@ router.post('/api/proxy/github/search/users', async (req, res) => { } }); +// === Microsoft Translator Proxy === + +// MS auth token 在进程内存中缓存 +let msTranslateToken: string | null = null; +let msTranslateTokenExpiresAt = 0; +const MS_AUTH_URL = 'https://edge.microsoft.com/translate/auth'; +const MS_TRANSLATE_URL = 'https://api-edge.cognitive.microsofttranslator.com/translate'; + +async function getMsTranslateToken(): Promise { + const now = Date.now(); + const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; + + if (msTranslateToken && now < msTranslateTokenExpiresAt - TOKEN_REFRESH_BUFFER_MS) { + return msTranslateToken; + } + + const res = await fetch(MS_AUTH_URL, { method: 'GET' }); + if (!res.ok) throw new Error(`MS auth failed: ${res.status}`); + const token = await res.text(); + + // 解析 JWT 获取过期时间 + try { + const parts = token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8')); + if (payload.exp) { + msTranslateTokenExpiresAt = payload.exp * 1000; + } + } + } catch { /* 解析失败则用默认 8 分钟 */ } + + if (msTranslateTokenExpiresAt <= now) { + msTranslateTokenExpiresAt = now + 8 * 60 * 1000; + } + + msTranslateToken = token; + return token; +} + +router.post('/api/proxy/translate', async (req, res) => { + try { + const { texts, to, from, textType } = req.body as { + texts: string[]; + to: string; + from?: string; + textType?: string; + }; + + if (!texts || !Array.isArray(texts) || texts.length === 0 || !to) { + res.status(400).json({ error: 'texts and to are required', code: 'TRANSLATE_PARAMS_REQUIRED' }); + return; + } + + const token = await getMsTranslateToken(); + + const params = new URLSearchParams({ 'api-version': '3.0', to }); + if (from) params.set('from', from); + if (textType === 'html') params.set('textType', 'html'); + + const targetUrl = `${MS_TRANSLATE_URL}?${params.toString()}`; + + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + + const body = texts.map(t => ({ Text: t })); + + const result = await proxyRequest({ url: targetUrl, method: 'POST', headers, body }); + + if (result.status === 401) { + // Token 过期,清除缓存后重试一次 + msTranslateToken = null; + msTranslateTokenExpiresAt = 0; + const newToken = await getMsTranslateToken(); + headers['Authorization'] = `Bearer ${newToken}`; + const retryResult = await proxyRequest({ url: targetUrl, method: 'POST', headers, body }); + res.status(retryResult.status).json(retryResult.data); + return; + } + + res.status(result.status).json(result.data); + } catch (err) { + console.error('Translation proxy error:', err); + res.status(500).json({ error: 'Translation proxy failed', code: 'TRANSLATION_PROXY_FAILED' }); + } +}); + export default router; diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx index 622f654..e541f4f 100644 --- a/src/components/ForkTimeline.tsx +++ b/src/components/ForkTimeline.tsx @@ -2,7 +2,7 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { Package, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, GitFork } from 'lucide-react'; import { ForkRepo, WorkflowDefinition } from '../types'; import { useAppStore } from '../store/useAppStore'; -import { GitHubApiService } from '../services/githubApi'; +import { backend } from '../services/backendAdapter'; import { formatDistanceToNow } from 'date-fns'; import ForkCard from './ForkCard'; import { useDialog } from '../hooks/useDialog'; @@ -12,7 +12,6 @@ export const ForkTimeline: React.FC = () => { const { forks, readForks, - githubToken, language, setForks, addForks, @@ -153,15 +152,14 @@ export const ForkTimeline: React.FC = () => { }; const handleRefresh = async () => { - if (!githubToken) { - toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); + if (!backend.isAvailable) { + toast(language === 'zh' ? '后端服务未连接,请检查后端状态。' : 'Backend service not connected. Please check the backend status.', 'error'); return; } setForkIsRefreshing(true); try { - const githubApi = new GitHubApiService(githubToken); - const newForks = await githubApi.getUserForks(); + const newForks = await backend.getUserForks(); // Merge with existing forks, preserving read status const existingForkMap = new Map(forks.map(f => [f.id, f])); @@ -223,7 +221,7 @@ export const ForkTimeline: React.FC = () => { const [owner, repo] = fork.full_name.split('/'); const branch = fork.default_branch || 'main'; try { - const result = await githubApi.checkForkSyncNeeded( + const result = await backend.checkForkSyncNeeded( owner, repo, branch, @@ -294,13 +292,12 @@ export const ForkTimeline: React.FC = () => { const loadWorkflows = async (forkId: number) => { const fork = forks.find(f => f.id === forkId); - if (!fork || !githubToken) return; + if (!fork || !backend.isAvailable) return; setLoadingWorkflows(prev => new Set(prev).add(forkId)); try { const [owner, repo] = fork.full_name.split('/'); - const githubApi = new GitHubApiService(githubToken); - const workflows = await githubApi.getRepositoryWorkflows(owner, repo); + const workflows = await backend.getRepositoryWorkflows(owner, repo); setWorkflowsMap(prev => ({ ...prev, [forkId]: workflows })); } catch (error) { console.error('Failed to load workflows:', error); @@ -314,14 +311,14 @@ export const ForkTimeline: React.FC = () => { }; const handleSyncUpstream = async (fork: ForkRepo) => { - if (!githubToken) { - toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); + if (!backend.isAvailable) { + toast(language === 'zh' ? '后端服务未连接,请检查后端状态。' : 'Backend service not connected. Please check the backend status.', 'error'); return; } const defaultBranch = fork.default_branch || 'main'; const [owner, repo] = fork.full_name.split('/'); - + setSyncModal({ isOpen: true, forkId: fork.id, @@ -332,10 +329,9 @@ export const ForkTimeline: React.FC = () => { }); setSyncModalBranches([]); setIsFetchingBranches(true); - + try { - const githubApi = new GitHubApiService(githubToken); - const branches = await githubApi.getBranches(owner, repo); + const branches = await backend.getBranches(owner, repo); setSyncModalBranches(branches); if (branches.length > 0 && !branches.includes(defaultBranch)) { setSyncModal(prev => ({ ...prev, branch: branches[0] })); @@ -346,7 +342,7 @@ export const ForkTimeline: React.FC = () => { }; const confirmSyncUpstream = async () => { - if (!githubToken || !syncModal.forkId) return; + if (!backend.isAvailable || !syncModal.forkId) return; const fork = forks.find(f => f.id === syncModal.forkId); if (!fork) return; @@ -355,10 +351,9 @@ export const ForkTimeline: React.FC = () => { setSyncModal(prev => ({ ...prev, isOpen: false })); setSyncingForks(prev => new Set(prev).add(fork.id)); - + try { - const githubApi = new GitHubApiService(githubToken); - const result = await githubApi.syncFork(owner, repo, branch); + const result = await backend.syncFork(owner, repo, branch); // Mark fork as up-to-date in UI setNeedsSyncMap(prev => ({ ...prev, [fork.id]: false })); @@ -408,8 +403,8 @@ export const ForkTimeline: React.FC = () => { }; const handleRunWorkflow = async (forkId: number, workflowPath: string, workflowName: string) => { - if (!githubToken) { - toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); + if (!backend.isAvailable) { + toast(language === 'zh' ? '后端服务未连接,请检查后端状态。' : 'Backend service not connected. Please check the backend status.', 'error'); return; } @@ -420,8 +415,7 @@ export const ForkTimeline: React.FC = () => { setRunningWorkflows(prev => new Set(prev).add(forkId)); try { const [owner, repo] = fork.full_name.split('/'); - const githubApi = new GitHubApiService(githubToken); - await githubApi.triggerWorkflowRun(owner, repo, workflowPath, branch); + await backend.triggerWorkflowRun(owner, repo, workflowPath, branch); toast(language === 'zh' ? `已触发工作流 "${workflowName}" 在 ${branch} 分支。` diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 852b40b..5dcb48e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,7 +1,9 @@ import React, { useState, useRef, useEffect } from 'react'; import { Settings, Calendar, Search, Moon, Sun, LogOut, RefreshCw, TrendingUp, GitFork } from 'lucide-react'; import { useAppStore } from '../store/useAppStore'; +import { Repository } from '../types'; import { GitHubApiService } from '../services/githubApi'; +import { backend } from '../services/backendAdapter'; import { useDialog } from '../hooks/useDialog'; export const Header: React.FC = () => { @@ -65,6 +67,67 @@ export const Header: React.FC = () => { }, []); const handleSync = async () => { + // 优先通过后端代理同步,确保后端为唯一数据源 + if (backend.isAvailable) { + setLoading(true); + try { + // 分页拉取所有星标仓库(通过后端代理,Token 存储在后端) + const allStarred: Repository[] = []; + let page = 1; + const perPage = 100; + while (true) { + const repos = await backend.fetchStarredRepos(page, perPage); + allStarred.push(...repos); + if (repos.length < perPage) break; + page++; + } + + // 合并已有数据,保留 AI 分析结果和自定义字段 + const existingRepoMap = new Map(repositories.map(repo => [repo.id, repo])); + const merged = allStarred.map(newRepo => { + const existing = existingRepoMap.get(newRepo.id); + if (existing) { + return { + ...newRepo, + has_fetched_releases: existing.has_fetched_releases, + last_release_fetch_time: existing.last_release_fetch_time, + ai_summary: existing.ai_summary, + ai_tags: existing.ai_tags, + ai_platforms: existing.ai_platforms, + analyzed_at: existing.analyzed_at, + analysis_failed: existing.analysis_failed, + custom_description: existing.custom_description, + custom_tags: existing.custom_tags, + custom_category: existing.custom_category, + category_locked: existing.category_locked, + last_edited: existing.last_edited, + }; + } + return newRepo; + }); + + // 全量同步到后端数据库 + await backend.syncRepositories(merged, true); + + setRepositories(merged); + setLastSync(new Date().toISOString()); + + const newRepoCount = allStarred.length - repositories.length; + if (newRepoCount > 0) { + toast(t(`同步完成!发现 ${newRepoCount} 个新仓库。`, `Sync completed! Found ${newRepoCount} new repositories.`), 'success'); + } else { + toast(t('同步完成!所有仓库都是最新的。', 'Sync completed! All repositories are up to date.'), 'info'); + } + } catch (error) { + console.error('Backend sync failed:', error); + toast(t('同步失败,请检查后端连接。', 'Sync failed. Please check backend connection.'), 'error'); + } finally { + setLoading(false); + } + return; + } + + // 后端不可用时走前端直接调用 GitHub API(降级方案) if (!githubToken) { toast(t('GitHub token 未找到,请重新登录。', 'GitHub token not found. Please login again.'), 'error'); return; @@ -73,7 +136,6 @@ export const Header: React.FC = () => { setLoading(true); try { const githubApi = new GitHubApiService(githubToken); - const newRepositories = await githubApi.getAllStarredRepositories(); const existingRepoMap = new Map(repositories.map(repo => [repo.id, repo])); @@ -100,14 +162,8 @@ export const Header: React.FC = () => { }); setRepositories(mergedRepositories); - - // Note: Release fetching is now handled by the Refresh button in Release Timeline - // Header sync only syncs the starred repos list - setLastSync(new Date().toISOString()); - console.log('Sync completed successfully'); - // 显示同步结果 const newRepoCount = newRepositories.length - repositories.length; if (newRepoCount > 0) { toast(t(`同步完成!发现 ${newRepoCount} 个新仓库。`, `Sync completed! Found ${newRepoCount} new repositories.`), 'success'); diff --git a/src/components/RepositoryCard.tsx b/src/components/RepositoryCard.tsx index 490168b..cdd0be0 100644 --- a/src/components/RepositoryCard.tsx +++ b/src/components/RepositoryCard.tsx @@ -7,7 +7,7 @@ import { getAICategory, getDefaultCategory } from '../utils/categoryUtils'; import { analyzeRepository, createFailedAnalysisResult } from '../services/aiAnalysisHelper'; import { backend } from '../services/backendAdapter'; import { backendAnalysis } from '../services/backendAnalysisService'; -import { GitHubApiService } from '../services/githubApi'; + import { formatDistanceToNow } from 'date-fns'; import { RepositoryEditModal } from './RepositoryEditModal'; import { ReadmeModal } from './ReadmeModal'; @@ -559,8 +559,8 @@ const RepositoryCardComponent: React.FC = ({ const t = (zh: string, en: string) => language === 'zh' ? zh : en; const handleUnstar = async () => { - if (!githubToken) { - toast(t('未找到 GitHub Token,请重新登录。', 'GitHub token not found. Please login again.'), 'error'); + if (!backend.isAvailable) { + toast(t('后端服务未连接,请检查后端状态。', 'Backend service not connected. Please check the backend status.'), 'error'); return; } @@ -574,9 +574,8 @@ const RepositoryCardComponent: React.FC = ({ setUnstarring(true); try { - const githubApi = new GitHubApiService(githubToken); const [owner, repo] = repository.full_name.split('/'); - await githubApi.unstarRepository(owner, repo); + await backend.unstarRepository(owner, repo); deleteRepository(repository.id); if (backend.isAvailable) { backend.deleteRepository(repository.id).catch(() => { /* non-critical */ }); @@ -588,8 +587,8 @@ const RepositoryCardComponent: React.FC = ({ } catch (error) { console.error('Failed to unstar repository:', error); const errorMessage = language === 'zh' - ? '取消 Star 失败,请检查网络连接或重新登录。' - : 'Failed to unstar repository. Please check your network connection or login again.'; + ? '取消 Star 失败,请检查后端服务或网络连接。' + : 'Failed to unstar repository. Please check the backend service or network connection.'; toast(errorMessage, 'error'); } finally { setUnstarring(false); diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx index fb96493..203c84c 100644 --- a/src/components/RepositoryList.tsx +++ b/src/components/RepositoryList.tsx @@ -657,8 +657,8 @@ export const RepositoryList: React.FC = ({ try { switch (action) { case 'unstar': { - if (!githubToken) { - toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); + if (!backend.isAvailable) { + toast(language === 'zh' ? '后端服务未连接,请检查后端状态。' : 'Backend service not connected. Please check the backend status.', 'error'); return; } @@ -673,13 +673,12 @@ export const RepositoryList: React.FC = ({ ); if (!confirmed) return; - const githubApi = new GitHubApiService(githubToken); const successIds: number[] = []; for (const repo of repos) { try { const [owner, name] = repo.full_name.split('/'); - await githubApi.unstarRepository(owner, name); + await backend.unstarRepository(owner, name); successIds.push(repo.id); } catch (error) { console.error(`Failed to unstar ${repo.full_name}:`, error); diff --git a/src/components/SubscriptionRepoCard.tsx b/src/components/SubscriptionRepoCard.tsx index a8dee00..995ceb6 100644 --- a/src/components/SubscriptionRepoCard.tsx +++ b/src/components/SubscriptionRepoCard.tsx @@ -98,18 +98,17 @@ export const SubscriptionRepoCard: React.FC = ({ repo // 执行取消Star操作 const executeUnstar = useCallback(async () => { - if (!githubToken) return; - + if (!backend.isAvailable) return; + setIsStarring(true); - + try { - const githubApi = new GitHubApiService(githubToken); const [owner, name] = repo.full_name.split('/'); - + // 乐观更新:立即更新UI状态 setOptimisticStarred(false); - - await githubApi.unstarRepository(owner, name); + + await backend.unstarRepository(owner, name); // 从本地删除 const existingRepo = repositories.find(r => r.full_name === repo.full_name); @@ -126,18 +125,18 @@ export const SubscriptionRepoCard: React.FC = ({ repo // 操作失败,回滚乐观状态 setOptimisticStarred(null); console.error('Failed to unstar repository:', error); - const errorMessage = t('取消 Star 失败,请检查网络连接或 GitHub Token 权限。', 'Failed to unstar repository. Please check your network connection or GitHub Token permissions.'); + const errorMessage = t('取消 Star 失败,请检查后端服务或网络连接。', 'Failed to unstar repository. Please check the backend service or network connection.'); toast(errorMessage, 'error'); } finally { setIsStarring(false); setPendingUnstarAction(null); } - }, [githubToken, repo, repositories, deleteRepository, t]); + }, [repo, repositories, deleteRepository, t]); // 处理添加/取消Star const handleStar = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); - if (!githubToken || isStarring) return; + if (!backend.isAvailable || isStarring) return; if (isStarred) { // 取消Star - 显示自定义确认对话框 @@ -150,13 +149,12 @@ export const SubscriptionRepoCard: React.FC = ({ repo setIsStarring(true); try { - const githubApi = new GitHubApiService(githubToken); const [owner, name] = repo.full_name.split('/'); - + // 乐观更新:立即更新UI状态 setOptimisticStarred(true); - - await githubApi.starRepository(owner, name); + + await backend.starRepository(owner, name); // 将DiscoveryRepo转换为Repository并添加到本地,保留AI分析结果 const repositoryToAdd = { @@ -187,12 +185,12 @@ export const SubscriptionRepoCard: React.FC = ({ repo // 操作失败,回滚乐观状态 setOptimisticStarred(null); console.error('Failed to star repository:', error); - const errorMessage = t('Star 操作失败,请检查网络连接或 GitHub Token 权限。', 'Failed to star repository. Please check your network connection or GitHub Token permissions.'); + const errorMessage = t('Star 操作失败,请检查后端服务或网络连接。', 'Failed to star repository. Please check the backend service or network connection.'); toast(errorMessage, 'error'); } finally { setIsStarring(false); } - }, [githubToken, isStarring, repo, onStar, t, isStarred, addRepository, executeUnstar]); + }, [isStarring, repo, onStar, t, isStarred, addRepository, executeUnstar]); // 处理在ZRead打开 const handleOpenInZRead = useCallback((e: React.MouseEvent) => { @@ -439,7 +437,7 @@ export const SubscriptionRepoCard: React.FC = ({ repo {/* Star button */}