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(() => {
+
+
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 @@