From a0c1bdbbd6da922bd862361ee36f0be6c7c34ab7 Mon Sep 17 00:00:00 2001 From: "F.Rogers" <1816524875@qq.com> Date: Fri, 14 Nov 2025 09:48:03 +0800 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BC=80?= =?UTF-8?q?=E6=9C=BA=E8=87=AA=E5=90=AF=E5=8A=A8=E9=80=89=E9=A1=B9=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E9=97=AA=E7=83=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 autoStartEnabled 初始值从 false 改为 true,与其他设置保持一致,避免在异步加载期间出现从关闭到打开的视觉跳变 --- frontend/src/components/General/Index.vue | 6 +++--- version_service.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/General/Index.vue b/frontend/src/components/General/Index.vue index d51b0f3..8b8963b 100644 --- a/frontend/src/components/General/Index.vue +++ b/frontend/src/components/General/Index.vue @@ -9,7 +9,7 @@ import { fetchAppSettings, saveAppSettings, type AppSettings } from '../../servi const router = useRouter() const heatmapEnabled = ref(true) const homeTitleVisible = ref(true) -const autoStartEnabled = ref(false) +const autoStartEnabled = ref(true) const settingsLoading = ref(true) const saveBusy = ref(false) @@ -23,12 +23,12 @@ const loadAppSettings = async () => { const data = await fetchAppSettings() heatmapEnabled.value = data?.show_heatmap ?? true homeTitleVisible.value = data?.show_home_title ?? true - autoStartEnabled.value = data?.auto_start ?? false + autoStartEnabled.value = data?.auto_start ?? true } catch (error) { console.error('failed to load app settings', error) heatmapEnabled.value = true homeTitleVisible.value = true - autoStartEnabled.value = false + autoStartEnabled.value = true } finally { settingsLoading.value = false } diff --git a/version_service.go b/version_service.go index 935d13e..45eefcb 100644 --- a/version_service.go +++ b/version_service.go @@ -1,6 +1,6 @@ package main -const AppVersion = "v0.2.3" +const AppVersion = "v0.2.4" type VersionService struct { version string From defaecb77007b3fa187cd663955d98d0ed5574ab Mon Sep 17 00:00:00 2001 From: "F.Rogers" <1816524875@qq.com> Date: Fri, 14 Nov 2025 09:51:05 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=20localStorage?= =?UTF-8?q?=20=E7=BC=93=E5=AD=98=E8=AE=BE=E7=BD=AE=E7=8A=B6=E6=80=81?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A7=86=E8=A7=89=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 localStorage 读取缓存值作为初始值 - 加载和保存设置后更新缓存 - 实现"记住状态"效果,打开设置时直接显示正确状态 - 版本更新至 v0.2.4 --- frontend/src/components/General/Index.vue | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/General/Index.vue b/frontend/src/components/General/Index.vue index 8b8963b..5350e4c 100644 --- a/frontend/src/components/General/Index.vue +++ b/frontend/src/components/General/Index.vue @@ -7,9 +7,14 @@ import ThemeSetting from '../Setting/ThemeSetting.vue' import { fetchAppSettings, saveAppSettings, type AppSettings } from '../../services/appSettings' const router = useRouter() -const heatmapEnabled = ref(true) -const homeTitleVisible = ref(true) -const autoStartEnabled = ref(true) +// 从 localStorage 读取缓存值作为初始值,避免加载时的视觉闪烁 +const getCachedValue = (key: string, defaultValue: boolean): boolean => { + const cached = localStorage.getItem(`app-settings-${key}`) + return cached !== null ? cached === 'true' : defaultValue +} +const heatmapEnabled = ref(getCachedValue('heatmap', true)) +const homeTitleVisible = ref(getCachedValue('homeTitle', true)) +const autoStartEnabled = ref(getCachedValue('autoStart', false)) const settingsLoading = ref(true) const saveBusy = ref(false) @@ -23,12 +28,17 @@ const loadAppSettings = async () => { const data = await fetchAppSettings() heatmapEnabled.value = data?.show_heatmap ?? true homeTitleVisible.value = data?.show_home_title ?? true - autoStartEnabled.value = data?.auto_start ?? true + autoStartEnabled.value = data?.auto_start ?? false + + // 缓存到 localStorage,下次打开时直接显示正确状态 + localStorage.setItem('app-settings-heatmap', String(heatmapEnabled.value)) + localStorage.setItem('app-settings-homeTitle', String(homeTitleVisible.value)) + localStorage.setItem('app-settings-autoStart', String(autoStartEnabled.value)) } catch (error) { console.error('failed to load app settings', error) heatmapEnabled.value = true homeTitleVisible.value = true - autoStartEnabled.value = true + autoStartEnabled.value = false } finally { settingsLoading.value = false } @@ -44,6 +54,12 @@ const persistAppSettings = async () => { auto_start: autoStartEnabled.value, } await saveAppSettings(payload) + + // 更新缓存 + localStorage.setItem('app-settings-heatmap', String(heatmapEnabled.value)) + localStorage.setItem('app-settings-homeTitle', String(homeTitleVisible.value)) + localStorage.setItem('app-settings-autoStart', String(autoStartEnabled.value)) + window.dispatchEvent(new CustomEvent('app-settings-updated')) } catch (error) { console.error('failed to save app settings', error) From 4703c410f44995380768e5eef58013c44d50635b Mon Sep 17 00:00:00 2001 From: "F.Rogers" <1816524875@qq.com> Date: Fri, 14 Nov 2025 11:19:50 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 UpdateService 后端服务,支持 GitHub Release 版本检查 - 每天 8:00 AM 自动检查更新,网络失败时自动重试 3 次 - 实现智能重启机制,下载更新后在下次启动时应用 - 设置页面新增"应用更新"板块,可手动检查和下载更新 - 主页 GitHub 图标显示更新状态徽章(New/下载进度/Ready) - 支持自动更新开关,状态持久化到本地配置 - 新增版本号管理服务,当前版本 v0.2.5 - 完善中英文国际化翻译 Co-Authored-By: Half open flowers <1816524875@qq.com> --- frontend/src/components/General/Index.vue | 150 ++++- frontend/src/components/Main/Index.vue | 70 ++- frontend/src/locales/en.json | 16 +- frontend/src/locales/zh.json | 14 + frontend/src/services/appSettings.ts | 2 + frontend/src/services/update.ts | 39 ++ frontend/src/style.css | 47 ++ go.mod | 1 + go.sum | 2 + main.go | 19 + services/appsettings.go | 2 + services/updateservice.go | 685 ++++++++++++++++++++++ version_service.go | 2 +- 13 files changed, 1037 insertions(+), 12 deletions(-) create mode 100644 frontend/src/services/update.ts create mode 100644 services/updateservice.go diff --git a/frontend/src/components/General/Index.vue b/frontend/src/components/General/Index.vue index 5350e4c..a86e05d 100644 --- a/frontend/src/components/General/Index.vue +++ b/frontend/src/components/General/Index.vue @@ -5,6 +5,8 @@ import ListItem from '../Setting/ListRow.vue' import LanguageSwitcher from '../Setting/LanguageSwitcher.vue' import ThemeSetting from '../Setting/ThemeSetting.vue' import { fetchAppSettings, saveAppSettings, type AppSettings } from '../../services/appSettings' +import { checkUpdate, downloadUpdate, restartApp, getUpdateState, setAutoCheckEnabled, type UpdateState } from '../../services/update' +import { fetchCurrentVersion } from '../../services/version' const router = useRouter() // 从 localStorage 读取缓存值作为初始值,避免加载时的视觉闪烁 @@ -15,9 +17,16 @@ const getCachedValue = (key: string, defaultValue: boolean): boolean => { const heatmapEnabled = ref(getCachedValue('heatmap', true)) const homeTitleVisible = ref(getCachedValue('homeTitle', true)) const autoStartEnabled = ref(getCachedValue('autoStart', false)) +const autoUpdateEnabled = ref(getCachedValue('autoUpdate', true)) const settingsLoading = ref(true) const saveBusy = ref(false) +// 更新相关状态 +const updateState = ref(null) +const checking = ref(false) +const downloading = ref(false) +const appVersion = ref('') + const goBack = () => { router.push('/') } @@ -29,16 +38,19 @@ const loadAppSettings = async () => { heatmapEnabled.value = data?.show_heatmap ?? true homeTitleVisible.value = data?.show_home_title ?? true autoStartEnabled.value = data?.auto_start ?? false + autoUpdateEnabled.value = data?.auto_update ?? true // 缓存到 localStorage,下次打开时直接显示正确状态 localStorage.setItem('app-settings-heatmap', String(heatmapEnabled.value)) localStorage.setItem('app-settings-homeTitle', String(homeTitleVisible.value)) localStorage.setItem('app-settings-autoStart', String(autoStartEnabled.value)) + localStorage.setItem('app-settings-autoUpdate', String(autoUpdateEnabled.value)) } catch (error) { console.error('failed to load app settings', error) heatmapEnabled.value = true homeTitleVisible.value = true autoStartEnabled.value = false + autoUpdateEnabled.value = true } finally { settingsLoading.value = false } @@ -52,13 +64,18 @@ const persistAppSettings = async () => { show_heatmap: heatmapEnabled.value, show_home_title: homeTitleVisible.value, auto_start: autoStartEnabled.value, + auto_update: autoUpdateEnabled.value, } await saveAppSettings(payload) + // 同步自动更新设置到 UpdateService + await setAutoCheckEnabled(autoUpdateEnabled.value) + // 更新缓存 localStorage.setItem('app-settings-heatmap', String(heatmapEnabled.value)) localStorage.setItem('app-settings-homeTitle', String(homeTitleVisible.value)) localStorage.setItem('app-settings-autoStart', String(autoStartEnabled.value)) + localStorage.setItem('app-settings-autoUpdate', String(autoUpdateEnabled.value)) window.dispatchEvent(new CustomEvent('app-settings-updated')) } catch (error) { @@ -68,8 +85,80 @@ const persistAppSettings = async () => { } } -onMounted(() => { - void loadAppSettings() +const loadUpdateState = async () => { + try { + updateState.value = await getUpdateState() + } catch (error) { + console.error('failed to load update state', error) + } +} + +const checkUpdateManually = async () => { + checking.value = true + try { + const info = await checkUpdate() + await loadUpdateState() + + if (!info.available) { + alert('已是最新版本') + } + } catch (error) { + console.error('check update failed', error) + alert('检查更新失败,请检查网络连接') + } finally { + checking.value = false + } +} + +const downloadAndInstall = async () => { + downloading.value = true + try { + await downloadUpdate() + await loadUpdateState() + + // 弹窗确认重启 + const confirmed = confirm('新版本已下载完成,是否立即重启应用?') + if (confirmed) { + await restartApp() + } + } catch (error) { + console.error('download failed', error) + alert('下载失败,请稍后重试') + } finally { + downloading.value = false + } +} + +const formatLastCheckTime = (timeStr?: string) => { + if (!timeStr) return '从未检查' + + const checkTime = new Date(timeStr) + const now = new Date() + const diffMs = now.getTime() - checkTime.getTime() + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + + if (diffHours < 1) { + return '刚刚' + } else if (diffHours < 24) { + return `${diffHours} 小时前` + } else { + const diffDays = Math.floor(diffHours / 24) + return `${diffDays} 天前` + } +} + +onMounted(async () => { + await loadAppSettings() + + // 加载当前版本号 + try { + appVersion.value = await fetchCurrentVersion() + } catch (error) { + console.error('failed to load app version', error) + } + + // 加载更新状态 + await loadUpdateState() }) @@ -142,6 +231,63 @@ onMounted(() => { + +
+

{{ $t('components.general.title.update') }}

+
+ + + + + + {{ formatLastCheckTime(updateState?.last_check_time) }} + + ⚠️ {{ $t('components.general.update.checkFailed', { count: updateState.consecutive_failures }) }} + + + + + {{ appVersion }} + + + + {{ updateState.latest_known_version }} 🆕 + + + + + + + + + +
+
diff --git a/frontend/src/components/Main/Index.vue b/frontend/src/components/Main/Index.vue index 69f5114..6e95d9a 100644 --- a/frontend/src/components/Main/Index.vue +++ b/frontend/src/components/Main/Index.vue @@ -4,9 +4,12 @@

{{ t('components.main.hero.eyebrow') }}

+
@@ -988,6 +1006,27 @@ const loadProviderStats = async (tab: ProviderTab) => { } } +// 刷新所有数据 +const refreshing = ref(false) +const refreshAllData = async () => { + if (refreshing.value) return + refreshing.value = true + try { + await Promise.all([ + loadUsageHeatmap(), + loadProvidersFromDisk(), + ...providerTabIds.map(refreshProxyState), + ...providerTabIds.map((tab) => loadProviderStats(tab)), + refreshImportStatus(), + pollUpdateState() + ]) + } catch (error) { + console.error('Failed to refresh data', error) + } finally { + refreshing.value = false + } +} + type ProviderStatDisplay = | { state: 'loading' | 'empty'; message: string } | { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 59bb4dc..d8d8ebe 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -59,7 +59,8 @@ }, "tabs": { "ariaLabel": "AI vendor switcher", - "addCard": "Add vendor card" + "addCard": "Add vendor card", + "refresh": "Refresh data" }, "relayToggle": { "label": "Claude proxy", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 6af7a8b..67a665e 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -60,7 +60,8 @@ }, "tabs": { "ariaLabel": "AI 供应商切换", - "addCard": "新增供应商卡片" + "addCard": "新增供应商卡片", + "refresh": "刷新数据" }, "relayToggle": { "label": "Claude proxy", diff --git a/frontend/src/style.css b/frontend/src/style.css index 6c59ee8..b21101f 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -630,6 +630,62 @@ html.dark .mac-sidebar-item:hover { } } +/* 更新检查按钮样式 */ +.action-btn, +.primary-btn { + padding: 6px 16px; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.action-btn { + background: var(--mac-surface-strong); + color: var(--mac-text); + border: 1px solid var(--mac-border); +} + +.action-btn:hover:not(:disabled) { + background: var(--mac-surface-hover); +} + +.primary-btn { + background: #0ea5e9; + color: white; +} + +.primary-btn:hover:not(:disabled) { + background: #0284c7; +} + +.action-btn:disabled, +.primary-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 版本号和信息文本样式 */ +.info-text, +.version-text { + font-size: 0.875rem; + color: var(--mac-text-secondary); +} + +.version-text.highlight { + color: #10b981; + font-weight: 600; +} + +.warning-badge { + font-size: 0.75rem; + color: #f59e0b; + margin-left: 8px; +} + .global-eyebrow { margin: 0 auto 0 0; text-transform: uppercase; @@ -1230,6 +1286,24 @@ html.dark .ghost-icon:hover { transform: translateX(-50%) translateY(2px); } +/* 刷新按钮旋转动画 */ +.ghost-icon.rotating svg { + animation: spin 1s linear infinite; +} + +.ghost-icon:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} .modal-backdrop { position: fixed; From f5bcfee092e0711d7395630d34ae254047653f7f Mon Sep 17 00:00:00 2001 From: "F.Rogers" <1816524875@qq.com> Date: Fri, 14 Nov 2025 14:20:11 +0800 Subject: [PATCH 07/15] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=88=B0=20v0.2.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 版本号更新到 v0.2.6 - 更新 release 说明,包含 UI 改进和 Bug 修复 Co-Authored-By: Half open flowers <1816524875@qq.com> --- .github/workflows/release.yml | 13 +++++++++++-- version_service.go | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92a8fb8..a6c467f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -182,17 +182,26 @@ jobs: ## 🔧 技术改进 - 新增 UpdateService 后端服务(400+ 行) - - 版本号管理服务,当前版本 v0.2.5 + - 版本号管理服务,当前版本 v0.2.6 - 状态持久化到 ~/.code-switch/update-state.json - 依赖更新:github.com/hashicorp/go-version v1.7.0 + ## 🐛 Bug 修复 + - 修复"立即检查"按钮文字换行问题 + - 修复 copyFile 函数重名冲突 + + ## ✨ UI 改进 + - 新增主页刷新按钮(一键刷新所有数据) + - 添加刷新按钮旋转动画效果 + - 完善按钮样式和禁用状态 + ## 📦 安装说明 - **Windows**:下载 `CodeSwitch-amd64-installer.exe` 运行安装,或下载 `CodeSwitch.exe` 直接运行 - **macOS (Apple Silicon)**:下载 `codeswitch-macos-arm64.zip`,解压后拖入 Applications 文件夹 - **macOS (Intel)**:下载 `codeswitch-macos-amd64.zip`,解压后拖入 Applications 文件夹 - **完整更新日志**: https://github.com/Rogers-F/code-switch-R/compare/v0.2.4...v0.2.5 + **完整更新日志**: https://github.com/Rogers-F/code-switch-R/compare/v0.2.4...v0.2.6 draft: false prerelease: false env: diff --git a/version_service.go b/version_service.go index bfa643c..2827440 100644 --- a/version_service.go +++ b/version_service.go @@ -1,6 +1,6 @@ package main -const AppVersion = "v0.2.5" +const AppVersion = "v0.2.6" type VersionService struct { version string From f171101c1d40355ecdb0d31ce6539f5cbb30651c Mon Sep 17 00:00:00 2001 From: "F.Rogers" <1816524875@qq.com> Date: Fri, 14 Nov 2025 14:50:43 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=AB=8B?= =?UTF-8?q?=E5=8D=B3=E6=A3=80=E6=9F=A5=E6=8C=89=E9=92=AE=E6=96=87=E5=AD=97?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 display: inline-flex 和 align-items: center 确保垂直居中 - 调整 padding 从 6px 改为 8px,增加垂直空间 - 添加 line-height: 1.2 控制行高 - 添加 justify-content: center 确保水平居中 Co-Authored-By: Half open flowers <1816524875@qq.com> --- frontend/src/style.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/style.css b/frontend/src/style.css index b21101f..6fc7a74 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -633,10 +633,14 @@ html.dark .mac-sidebar-item:hover { /* 更新检查按钮样式 */ .action-btn, .primary-btn { - padding: 6px 16px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; border-radius: 8px; font-size: 0.875rem; font-weight: 500; + line-height: 1.2; border: none; cursor: pointer; transition: all 0.2s ease; From a42278479cea68556791443ce1b196d7a72e8cb9 Mon Sep 17 00:00:00 2001 From: "F.Rogers" <1816524875@qq.com> Date: Fri, 14 Nov 2025 14:59:30 +0800 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20=E5=86=8D=E6=AC=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8C=89=E9=92=AE=E6=96=87=E5=AD=97=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8=20min-height=20=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改用 min-height: 36px 代替 padding 控制高度 - padding 改为 0 16px,只保留左右内边距 - line-height 改为 1,让文字更紧凑 - 让 flexbox 的 align-items: center 正确工作 Co-Authored-By: Half open flowers <1816524875@qq.com> --- frontend/src/style.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/style.css b/frontend/src/style.css index 6fc7a74..f76be05 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -636,11 +636,12 @@ html.dark .mac-sidebar-item:hover { display: inline-flex; align-items: center; justify-content: center; - padding: 8px 16px; + padding: 0 16px; + min-height: 36px; border-radius: 8px; font-size: 0.875rem; font-weight: 500; - line-height: 1.2; + line-height: 1; border: none; cursor: pointer; transition: all 0.2s ease; From f07b8face335ef22a3c72b8d99c15acfec7659a2 Mon Sep 17 00:00:00 2001 From: "F.Rogers" <1816524875@qq.com> Date: Fri, 14 Nov 2025 18:28:11 +0800 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20HTTP=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=A0=81=E6=9C=AA=E6=AD=A3=E7=A1=AE=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:当 provider 返回 4xx/5xx 错误时,日志中 HTTP 状态码显示为 0 原因:resp.Error() 检查在获取 StatusCode() 之前,导致提前返回 修复:先获取并设置 HttpCode,再检查 Error(),确保无论成功失败都能记录正确状态码 影响:修复后,所有错误响应都能正确显示 HTTP 状态码(如 400/401/429/500 等) Co-Authored-By: Half open flowers <1816524875@qq.com> --- services/providerrelay.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/providerrelay.go b/services/providerrelay.go index 68f4334..8075d20 100644 --- a/services/providerrelay.go +++ b/services/providerrelay.go @@ -359,13 +359,14 @@ func (prs *ProviderRelayService) forwardRequest( return false, fmt.Errorf("empty response") } + // 先获取状态码,确保即使后续返回错误,也能记录正确的 HTTP 状态码 + status := resp.StatusCode() + requestLog.HttpCode = status + if resp.Error() != nil { return false, resp.Error() } - status := resp.StatusCode() - requestLog.HttpCode = status - if status >= http.StatusOK && status < http.StatusMultipleChoices { _, copyErr := resp.ToHttpResponseWriter(c.Writer, ReqeustLogHook(c, kind, requestLog)) return copyErr == nil, copyErr From 7c3a933ea7fa18c40fe83a44bd428b249cde8571 Mon Sep 17 00:00:00 2001 From: Half open flowers <1816524875@qq.com> Date: Fri, 14 Nov 2025 22:44:23 +0800 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E5=A4=B1=E8=B4=A5=E8=87=AA=E5=8A=A8=E6=8B=89?= =?UTF-8?q?=E9=BB=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: - 实现失败次数追踪和自动拉黑机制(默认3次失败拉黑30分钟) - 支持可配置的拉黑阈值(1-10次)和时长(15/30/60分钟) - 添加黑名单状态持久化(SQLite存储) - 实现后台自动恢复定时器(每分钟检查过期黑名单) - 前端新增拉黑状态显示和实时倒计时 - 支持手动解禁功能 - 完善中英文国际化支持 技术细节: - 新增 BlacklistService 和 SettingsService 服务 - 扩展数据库表:provider_blacklist 和 app_settings - 集成到 ProviderRelay 降级逻辑中 - 前端 UI 适配深色/浅色主题 Author: Half open flowers <1816524875@qq.com> --- BLACKLIST_FRONTEND_GUIDE.md | 320 +++++++++++++++++++++++++ frontend/src/components/Main/Index.vue | 135 +++++++++++ frontend/src/locales/en.json | 9 + frontend/src/locales/zh.json | 9 + frontend/src/services/blacklist.ts | 55 +++++ main.go | 18 +- services/blacklistservice.go | 303 +++++++++++++++++++++++ services/database.go | 60 +++++ services/providerrelay.go | 35 ++- services/settingsservice.go | 124 ++++++++++ 10 files changed, 1059 insertions(+), 9 deletions(-) create mode 100644 BLACKLIST_FRONTEND_GUIDE.md create mode 100644 frontend/src/services/blacklist.ts create mode 100644 services/blacklistservice.go create mode 100644 services/database.go create mode 100644 services/settingsservice.go diff --git a/BLACKLIST_FRONTEND_GUIDE.md b/BLACKLIST_FRONTEND_GUIDE.md new file mode 100644 index 0000000..e61ac90 --- /dev/null +++ b/BLACKLIST_FRONTEND_GUIDE.md @@ -0,0 +1,320 @@ +# 供应商黑名单功能 - 前端集成指南 + +## 📝 概述 + +由于前端文件 `Index.vue` 较大(1693行),手动修改容易出错。此指南提供完整的修改步骤,或者您可以跳过此步骤,先测试后端功能。 + +--- + +## ⚡ 快速方案:先测试后端 + +如果您想快速验证后端功能,可以先跳过前端 UI 修改,直接测试: + +1. **运行应用**:`wails3 task dev` +2. **查看后端日志**:在控制台观察是否有拉黑相关日志 +3. **检查数据库**:查看 `~/.code-switch/app.db` 中的 `provider_blacklist` 表 + +--- + +## 🛠️ 完整方案:集成前端 UI + +### 修改清单 + +| 文件 | 修改内容 | 优先级 | +|------|---------|--------| +| `frontend/src/components/Main/Index.vue` | 添加黑名单 UI 和逻辑 | 高 | +| `frontend/src/locales/zh-CN.json` | 中文文案 | 中 | +| `frontend/src/locales/en-US.json` | 英文文案 | 低 | + +--- + +## 📄 详细修改步骤 + +### 1. 修改 `Index.vue` - 导入部分 + +**位置**:第 580 行之后 + +**添加**: +```typescript +import { getBlacklistStatus, manualUnblock, type BlacklistStatus } from '../../services/blacklist' +``` + +--- + +### 2. 修改 `Index.vue` - 添加状态变量 + +**位置**:第 637 行之后 + +**添加**: +```typescript +// 黑名单状态 +const blacklistStatusMap = reactive>>({ + claude: {}, + codex: {}, +}) +let blacklistTimer: number | undefined +``` + +--- + +### 3. 修改 `Index.vue` - 添加方法 + +**位置**:在 `loadProviderStats` 方法附近 + +**添加以下 4 个方法**: + +```typescript +// 加载黑名单状态 +const loadBlacklistStatus = async (tab: ProviderTab) => { + try { + const statuses = await getBlacklistStatus(tab) + const map: Record = {} + statuses.forEach(status => { + map[status.providerName] = status + }) + blacklistStatusMap[tab] = map + } catch (err) { + console.error(`加载 ${tab} 黑名单状态失败:`, err) + } +} + +// 手动解禁 +const handleUnblock = async (providerName: string) => { + try { + await manualUnblock(activeTab.value, providerName) + showToast(t('components.main.blacklist.unblockSuccess', { name: providerName }), 'success') + await loadBlacklistStatus(activeTab.value) + } catch (err) { + console.error('解除拉黑失败:', err) + showToast(t('components.main.blacklist.unblockFailed'), 'error') + } +} + +// 格式化倒计时 +const formatBlacklistCountdown = (remainingSeconds: number): string => { + const minutes = Math.floor(remainingSeconds / 60) + const seconds = remainingSeconds % 60 + return `${minutes}${t('components.main.blacklist.minutes')}${seconds}${t('components.main.blacklist.seconds')}` +} + +// 获取 provider 黑名单状态 +const getProviderBlacklistStatus = (providerName: string): BlacklistStatus | null => { + return blacklistStatusMap[activeTab.value][providerName] || null +} +``` + +--- + +### 4. 修改 `Index.vue` - 修改生命周期钩子 + +#### 4.1 在 `onMounted` 中添加定时器 + +**位置**:在现有定时器之后 + +**添加**: +```typescript +// 加载初始黑名单状态 +loadBlacklistStatus(activeTab.value) + +// 每秒更新黑名单倒计时 +blacklistTimer = window.setInterval(() => { + const tab = activeTab.value + Object.keys(blacklistStatusMap[tab]).forEach(providerName => { + const status = blacklistStatusMap[tab][providerName] + if (status && status.isBlacklisted && status.remainingSeconds > 0) { + status.remainingSeconds-- + if (status.remainingSeconds <= 0) { + loadBlacklistStatus(tab) + } + } + }) +}, 1000) +``` + +#### 4.2 在 `onUnmounted` 中清理定时器 + +**位置**:在现有清理代码之后 + +**添加**: +```typescript +if (blacklistTimer) { + window.clearInterval(blacklistTimer) +} +``` + +--- + +### 5. 修改 `Index.vue` - 模板部分 + +**位置**:第 353 行的 `

` 之后 + +**添加**: +```vue + +
+ + + {{ t('components.main.blacklist.blocked') }} | + {{ t('components.main.blacklist.remaining') }}: + {{ formatBlacklistCountdown(getProviderBlacklistStatus(card.name)!.remainingSeconds) }} + + +
+``` + +--- + +### 6. 修改 `Index.vue` - 样式部分 + +**位置**:在 `` 标签之前 + +**添加**: +```scss +.blacklist-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-top: 8px; + background: rgba(239, 68, 68, 0.1); + border-left: 3px solid #ef4444; + border-radius: 6px; + font-size: 13px; + color: #dc2626; + + &.dark { + background: rgba(239, 68, 68, 0.15); + color: #f87171; + } +} + +.blacklist-icon { + font-size: 16px; + flex-shrink: 0; +} + +.blacklist-text { + flex: 1; + font-weight: 500; +} + +.unblock-btn { + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + color: #fff; + background: #ef4444; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #dc2626; + } + + &:active { + transform: scale(0.98); + } +} +``` + +--- + +### 7. 修改 `zh-CN.json` - 中文文案 + +**位置**:在 `components.main` 对象中添加 + +**添加**: +```json +"blacklist": { + "blocked": "已拉黑", + "remaining": "剩余", + "minutes": "分", + "seconds": "秒", + "unblock": "立即解禁", + "unblockSuccess": "已解除 {name} 的拉黑", + "unblockFailed": "解除拉黑失败,请稍后重试" +} +``` + +--- + +### 8. 修改 `en-US.json` - 英文文案 + +**添加**: +```json +"blacklist": { + "blocked": "Blocked", + "remaining": "Remaining", + "minutes": "m", + "seconds": "s", + "unblock": "Unblock", + "unblockSuccess": "{name} has been unblocked", + "unblockFailed": "Failed to unblock" +} +``` + +--- + +## 🧪 测试步骤 + +### 后端功能测试 + +1. **启动应用**: + ```bash + cd G:\claude-lit\cc-r + wails3 task dev + ``` + +2. **检查数据库表**: + - 打开 `~/.code-switch/app.db` + - 确认 `provider_blacklist` 和 `app_settings` 表已创建 + +3. **触发拉黑**: + - 添加一个故意配置错误的 provider(错误的 API Key) + - 向该 provider 发送 3 次请求 + - 查看控制台日志,应该看到 "⛔ Provider XXX 已拉黑 30 分钟" + +### 前端 UI 测试(如果完成了前端修改) + +1. **验证拉黑横幅**: + - Provider 卡片下方应出现红色横幅 + - 显示 "⛔ 已拉黑 | 剩余: 29分59秒" + +2. **验证倒计时**: + - 每秒递减 + - 格式正确 + +3. **验证手动解禁**: + - 点击"立即解禁"按钮 + - 横幅消失 + - Provider 恢复可用 + +--- + +## 🐛 故障排除 + +**问题:拉黑不生效** +- 检查后端日志是否有错误 +- 确认数据库表已创建 +- 验证 provider 确实失败了 + +**问题:前端横幅不显示** +- 检查浏览器控制台是否有 API 调用错误 +- 确认导入和状态变量已正确添加 +- 验证模板代码位置正确 + +--- + +作者:Half open flowers +日期:2025-01-14 diff --git a/frontend/src/components/Main/Index.vue b/frontend/src/components/Main/Index.vue index 30fd460..9639186 100644 --- a/frontend/src/components/Main/Index.vue +++ b/frontend/src/components/Main/Index.vue @@ -351,6 +351,25 @@ {{ stats.cost }}

+ +
+ + + {{ t('components.main.blacklist.blocked') }} | + {{ t('components.main.blacklist.remaining') }}: + {{ formatBlacklistCountdown(getProviderBlacklistStatus(card.name)!.remainingSeconds) }} + + +
@@ -585,6 +604,7 @@ import { getCurrentTheme, setTheme, type ThemeMode } from '../../utils/ThemeMana import { useRouter } from 'vue-router' import { fetchConfigImportStatus, importFromCcSwitch, type ConfigImportStatus } from '../../services/configImport' import { showToast } from '../../utils/toast' +import { getBlacklistStatus, manualUnblock, type BlacklistStatus } from '../../services/blacklist' const { t, locale } = useI18n() const router = useRouter() @@ -636,6 +656,13 @@ const downloadProgress = ref(0) const importStatus = ref(null) const importBusy = ref(false) +// 黑名单状态 +const blacklistStatusMap = reactive>>({ + claude: {}, + codex: {}, +}) +let blacklistTimer: number | undefined + const showImportButton = computed(() => { const status = importStatus.value if (!status) return false @@ -1006,6 +1033,44 @@ const loadProviderStats = async (tab: ProviderTab) => { } } +// 加载黑名单状态 +const loadBlacklistStatus = async (tab: ProviderTab) => { + try { + const statuses = await getBlacklistStatus(tab) + const map: Record = {} + statuses.forEach(status => { + map[status.providerName] = status + }) + blacklistStatusMap[tab] = map + } catch (err) { + console.error(`加载 ${tab} 黑名单状态失败:`, err) + } +} + +// 手动解禁 +const handleUnblock = async (providerName: string) => { + try { + await manualUnblock(activeTab.value, providerName) + showToast(t('components.main.blacklist.unblockSuccess', { name: providerName }), 'success') + await loadBlacklistStatus(activeTab.value) + } catch (err) { + console.error('解除拉黑失败:', err) + showToast(t('components.main.blacklist.unblockFailed'), 'error') + } +} + +// 格式化倒计时 +const formatBlacklistCountdown = (remainingSeconds: number): string => { + const minutes = Math.floor(remainingSeconds / 60) + const seconds = remainingSeconds % 60 + return `${minutes}${t('components.main.blacklist.minutes')}${seconds}${t('components.main.blacklist.seconds')}` +} + +// 获取 provider 黑名单状态 +const getProviderBlacklistStatus = (providerName: string): BlacklistStatus | null => { + return blacklistStatusMap[activeTab.value][providerName] || null +} + // 刷新所有数据 const refreshing = ref(false) const refreshAllData = async () => { @@ -1138,6 +1203,24 @@ onMounted(async () => { await refreshImportStatus() startProviderStatsTimer() startUpdateTimer() + + // 加载初始黑名单状态 + await Promise.all(providerTabIds.map((tab) => loadBlacklistStatus(tab))) + + // 每秒更新黑名单倒计时 + blacklistTimer = window.setInterval(() => { + const tab = activeTab.value + Object.keys(blacklistStatusMap[tab]).forEach(providerName => { + const status = blacklistStatusMap[tab][providerName] + if (status && status.isBlacklisted && status.remainingSeconds > 0) { + status.remainingSeconds-- + if (status.remainingSeconds <= 0) { + loadBlacklistStatus(tab) + } + } + }) + }, 1000) + window.addEventListener('app-settings-updated', handleAppSettingsUpdated) }) @@ -1145,6 +1228,9 @@ onUnmounted(() => { stopProviderStatsTimer() window.removeEventListener('app-settings-updated', handleAppSettingsUpdated) stopUpdateTimer() + if (blacklistTimer) { + window.clearInterval(blacklistTimer) + } }) const selectedIndex = ref(0) @@ -1690,4 +1776,53 @@ const handleImportClick = async () => { .level-option.selected .level-name { color: var(--mac-accent); } + +/* 黑名单横幅 */ +.blacklist-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-top: 8px; + background: rgba(239, 68, 68, 0.1); + border-left: 3px solid #ef4444; + border-radius: 6px; + font-size: 13px; + color: #dc2626; +} + +.blacklist-banner.dark { + background: rgba(239, 68, 68, 0.15); + color: #f87171; +} + +.blacklist-icon { + font-size: 16px; + flex-shrink: 0; +} + +.blacklist-text { + flex: 1; + font-weight: 500; +} + +.unblock-btn { + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + color: #fff; + background: #ef4444; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +.unblock-btn:hover { + background: #dc2626; +} + +.unblock-btn:active { + transform: scale(0.98); +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d8d8ebe..0311757 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -147,6 +147,15 @@ "lower": "Lower Priority", "veryLow": "Very Low Priority", "lowest": "Lowest Priority" + }, + "blacklist": { + "blocked": "Blocked", + "remaining": "Remaining", + "minutes": "m", + "seconds": "s", + "unblock": "Unblock", + "unblockSuccess": "{name} has been unblocked", + "unblockFailed": "Failed to unblock" } }, "logs": { diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 67a665e..7ec1f78 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -148,6 +148,15 @@ "lower": "较低优先级", "veryLow": "很低优先级", "lowest": "最低优先级" + }, + "blacklist": { + "blocked": "已拉黑", + "remaining": "剩余", + "minutes": "分", + "seconds": "秒", + "unblock": "立即解禁", + "unblockSuccess": "已解除 {name} 的拉黑", + "unblockFailed": "解除拉黑失败,请稍后重试" } }, "logs": { diff --git a/frontend/src/services/blacklist.ts b/frontend/src/services/blacklist.ts new file mode 100644 index 0000000..3bf5e5e --- /dev/null +++ b/frontend/src/services/blacklist.ts @@ -0,0 +1,55 @@ +import { Call } from '@wailsio/runtime' + +// 黑名单状态接口 +export interface BlacklistStatus { + platform: string + providerName: string + failureCount: number + blacklistedAt?: string // ISO 时间字符串 + blacklistedUntil?: string // ISO 时间字符串 + lastFailureAt?: string // ISO 时间字符串 + isBlacklisted: boolean + remainingSeconds: number // 剩余拉黑时间(秒) +} + +// 黑名单配置接口 +export interface BlacklistSettings { + failureThreshold: number // 失败次数阈值 + durationMinutes: number // 拉黑时长(分钟) +} + +const BLACKLIST_SERVICE = 'codeswitch/services.BlacklistService' +const SETTINGS_SERVICE = 'codeswitch/services.SettingsService' + +/** + * 获取指定平台的黑名单状态列表 + * @param platform 'claude' | 'codex' + */ +export const getBlacklistStatus = async (platform: string): Promise => { + return Call.ByName(`${BLACKLIST_SERVICE}.GetBlacklistStatus`, platform) +} + +/** + * 手动解除拉黑 + * @param platform 'claude' | 'codex' + * @param providerName provider 名称 + */ +export const manualUnblock = async (platform: string, providerName: string): Promise => { + return Call.ByName(`${BLACKLIST_SERVICE}.ManualUnblock`, platform, providerName) +} + +/** + * 获取黑名单配置 + */ +export const getBlacklistSettings = async (): Promise => { + return Call.ByName(`${SETTINGS_SERVICE}.GetBlacklistSettingsStruct`) +} + +/** + * 更新黑名单配置 + * @param threshold 失败次数阈值(1-10) + * @param duration 拉黑时长(15/30/60 分钟) + */ +export const updateBlacklistSettings = async (threshold: number, duration: number): Promise => { + return Call.ByName(`${SETTINGS_SERVICE}.UpdateBlacklistSettings`, threshold, duration) +} diff --git a/main.go b/main.go index 407e49d..1bc7eda 100644 --- a/main.go +++ b/main.go @@ -68,7 +68,9 @@ func main() { // 处理错误,比如日志或退出 } providerService := services.NewProviderService() - providerRelay := services.NewProviderRelayService(providerService, ":18100") + settingsService := services.NewSettingsService() + blacklistService := services.NewBlacklistService(settingsService) + providerRelay := services.NewProviderRelayService(providerService, blacklistService, ":18100") claudeSettings := services.NewClaudeSettingsService(providerRelay.Addr()) codexSettings := services.NewCodexSettingsService(providerRelay.Addr()) logService := services.NewLogService() @@ -104,6 +106,18 @@ func main() { } }() + // 启动黑名单自动恢复定时器(每分钟检查一次) + go func() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + if err := blacklistService.AutoRecoverExpired(); err != nil { + log.Printf("自动恢复黑名单失败: %v", err) + } + } + }() + //fmt.Println(clipboardService) // Create a new Wails application by providing the necessary options. // Variables 'Name' and 'Description' are for application metadata. @@ -117,6 +131,8 @@ func main() { application.NewService(appservice), application.NewService(suiService), application.NewService(providerService), + application.NewService(settingsService), + application.NewService(blacklistService), application.NewService(claudeSettings), application.NewService(codexSettings), application.NewService(logService), diff --git a/services/blacklistservice.go b/services/blacklistservice.go new file mode 100644 index 0000000..efb4fa2 --- /dev/null +++ b/services/blacklistservice.go @@ -0,0 +1,303 @@ +package services + +import ( + "database/sql" + "fmt" + "log" + "time" + + "github.com/daodao97/xgo/xdb" +) + +// BlacklistService 管理供应商黑名单 +type BlacklistService struct { + settingsService *SettingsService +} + +// BlacklistStatus 黑名单状态(用于前端展示) +type BlacklistStatus struct { + Platform string `json:"platform"` + ProviderName string `json:"providerName"` + FailureCount int `json:"failureCount"` + BlacklistedAt *time.Time `json:"blacklistedAt"` + BlacklistedUntil *time.Time `json:"blacklistedUntil"` + LastFailureAt *time.Time `json:"lastFailureAt"` + IsBlacklisted bool `json:"isBlacklisted"` + RemainingSeconds int `json:"remainingSeconds"` // 剩余拉黑时间(秒) +} + +func NewBlacklistService(settingsService *SettingsService) *BlacklistService { + return &BlacklistService{ + settingsService: settingsService, + } +} + +// RecordFailure 记录 provider 失败,失败次数达到阈值时自动拉黑 +func (bs *BlacklistService) RecordFailure(platform string, providerName string) error { + db, err := xdb.DB("default") + if err != nil { + return fmt.Errorf("获取数据库连接失败: %w", err) + } + + // 获取配置 + threshold, duration, err := bs.settingsService.GetBlacklistSettings() + if err != nil { + log.Printf("⚠️ 获取黑名单配置失败,使用默认值: %v", err) + threshold, duration = 3, 30 + } + + now := time.Now() + + // 检查是否已存在记录 + var id int + var failureCount int + var blacklistedUntil sql.NullTime + + err = db.QueryRow(` + SELECT id, failure_count, blacklisted_until + FROM provider_blacklist + WHERE platform = ? AND provider_name = ? + `, platform, providerName).Scan(&id, &failureCount, &blacklistedUntil) + + if err == sql.ErrNoRows { + // 首次失败,插入新记录 + _, err = db.Exec(` + INSERT INTO provider_blacklist + (platform, provider_name, failure_count, last_failure_at) + VALUES (?, ?, 1, ?) + `, platform, providerName, now) + + if err != nil { + return fmt.Errorf("插入失败记录失败: %w", err) + } + + log.Printf("📊 Provider %s/%s 失败计数: 1/%d", platform, providerName, threshold) + return nil + } else if err != nil { + return fmt.Errorf("查询黑名单记录失败: %w", err) + } + + // 如果已经拉黑且未过期,不重复计数 + if blacklistedUntil.Valid && blacklistedUntil.Time.After(now) { + log.Printf("⛔ Provider %s/%s 已在黑名单中,过期时间: %s", platform, providerName, blacklistedUntil.Time.Format("15:04:05")) + return nil + } + + // 失败计数 +1 + failureCount++ + + // 检查是否达到拉黑阈值 + if failureCount >= threshold { + blacklistedAt := now + blacklistedUntil := now.Add(time.Duration(duration) * time.Minute) + + _, err = db.Exec(` + UPDATE provider_blacklist + SET failure_count = ?, + last_failure_at = ?, + blacklisted_at = ?, + blacklisted_until = ?, + auto_recovered = 0 + WHERE id = ? + `, failureCount, now, blacklistedAt, blacklistedUntil, id) + + if err != nil { + return fmt.Errorf("更新拉黑状态失败: %w", err) + } + + log.Printf("⛔ Provider %s/%s 已拉黑 %d 分钟(失败 %d 次),过期时间: %s", + platform, providerName, duration, failureCount, blacklistedUntil.Format("15:04:05")) + + } else { + // 更新失败计数 + _, err = db.Exec(` + UPDATE provider_blacklist + SET failure_count = ?, last_failure_at = ? + WHERE id = ? + `, failureCount, now, id) + + if err != nil { + return fmt.Errorf("更新失败计数失败: %w", err) + } + + log.Printf("📊 Provider %s/%s 失败计数: %d/%d", platform, providerName, failureCount, threshold) + } + + return nil +} + +// IsBlacklisted 检查 provider 是否在黑名单中 +func (bs *BlacklistService) IsBlacklisted(platform string, providerName string) (bool, *time.Time) { + db, err := xdb.DB("default") + if err != nil { + log.Printf("⚠️ 获取数据库连接失败: %v", err) + return false, nil + } + + var blacklistedUntil sql.NullTime + + err = db.QueryRow(` + SELECT blacklisted_until + FROM provider_blacklist + WHERE platform = ? AND provider_name = ? AND blacklisted_until > datetime('now') + `, platform, providerName).Scan(&blacklistedUntil) + + if err == sql.ErrNoRows { + return false, nil + } else if err != nil { + log.Printf("⚠️ 查询黑名单状态失败: %v", err) + return false, nil + } + + if blacklistedUntil.Valid { + return true, &blacklistedUntil.Time + } + + return false, nil +} + +// ManualUnblock 手动解除拉黑 +func (bs *BlacklistService) ManualUnblock(platform string, providerName string) error { + db, err := xdb.DB("default") + if err != nil { + return fmt.Errorf("获取数据库连接失败: %w", err) + } + + result, err := db.Exec(` + UPDATE provider_blacklist + SET blacklisted_at = NULL, + blacklisted_until = NULL, + failure_count = 0, + auto_recovered = 0 + WHERE platform = ? AND provider_name = ? + `, platform, providerName) + + if err != nil { + return fmt.Errorf("手动解除拉黑失败: %w", err) + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("provider %s/%s 不在黑名单中", platform, providerName) + } + + log.Printf("✅ 手动解除拉黑: %s/%s", platform, providerName) + return nil +} + +// AutoRecoverExpired 自动恢复过期的黑名单(由定时器调用) +func (bs *BlacklistService) AutoRecoverExpired() error { + db, err := xdb.DB("default") + if err != nil { + return fmt.Errorf("获取数据库连接失败: %w", err) + } + + // 查询需要恢复的 provider + rows, err := db.Query(` + SELECT platform, provider_name + FROM provider_blacklist + WHERE blacklisted_until IS NOT NULL + AND blacklisted_until <= datetime('now') + AND auto_recovered = 0 + `) + + if err != nil { + return fmt.Errorf("查询过期黑名单失败: %w", err) + } + defer rows.Close() + + var recovered []string + for rows.Next() { + var platform, providerName string + if err := rows.Scan(&platform, &providerName); err != nil { + log.Printf("⚠️ 读取恢复记录失败: %v", err) + continue + } + + // 标记为已恢复(保留历史记录) + _, err = db.Exec(` + UPDATE provider_blacklist + SET auto_recovered = 1, failure_count = 0 + WHERE platform = ? AND provider_name = ? + `, platform, providerName) + + if err != nil { + log.Printf("⚠️ 标记恢复状态失败: %s/%s - %v", platform, providerName, err) + continue + } + + recovered = append(recovered, fmt.Sprintf("%s/%s", platform, providerName)) + } + + if len(recovered) > 0 { + log.Printf("✅ 自动恢复 %d 个过期拉黑: %v", len(recovered), recovered) + } + + return nil +} + +// GetBlacklistStatus 获取所有黑名单状态(用于前端展示) +func (bs *BlacklistService) GetBlacklistStatus(platform string) ([]BlacklistStatus, error) { + db, err := xdb.DB("default") + if err != nil { + return nil, fmt.Errorf("获取数据库连接失败: %w", err) + } + + rows, err := db.Query(` + SELECT + platform, + provider_name, + failure_count, + blacklisted_at, + blacklisted_until, + last_failure_at + FROM provider_blacklist + WHERE platform = ? + ORDER BY last_failure_at DESC + `, platform) + + if err != nil { + return nil, fmt.Errorf("查询黑名单状态失败: %w", err) + } + defer rows.Close() + + var statuses []BlacklistStatus + now := time.Now() + + for rows.Next() { + var s BlacklistStatus + var blacklistedAt, blacklistedUntil, lastFailureAt sql.NullTime + + err := rows.Scan( + &s.Platform, + &s.ProviderName, + &s.FailureCount, + &blacklistedAt, + &blacklistedUntil, + &lastFailureAt, + ) + + if err != nil { + log.Printf("⚠️ 读取黑名单状态失败: %v", err) + continue + } + + if blacklistedAt.Valid { + s.BlacklistedAt = &blacklistedAt.Time + } + if blacklistedUntil.Valid { + s.BlacklistedUntil = &blacklistedUntil.Time + s.IsBlacklisted = blacklistedUntil.Time.After(now) + if s.IsBlacklisted { + s.RemainingSeconds = int(blacklistedUntil.Time.Sub(now).Seconds()) + } + } + if lastFailureAt.Valid { + s.LastFailureAt = &lastFailureAt.Time + } + + statuses = append(statuses, s) + } + + return statuses, nil +} diff --git a/services/database.go b/services/database.go new file mode 100644 index 0000000..df23237 --- /dev/null +++ b/services/database.go @@ -0,0 +1,60 @@ +package services + +import ( + "database/sql" + + "github.com/daodao97/xgo/xdb" +) + +// ensureBlacklistTables 初始化黑名单相关的数据库表 +func ensureBlacklistTables() error { + db, err := xdb.DB("default") + if err != nil { + return err + } + return ensureBlacklistTablesWithDB(db) +} + +// ensureBlacklistTablesWithDB 使用给定的数据库连接初始化黑名单表 +func ensureBlacklistTablesWithDB(db *sql.DB) error { + // 创建 provider_blacklist 表 + const createBlacklistTableSQL = `CREATE TABLE IF NOT EXISTS provider_blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + provider_name TEXT NOT NULL, + failure_count INTEGER DEFAULT 1, + blacklisted_at DATETIME, + blacklisted_until DATETIME, + last_failure_at DATETIME, + auto_recovered BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(platform, provider_name) + )` + + if _, err := db.Exec(createBlacklistTableSQL); err != nil { + return err + } + + // 创建 app_settings 表 + const createSettingsTableSQL = `CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )` + + if _, err := db.Exec(createSettingsTableSQL); err != nil { + return err + } + + // 插入默认配置(如果不存在) + const insertDefaultSettings = ` + INSERT OR IGNORE INTO app_settings (key, value) VALUES + ('blacklist_failure_threshold', '3'), + ('blacklist_duration_minutes', '30') + ` + + if _, err := db.Exec(insertDefaultSettings); err != nil { + return err + } + + return nil +} diff --git a/services/providerrelay.go b/services/providerrelay.go index 8075d20..9f1e253 100644 --- a/services/providerrelay.go +++ b/services/providerrelay.go @@ -22,12 +22,13 @@ import ( ) type ProviderRelayService struct { - providerService *ProviderService - server *http.Server - addr string + providerService *ProviderService + blacklistService *BlacklistService + server *http.Server + addr string } -func NewProviderRelayService(providerService *ProviderService, addr string) *ProviderRelayService { +func NewProviderRelayService(providerService *ProviderService, blacklistService *BlacklistService, addr string) *ProviderRelayService { if addr == "" { addr = ":18100" } @@ -42,13 +43,19 @@ func NewProviderRelayService(providerService *ProviderService, addr string) *Pro }, }); err != nil { fmt.Printf("初始化数据库失败: %v\n", err) - } else if err := ensureRequestLogTable(); err != nil { - fmt.Printf("初始化 request_log 表失败: %v\n", err) + } else { + if err := ensureRequestLogTable(); err != nil { + fmt.Printf("初始化 request_log 表失败: %v\n", err) + } + if err := ensureBlacklistTables(); err != nil { + fmt.Printf("初始化黑名单表失败: %v\n", err) + } } return &ProviderRelayService{ - providerService: providerService, - addr: addr, + providerService: providerService, + blacklistService: blacklistService, + addr: addr, } } @@ -190,6 +197,13 @@ func (prs *ProviderRelayService) proxyHandler(kind string, endpoint string) gin. continue } + // 黑名单检查:跳过已拉黑的 provider + if isBlacklisted, until := prs.blacklistService.IsBlacklisted(kind, provider.Name); isBlacklisted { + fmt.Printf("⛔ Provider %s 已拉黑,过期时间: %v\n", provider.Name, until.Format("15:04:05")) + skippedCount++ + continue + } + active = append(active, provider) } @@ -282,6 +296,11 @@ func (prs *ProviderRelayService) proxyHandler(kind string, endpoint string) gin. fmt.Printf("[WARN] ✗ Level %d 失败: %s | 错误: %s | 耗时: %.2fs\n", level, provider.Name, errorMsg, duration.Seconds()) lastErr = err + + // 记录失败到黑名单系统 + if err := prs.blacklistService.RecordFailure(kind, provider.Name); err != nil { + fmt.Printf("[ERROR] 记录失败到黑名单失败: %v\n", err) + } } // 当前 Level 所有 provider 都失败 diff --git a/services/settingsservice.go b/services/settingsservice.go new file mode 100644 index 0000000..4894f1c --- /dev/null +++ b/services/settingsservice.go @@ -0,0 +1,124 @@ +package services + +import ( + "database/sql" + "fmt" + "strconv" + + "github.com/daodao97/xgo/xdb" +) + +// SettingsService 管理全局配置 +type SettingsService struct{} + +// BlacklistSettings 黑名单配置 +type BlacklistSettings struct { + FailureThreshold int `json:"failureThreshold"` // 失败次数阈值 + DurationMinutes int `json:"durationMinutes"` // 拉黑时长(分钟) +} + +func NewSettingsService() *SettingsService { + return &SettingsService{} +} + +// GetBlacklistSettings 获取黑名单配置 +func (ss *SettingsService) GetBlacklistSettings() (threshold int, duration int, err error) { + db, err := xdb.DB("default") + if err != nil { + return 0, 0, fmt.Errorf("获取数据库连接失败: %w", err) + } + + // 获取失败阈值 + var thresholdStr string + err = db.QueryRow(` + SELECT value FROM app_settings WHERE key = 'blacklist_failure_threshold' + `).Scan(&thresholdStr) + + if err != nil { + return 0, 0, fmt.Errorf("获取失败阈值失败: %w", err) + } + + threshold, err = strconv.Atoi(thresholdStr) + if err != nil { + return 0, 0, fmt.Errorf("失败阈值格式错误: %w", err) + } + + // 获取拉黑时长 + var durationStr string + err = db.QueryRow(` + SELECT value FROM app_settings WHERE key = 'blacklist_duration_minutes' + `).Scan(&durationStr) + + if err != nil { + return 0, 0, fmt.Errorf("获取拉黑时长失败: %w", err) + } + + duration, err = strconv.Atoi(durationStr) + if err != nil { + return 0, 0, fmt.Errorf("拉黑时长格式错误: %w", err) + } + + return threshold, duration, nil +} + +// UpdateBlacklistSettings 更新黑名单配置 +func (ss *SettingsService) UpdateBlacklistSettings(threshold int, duration int) error { + db, err := xdb.DB("default") + if err != nil { + return fmt.Errorf("获取数据库连接失败: %w", err) + } + + // 验证参数 + if threshold < 1 || threshold > 10 { + return fmt.Errorf("失败阈值必须在 1-10 之间") + } + + if duration != 15 && duration != 30 && duration != 60 { + return fmt.Errorf("拉黑时长只支持 15/30/60 分钟") + } + + // 开启事务 + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("开启事务失败: %w", err) + } + defer tx.Rollback() + + // 更新失败阈值 + _, err = tx.Exec(` + UPDATE app_settings SET value = ? WHERE key = 'blacklist_failure_threshold' + `, strconv.Itoa(threshold)) + + if err != nil { + return fmt.Errorf("更新失败阈值失败: %w", err) + } + + // 更新拉黑时长 + _, err = tx.Exec(` + UPDATE app_settings SET value = ? WHERE key = 'blacklist_duration_minutes' + `, strconv.Itoa(duration)) + + if err != nil { + return fmt.Errorf("更新拉黑时长失败: %w", err) + } + + // 提交事务 + if err = tx.Commit(); err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + + return nil +} + +// GetBlacklistSettingsStruct 获取黑名单配置(结构体形式,用于前端) +func (ss *SettingsService) GetBlacklistSettingsStruct() (*BlacklistSettings, error) { + threshold, duration, err := ss.GetBlacklistSettings() + if err != nil { + return nil, err + } + + return &BlacklistSettings{ + FailureThreshold: threshold, + DurationMinutes: duration, + }, nil +} From a1cfa0f2681f69f4264d43a56a355ce97b57d7af Mon Sep 17 00:00:00 2001 From: Half open flowers <1816524875@qq.com> Date: Fri, 14 Nov 2025 22:50:31 +0800 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=20database/sql=20=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复构建错误:settingsservice.go 中导入了 database/sql 但未使用 Author: Half open flowers <1816524875@qq.com> --- services/settingsservice.go | 1 - 1 file changed, 1 deletion(-) diff --git a/services/settingsservice.go b/services/settingsservice.go index 4894f1c..1396d70 100644 --- a/services/settingsservice.go +++ b/services/settingsservice.go @@ -1,7 +1,6 @@ package services import ( - "database/sql" "fmt" "strconv" From f95d10036b93e376160ce98ce5cc16123ca157ed Mon Sep 17 00:00:00 2001 From: Half open flowers <1816524875@qq.com> Date: Fri, 14 Nov 2025 23:07:34 +0800 Subject: [PATCH 13/15] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E5=88=B0=20v0.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Author: Half open flowers <1816524875@qq.com> --- version_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version_service.go b/version_service.go index 2827440..b70dffc 100644 --- a/version_service.go +++ b/version_service.go @@ -1,6 +1,6 @@ package main -const AppVersion = "v0.2.6" +const AppVersion = "v0.3.0" type VersionService struct { version string From 92ea2d0334759cef9641bb3061095b58bb1a821d Mon Sep 17 00:00:00 2001 From: Half open flowers <1816524875@qq.com> Date: Fri, 14 Nov 2025 23:45:06 +0800 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8B=89?= =?UTF-8?q?=E9=BB=91=E9=85=8D=E7=BD=AE=E7=95=8C=E9=9D=A2=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=AE=9E=E6=97=B6=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **新增功能** - 在应用设置中新增「拉黑配置」部分 - 支持自定义失败阈值(1-10 次,默认 3 次) - 支持自定义拉黑时长(15/30/60 分钟,默认 30 分钟) - 配置即时生效,持久化到数据库 **优化改��** - 修复拉黑条幅延迟显示问题 - 添加窗口焦点恢复监听,最小化后立即刷新状态 - 添加定期轮询机制(10 秒间隔),确保状态同步 - 切换 Claude/Codex tab 时立即刷新黑名单状态 - 点击刷新按钮时同步更新黑名单数据 **版本更新** - 版本号:v0.3.0 → v0.3.1 --- frontend/src/components/General/Index.vue | 83 +++++++++++++++++++++++ frontend/src/components/Main/Index.vue | 31 ++++++++- frontend/src/locales/en.json | 12 +++- frontend/src/locales/zh.json | 12 +++- frontend/src/services/settings.ts | 31 +++++++++ version_service.go | 2 +- 6 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 frontend/src/services/settings.ts diff --git a/frontend/src/components/General/Index.vue b/frontend/src/components/General/Index.vue index a86e05d..282e1b7 100644 --- a/frontend/src/components/General/Index.vue +++ b/frontend/src/components/General/Index.vue @@ -7,6 +7,7 @@ import ThemeSetting from '../Setting/ThemeSetting.vue' import { fetchAppSettings, saveAppSettings, type AppSettings } from '../../services/appSettings' import { checkUpdate, downloadUpdate, restartApp, getUpdateState, setAutoCheckEnabled, type UpdateState } from '../../services/update' import { fetchCurrentVersion } from '../../services/version' +import { getBlacklistSettings, updateBlacklistSettings, type BlacklistSettings } from '../../services/settings' const router = useRouter() // 从 localStorage 读取缓存值作为初始值,避免加载时的视觉闪烁 @@ -27,6 +28,12 @@ const checking = ref(false) const downloading = ref(false) const appVersion = ref('') +// 拉黑配置相关状态 +const blacklistThreshold = ref(3) +const blacklistDuration = ref(30) +const blacklistLoading = ref(false) +const blacklistSaving = ref(false) + const goBack = () => { router.push('/') } @@ -147,6 +154,38 @@ const formatLastCheckTime = (timeStr?: string) => { } } +// 加载拉黑配置 +const loadBlacklistSettings = async () => { + blacklistLoading.value = true + try { + const settings = await getBlacklistSettings() + blacklistThreshold.value = settings.failureThreshold + blacklistDuration.value = settings.durationMinutes + } catch (error) { + console.error('failed to load blacklist settings', error) + // 使用默认值 + blacklistThreshold.value = 3 + blacklistDuration.value = 30 + } finally { + blacklistLoading.value = false + } +} + +// 保存拉黑配置 +const saveBlacklistSettings = async () => { + if (blacklistLoading.value || blacklistSaving.value) return + blacklistSaving.value = true + try { + await updateBlacklistSettings(blacklistThreshold.value, blacklistDuration.value) + alert('拉黑配置已保存') + } catch (error) { + console.error('failed to save blacklist settings', error) + alert('保存失败:' + (error as Error).message) + } finally { + blacklistSaving.value = false + } +} + onMounted(async () => { await loadAppSettings() @@ -159,6 +198,9 @@ onMounted(async () => { // 加载更新状态 await loadUpdateState() + + // 加载拉黑配置 + await loadBlacklistSettings() }) @@ -220,6 +262,47 @@ onMounted(async () => {
+
+

{{ $t('components.general.title.blacklist') }}

+
+ + + + + + + + + +
+
+

{{ $t('components.general.title.exterior') }}

diff --git a/frontend/src/components/Main/Index.vue b/frontend/src/components/Main/Index.vue index 9639186..7a53ac2 100644 --- a/frontend/src/components/Main/Index.vue +++ b/frontend/src/components/Main/Index.vue @@ -575,7 +575,7 @@