diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77275b6..a6c467f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,30 +159,49 @@ jobs: with: files: release-assets/* body: | - ## 新增功能 - - ### 优先级分组调度 - - 新增 Level 字段(1-10)用于供应商优先级分组,实现更灵活的降级策略。 - - **主要改进**: - - 后端:重构调度逻辑为两层循环架构,优先尝试高优先级分组 - - 前端:添加 Level 下拉选择器和可视化徽章(L1-L10) - - 国际化:支持中英文 Level 描述文本 - - 测试:新增单元测试覆盖分组、排序和序列化逻辑 - - **使用场景**: - - 成本优化:低成本供应商优先,高成本作为备份 - - 稳定性保障:高质量供应商优先,社区供应商降级 - - 地域分组:国内供应商优先,国外供应商备份 - - **向后兼容**:未设置 Level 的供应商自动默认为 Level 1。 - - ## 安装说明 - - - **Windows**:下载 `codeswitch-amd64-installer.exe` 运行安装,或下载 `codeswitch.exe` 直接运行 - - **macOS (Apple Silicon)**:下载 `codeswitch-macos-arm64.zip` - - **macOS (Intel)**:下载 `codeswitch-macos-amd64.zip` + ## ✨ 新功能 + + ### 应用自动更新 + - 新增自动更新功能,支持一键更新到最新版本 + - 每天 8:00 AM 自动检查 GitHub Release 更新 + - 网络异常时自动重试 3 次(每次间隔 5 分钟) + - 智能重启机制:下载更新后在下次启动时自动应用 + + ### 设置界面增强 + - 设置页面新增"应用更新"板块 + - 可查看当前版本、最新版本和上次检查时间 + - 支持手动检查更新和立即下载安装 + - 提供自动更新开关,状态持久化保存 + + ### UI 改进 + - 主页 GitHub 图标显示更新状态徽章 + - 🔵 蓝色 "New" 徽章:有新版本可用 + - 🟡 黄色进度徽章:正在下载更新 + - 🟢 绿色 "Ready" 徽章(脉冲动画):更新已就绪,点击重启 + - 完善中英文国际化翻译 + + ## 🔧 技术改进 + - 新增 UpdateService 后端服务(400+ 行) + - 版本号管理服务,当前版本 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.6 draft: false prerelease: false env: 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/General/Index.vue b/frontend/src/components/General/Index.vue index d51b0f3..cd83043 100644 --- a/frontend/src/components/General/Index.vue +++ b/frontend/src/components/General/Index.vue @@ -5,14 +5,35 @@ 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' +import { getBlacklistSettings, updateBlacklistSettings, type BlacklistSettings } from '../../services/settings' const router = useRouter() -const heatmapEnabled = ref(true) -const homeTitleVisible = ref(true) -const autoStartEnabled = ref(false) +// 从 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 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 blacklistThreshold = ref(3) +const blacklistDuration = ref(30) +const blacklistLoading = ref(false) +const blacklistSaving = ref(false) + const goBack = () => { router.push('/') } @@ -24,11 +45,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 } @@ -42,8 +71,19 @@ 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) { console.error('failed to save app settings', error) @@ -52,8 +92,137 @@ 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('已是最新版本') + } else { + // 发现新版本,提示用户并开始下载 + const confirmed = confirm(`发现新版本 ${info.version},是否立即下载?`) + if (confirmed) { + downloading.value = true + checking.value = false + try { + await downloadUpdate() + await loadUpdateState() + + // 下载完成,提示重启 + const restart = confirm('新版本已下载完成,是否立即重启应用?') + if (restart) { + await restartApp() + } + } catch (downloadError) { + console.error('download failed', downloadError) + alert('下载失败,请稍后重试') + } finally { + downloading.value = false + } + } + } + } 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} 天前` + } +} + +// 加载拉黑配置 +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() + + // 加载当前版本号 + try { + appVersion.value = await fetchCurrentVersion() + } catch (error) { + console.error('failed to load app version', error) + } + + // 加载更新状态 + await loadUpdateState() + + // 加载拉黑配置 + await loadBlacklistSettings() }) @@ -115,6 +284,47 @@ onMounted(() => { +
+

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

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

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

@@ -126,6 +336,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..7a53ac2 100644 --- a/frontend/src/components/Main/Index.vue +++ b/frontend/src/components/Main/Index.vue @@ -4,9 +4,12 @@

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

+
@@ -324,6 +351,25 @@ {{ stats.cost }}

+ +
+ + + {{ t('components.main.blacklist.blocked') }} | + {{ t('components.main.blacklist.remaining') }}: + {{ formatBlacklistCountdown(getProviderBlacklistStatus(card.name)!.remainingSeconds) }} + + +
@@ -529,7 +575,7 @@