diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bc967eb..c260414 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,9 @@ "permissions": { "allow": [ "WebSearch", - "Bash(find:*)" + "Bash(find:*)", + "mcp__exa__web_search_exa", + "mcp__ace-tool__search_context" ], "deny": [], "ask": [] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41139f7..f1da945 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,10 @@ jobs: - uses: actions/checkout@v4 - name: Update version from tag + id: version run: | VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "Updating version to: $VERSION" sed -i '' "s/const AppVersion = \"v[^\"]*\"/const AppVersion = \"v$VERSION\"/" version_service.go echo "Updated version_service.go:" @@ -64,13 +66,13 @@ jobs: - name: Archive macOS app run: | cd bin - ditto -c -k --sequesterRsrc --keepParent "$(basename ${{ steps.find-app.outputs.app_path }})" codeswitch-macos-${{ matrix.arch }}.zip + ditto -c -k --sequesterRsrc --keepParent "$(basename ${{ steps.find-app.outputs.app_path }})" CodeSwitch-v${{ steps.version.outputs.VERSION }}-macos-${{ matrix.arch }}.zip - name: Upload artifact uses: actions/upload-artifact@v4 with: name: macos-${{ matrix.arch }} - path: bin/codeswitch-macos-${{ matrix.arch }}.zip + path: bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}-macos-${{ matrix.arch }}.zip build-windows: name: Build Windows @@ -79,9 +81,11 @@ jobs: - uses: actions/checkout@v4 - name: Update version from tag + id: version shell: pwsh run: | $VERSION = "${{ github.ref_name }}".TrimStart('v') + "VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_OUTPUT -Append Write-Host "Updating version to: $VERSION" # Update version_service.go @@ -161,11 +165,30 @@ jobs: go build -ldflags="-w -s -H windowsgui" -o bin/updater.exe ./cmd/updater Remove-Item cmd/updater/*.syso -ErrorAction SilentlyContinue + - name: Rename files with version + shell: pwsh + run: | + $VERSION = "${{ steps.version.outputs.VERSION }}" + Push-Location bin + Rename-Item "CodeSwitch.exe" "CodeSwitch-v$VERSION.exe" + Rename-Item "updater.exe" "updater-v$VERSION.exe" + Pop-Location + # Rename installer (generated by NSIS) + if (Test-Path "build/windows/nsis/CodeSwitch-amd64-installer.exe") { + Move-Item "build/windows/nsis/CodeSwitch-amd64-installer.exe" "bin/CodeSwitch-v$VERSION-amd64-installer.exe" + } elseif (Test-Path "bin/CodeSwitch-amd64-installer.exe") { + Rename-Item "bin/CodeSwitch-amd64-installer.exe" "CodeSwitch-v$VERSION-amd64-installer.exe" + } + Write-Host "Files renamed:" + Get-ChildItem bin + - name: Generate SHA256 Checksums + shell: pwsh run: | + $VERSION = "${{ steps.version.outputs.VERSION }}" Push-Location bin - Get-FileHash -Algorithm SHA256 CodeSwitch.exe | ForEach-Object { "$($_.Hash.ToLower()) CodeSwitch.exe" } | Out-File -Encoding ascii CodeSwitch.exe.sha256 - Get-FileHash -Algorithm SHA256 updater.exe | ForEach-Object { "$($_.Hash.ToLower()) updater.exe" } | Out-File -Encoding ascii updater.exe.sha256 + Get-FileHash -Algorithm SHA256 "CodeSwitch-v$VERSION.exe" | ForEach-Object { "$($_.Hash.ToLower()) CodeSwitch-v$VERSION.exe" } | Out-File -Encoding ascii "CodeSwitch-v$VERSION.exe.sha256" + Get-FileHash -Algorithm SHA256 "updater-v$VERSION.exe" | ForEach-Object { "$($_.Hash.ToLower()) updater-v$VERSION.exe" } | Out-File -Encoding ascii "updater-v$VERSION.exe.sha256" Write-Host "SHA256 checksums generated:" Get-Content *.sha256 Pop-Location @@ -175,11 +198,11 @@ jobs: with: name: windows-amd64 path: | - bin/CodeSwitch-amd64-installer.exe - bin/CodeSwitch.exe - bin/CodeSwitch.exe.sha256 - bin/updater.exe - bin/updater.exe.sha256 + bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}-amd64-installer.exe + bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}.exe + bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}.exe.sha256 + bin/updater-v${{ steps.version.outputs.VERSION }}.exe + bin/updater-v${{ steps.version.outputs.VERSION }}.exe.sha256 build-linux: name: Build Linux @@ -188,8 +211,10 @@ jobs: - uses: actions/checkout@v4 - name: Update version from tag + id: version run: | VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "Updating version to: $VERSION" sed -i "s/const AppVersion = \"v[^\"]*\"/const AppVersion = \"v$VERSION\"/" version_service.go echo "Updated version_service.go:" @@ -237,18 +262,19 @@ jobs: - name: Rename AppImage run: | + VERSION=${{ steps.version.outputs.VERSION }} cd bin echo "Files in bin/:" ls -la # linuxdeploy creates AppImage with lowercase name and arch suffix for f in *-x86_64.AppImage *-aarch64.AppImage; do if [ -f "$f" ]; then - mv "$f" CodeSwitch.AppImage - echo "Renamed $f -> CodeSwitch.AppImage" + mv "$f" "CodeSwitch-v${VERSION}.AppImage" + echo "Renamed $f -> CodeSwitch-v${VERSION}.AppImage" break fi done - ls -la CodeSwitch.AppImage + ls -la "CodeSwitch-v${VERSION}.AppImage" - name: Set nfpm script permissions run: | @@ -269,8 +295,9 @@ jobs: - name: Generate SHA256 Checksums run: | + VERSION=${{ steps.version.outputs.VERSION }} cd bin - sha256sum CodeSwitch.AppImage > CodeSwitch.AppImage.sha256 + sha256sum "CodeSwitch-v${VERSION}.AppImage" > "CodeSwitch-v${VERSION}.AppImage.sha256" for f in codeswitch_*.deb; do [ -f "$f" ] && sha256sum "$f" > "${f}.sha256"; done for f in codeswitch-*.rpm; do [ -f "$f" ] && sha256sum "$f" > "${f}.sha256"; done echo "SHA256 checksums:" @@ -281,8 +308,8 @@ jobs: with: name: linux-amd64 path: | - bin/CodeSwitch.AppImage - bin/CodeSwitch.AppImage.sha256 + bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}.AppImage + bin/CodeSwitch-v${{ steps.version.outputs.VERSION }}.AppImage.sha256 bin/codeswitch_*.deb bin/codeswitch_*.deb.sha256 bin/codeswitch-*.rpm @@ -305,22 +332,23 @@ jobs: - name: Prepare release assets run: | + VERSION=${GITHUB_REF#refs/tags/v} mkdir -p release-assets # macOS - cp artifacts/macos-arm64/codeswitch-macos-arm64.zip release-assets/ - cp artifacts/macos-amd64/codeswitch-macos-amd64.zip release-assets/ + cp artifacts/macos-arm64/CodeSwitch-v${VERSION}-macos-arm64.zip release-assets/ + cp artifacts/macos-amd64/CodeSwitch-v${VERSION}-macos-amd64.zip release-assets/ # Windows - cp artifacts/windows-amd64/CodeSwitch-amd64-installer.exe release-assets/ - cp artifacts/windows-amd64/CodeSwitch.exe release-assets/ - cp artifacts/windows-amd64/CodeSwitch.exe.sha256 release-assets/ - cp artifacts/windows-amd64/updater.exe release-assets/ - cp artifacts/windows-amd64/updater.exe.sha256 release-assets/ + cp artifacts/windows-amd64/CodeSwitch-v${VERSION}-amd64-installer.exe release-assets/ + cp artifacts/windows-amd64/CodeSwitch-v${VERSION}.exe release-assets/ + cp artifacts/windows-amd64/CodeSwitch-v${VERSION}.exe.sha256 release-assets/ + cp artifacts/windows-amd64/updater-v${VERSION}.exe release-assets/ + cp artifacts/windows-amd64/updater-v${VERSION}.exe.sha256 release-assets/ # Linux - cp artifacts/linux-amd64/CodeSwitch.AppImage release-assets/ - cp artifacts/linux-amd64/CodeSwitch.AppImage.sha256 release-assets/ + cp artifacts/linux-amd64/CodeSwitch-v${VERSION}.AppImage release-assets/ + cp artifacts/linux-amd64/CodeSwitch-v${VERSION}.AppImage.sha256 release-assets/ cp artifacts/linux-amd64/codeswitch_*.deb release-assets/ 2>/dev/null || true cp artifacts/linux-amd64/codeswitch_*.deb.sha256 release-assets/ 2>/dev/null || true cp artifacts/linux-amd64/codeswitch-*.rpm release-assets/ 2>/dev/null || true @@ -330,12 +358,13 @@ jobs: - name: Generate latest.json run: | VERSION=${GITHUB_REF#refs/tags/} + VERSION_NUM=${GITHUB_REF#refs/tags/v} REPO="${{ github.repository }}" BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" # Read SHA256 checksums from existing .sha256 files - WIN_SHA=$(cut -d' ' -f1 release-assets/CodeSwitch.exe.sha256) - LINUX_SHA=$(cut -d' ' -f1 release-assets/CodeSwitch.AppImage.sha256) + WIN_SHA=$(cut -d' ' -f1 release-assets/CodeSwitch-${VERSION}.exe.sha256) + LINUX_SHA=$(cut -d' ' -f1 release-assets/CodeSwitch-${VERSION}.AppImage.sha256) cat > release-assets/latest.json << EOF { @@ -343,21 +372,21 @@ jobs: "release_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", "files": { "windows": { - "name": "CodeSwitch.exe", - "url": "${BASE_URL}/CodeSwitch.exe", + "name": "CodeSwitch-${VERSION}.exe", + "url": "${BASE_URL}/CodeSwitch-${VERSION}.exe", "sha256": "${WIN_SHA}" }, "darwin-arm64": { - "name": "codeswitch-macos-arm64.zip", - "url": "${BASE_URL}/codeswitch-macos-arm64.zip" + "name": "CodeSwitch-${VERSION}-macos-arm64.zip", + "url": "${BASE_URL}/CodeSwitch-${VERSION}-macos-arm64.zip" }, "darwin-amd64": { - "name": "codeswitch-macos-amd64.zip", - "url": "${BASE_URL}/codeswitch-macos-amd64.zip" + "name": "CodeSwitch-${VERSION}-macos-amd64.zip", + "url": "${BASE_URL}/CodeSwitch-${VERSION}-macos-amd64.zip" }, "linux": { - "name": "CodeSwitch.AppImage", - "url": "${BASE_URL}/CodeSwitch.AppImage", + "name": "CodeSwitch-${VERSION}.AppImage", + "url": "${BASE_URL}/CodeSwitch-${VERSION}.AppImage", "sha256": "${LINUX_SHA}" } } @@ -386,7 +415,7 @@ jobs: # 组合完整的 release body cat /tmp/version_notes.md > /tmp/release_body.md - cat >> /tmp/release_body.md << 'EOF' + cat >> /tmp/release_body.md << EOF --- @@ -394,46 +423,46 @@ jobs: | 平台 | 文件 | 说明 | |------|------|------| - | **Windows (首次)** | `CodeSwitch-amd64-installer.exe` | NSIS 安装器 | - | **Windows (便携)** | `CodeSwitch.exe` | 直接运行 | - | **Windows (更新)** | `updater.exe` | 静默更新辅助 | - | **macOS (ARM)** | `codeswitch-macos-arm64.zip` | Apple Silicon | - | **macOS (Intel)** | `codeswitch-macos-amd64.zip` | Intel 芯片 | - | **Linux (通用)** | `CodeSwitch.AppImage` | 跨发行版便携 | - | **Linux (Debian/Ubuntu)** | `codeswitch_*.deb` | apt 安装 | - | **Linux (RHEL/Fedora)** | `codeswitch-*.rpm` | dnf/yum 安装 | + | **Windows (首次)** | \`CodeSwitch-v${VERSION}-amd64-installer.exe\` | NSIS 安装器 | + | **Windows (便携)** | \`CodeSwitch-v${VERSION}.exe\` | 直接运行 | + | **Windows (更新)** | \`updater-v${VERSION}.exe\` | 静默更新辅助 | + | **macOS (ARM)** | \`CodeSwitch-v${VERSION}-macos-arm64.zip\` | Apple Silicon | + | **macOS (Intel)** | \`CodeSwitch-v${VERSION}-macos-amd64.zip\` | Intel 芯片 | + | **Linux (通用)** | \`CodeSwitch-v${VERSION}.AppImage\` | 跨发行版便携 | + | **Linux (Debian/Ubuntu)** | \`codeswitch_*.deb\` | apt 安装 | + | **Linux (RHEL/Fedora)** | \`codeswitch-*.rpm\` | dnf/yum 安装 | ## Linux 安装 ### AppImage (推荐) - ```bash - chmod +x CodeSwitch.AppImage - ./CodeSwitch.AppImage - ``` - 如遇 FUSE 问题:`./CodeSwitch.AppImage --appimage-extract-and-run` + \`\`\`bash + chmod +x CodeSwitch-v${VERSION}.AppImage + ./CodeSwitch-v${VERSION}.AppImage + \`\`\` + 如遇 FUSE 问题:\`./CodeSwitch-v${VERSION}.AppImage --appimage-extract-and-run\` ### Debian/Ubuntu - ```bash + \`\`\`bash sudo dpkg -i codeswitch_*.deb sudo apt-get install -f # 安装依赖 - ``` + \`\`\` ### RHEL/Fedora - ```bash + \`\`\`bash sudo rpm -i codeswitch-*.rpm # 或 sudo dnf install codeswitch-*.rpm - ``` + \`\`\` ## 文件校验 - 所有平台均提供 SHA256 校验文件(`.sha256`),下载后可验证完整性: - ```bash + 所有平台均提供 SHA256 校验文件(\`.sha256\`),下载后可验证完整性: + \`\`\`bash # Linux/macOS - sha256sum -c CodeSwitch.AppImage.sha256 + sha256sum -c CodeSwitch-v${VERSION}.AppImage.sha256 # Windows PowerShell - Get-FileHash CodeSwitch.exe | Format-List - ``` + Get-FileHash CodeSwitch-v${VERSION}.exe | Format-List + \`\`\` EOF echo "Generated release body:" diff --git a/.gitignore b/.gitignore index 3444dd2..040cb4d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ frontend/dist .task bin CLAUDE.md +.ace-tool/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f2be55a..8542662 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,14 @@ +# Code Switch v2.6.14 + +## 新功能 +- **自适应热力图**:新增供应商使用热力图功能,直观展示各供应商的请求分布情况,支持按时间范围筛选 +- **图标搜索**:新增供应商图标搜索功能,方便快速查找和选择合适的图标 + +## 修复 +- 修复控制台日志递归爆炸问题 + +--- + # Code Switch v2.0.0 ## 新功能 diff --git a/frontend/src/components/General/Index.vue b/frontend/src/components/General/Index.vue index 98deea3..a2a11de 100644 --- a/frontend/src/components/General/Index.vue +++ b/frontend/src/components/General/Index.vue @@ -22,6 +22,12 @@ const getCachedValue = (key: string, defaultValue: boolean): boolean => { const cached = localStorage.getItem(`app-settings-${key}`) return cached !== null ? cached === 'true' : defaultValue } +const getCachedNumber = (key: string, defaultValue: number): number => { + const cached = localStorage.getItem(`app-settings-${key}`) + if (cached === null) return defaultValue + const parsed = Number(cached) + return Number.isFinite(parsed) ? parsed : defaultValue +} const heatmapEnabled = ref(getCachedValue('heatmap', true)) const homeTitleVisible = ref(getCachedValue('homeTitle', true)) const autoStartEnabled = ref(getCachedValue('autoStart', false)) @@ -29,6 +35,7 @@ const autoUpdateEnabled = ref(getCachedValue('autoUpdate', true)) const autoConnectivityTestEnabled = ref(getCachedValue('autoConnectivityTest', false)) const switchNotifyEnabled = ref(getCachedValue('switchNotify', true)) // 切换通知开关 const roundRobinEnabled = ref(getCachedValue('roundRobin', false)) // 同 Level 轮询开关 +const budgetTotal = ref(getCachedNumber('budgetTotal', 0)) const settingsLoading = ref(true) const saveBusy = ref(false) @@ -62,6 +69,7 @@ const loadAppSettings = async () => { const data = await fetchAppSettings() heatmapEnabled.value = data?.show_heatmap ?? true homeTitleVisible.value = data?.show_home_title ?? true + budgetTotal.value = Number(data?.budget_total ?? 0) autoStartEnabled.value = data?.auto_start ?? false autoUpdateEnabled.value = data?.auto_update ?? true autoConnectivityTestEnabled.value = data?.auto_connectivity_test ?? false @@ -71,6 +79,7 @@ const loadAppSettings = async () => { // 缓存到 localStorage,下次打开时直接显示正确状态 localStorage.setItem('app-settings-heatmap', String(heatmapEnabled.value)) localStorage.setItem('app-settings-homeTitle', String(homeTitleVisible.value)) + localStorage.setItem('app-settings-budgetTotal', String(budgetTotal.value)) localStorage.setItem('app-settings-autoStart', String(autoStartEnabled.value)) localStorage.setItem('app-settings-autoUpdate', String(autoUpdateEnabled.value)) localStorage.setItem('app-settings-autoConnectivityTest', String(autoConnectivityTestEnabled.value)) @@ -80,6 +89,7 @@ const loadAppSettings = async () => { console.error('failed to load app settings', error) heatmapEnabled.value = true homeTitleVisible.value = true + budgetTotal.value = 0 autoStartEnabled.value = false autoUpdateEnabled.value = true autoConnectivityTestEnabled.value = false @@ -94,9 +104,12 @@ const persistAppSettings = async () => { if (settingsLoading.value || saveBusy.value) return saveBusy.value = true try { + const normalizedBudgetTotal = Number.isFinite(budgetTotal.value) ? Math.max(0, budgetTotal.value) : 0 + budgetTotal.value = normalizedBudgetTotal const payload: AppSettings = { show_heatmap: heatmapEnabled.value, show_home_title: homeTitleVisible.value, + budget_total: normalizedBudgetTotal, auto_start: autoStartEnabled.value, auto_update: autoUpdateEnabled.value, auto_connectivity_test: autoConnectivityTestEnabled.value, @@ -117,6 +130,7 @@ const persistAppSettings = async () => { // 更新缓存 localStorage.setItem('app-settings-heatmap', String(heatmapEnabled.value)) localStorage.setItem('app-settings-homeTitle', String(homeTitleVisible.value)) + localStorage.setItem('app-settings-budgetTotal', String(budgetTotal.value)) localStorage.setItem('app-settings-autoStart', String(autoStartEnabled.value)) localStorage.setItem('app-settings-autoUpdate', String(autoUpdateEnabled.value)) localStorage.setItem('app-settings-autoConnectivityTest', String(autoConnectivityTestEnabled.value)) @@ -416,6 +430,24 @@ onMounted(async () => { + + + + + USD + + {{ $t('components.general.label.budgetTotalHint') }} + + { diff --git a/frontend/src/components/Logs/Index.vue b/frontend/src/components/Logs/Index.vue index 4412c48..a265155 100644 --- a/frontend/src/components/Logs/Index.vue +++ b/frontend/src/components/Logs/Index.vue @@ -16,11 +16,14 @@ {{ card.label }} - {{ card.value }} + + {{ card.value }} + ({{ card.subValue }}) + {{ card.hint }} @@ -68,6 +71,7 @@ {{ t('components.logs.table.httpCode') }} {{ t('components.logs.table.stream') }} {{ t('components.logs.table.duration') }} + {{ t('components.logs.table.cost') }} {{ t('components.logs.table.tokens') }} @@ -80,31 +84,32 @@ {{ item.http_code }} {{ formatStream(item.is_stream) }} {{ formatDuration(item.duration_sec) }} + {{ formatCurrency(item.total_cost) }} {{ t('components.logs.tokenLabels.input') }} - {{ formatNumber(item.input_tokens) }} + {{ formatTokenNumber(item.input_tokens) }} {{ t('components.logs.tokenLabels.output') }} - {{ formatNumber(item.output_tokens) }} + {{ formatTokenNumber(item.output_tokens) }} {{ t('components.logs.tokenLabels.reasoning') }} - {{ formatNumber(item.reasoning_tokens) }} + {{ formatTokenNumber(item.reasoning_tokens) }} {{ t('components.logs.tokenLabels.cacheWrite') }} - {{ formatNumber(item.cache_create_tokens) }} + {{ formatTokenNumber(item.cache_create_tokens) }} {{ t('components.logs.tokenLabels.cacheRead') }} - {{ formatNumber(item.cache_read_tokens) }} + {{ formatTokenNumber(item.cache_read_tokens) }} - {{ t('components.logs.empty') }} + {{ t('components.logs.empty') }} @@ -144,6 +149,26 @@ + + + + + + + {{ t('components.logs.tokenLabels.input') }} + {{ formatTokenNumber(stats?.input_tokens) }} + + + {{ t('components.logs.tokenLabels.output') }} + {{ formatTokenNumber(stats?.output_tokens) }} + + + + @@ -201,6 +226,13 @@ const costDetailModal = reactive<{ data: [], }) +// Token 明细弹窗状态 +const tokenDetailModal = reactive<{ + open: boolean +}>({ + open: false, +}) + // 打开金额明细弹窗 const openCostDetailModal = async () => { costDetailModal.open = true @@ -225,6 +257,25 @@ const closeCostDetailModal = () => { costDetailModal.open = false } +// 处理卡片点击 +const handleCardClick = (key: string) => { + if (key === 'cost') { + openCostDetailModal() + } else if (key === 'tokens') { + openTokenDetailModal() + } +} + +// 打开 Token 明细弹窗 +const openTokenDetailModal = () => { + tokenDetailModal.open = true +} + +// 关闭 Token 明细弹窗 +const closeTokenDetailModal = () => { + tokenDetailModal.open = false +} + const parseLogDate = (value?: string) => { if (!value) return null const normalize = value.replace(' ', 'T') @@ -512,6 +563,44 @@ const formatNumber = (value?: number) => { return value.toLocaleString() } +/** + * 格式化 token 数值,支持 k/M/B 单位换算 + * @author sm + */ +const formatTokenNumber = (value?: number) => { + if (value === undefined || value === null) return '—' + + if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(2)}B` + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(2)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(2)}k` + } + + return value.toLocaleString() +} + +/** + * 计算缓存命中率 + * @param cacheRead 缓存读取 token 数 + * @param inputTokens 输入 token 数 + * @returns 命中率百分比字符串 + * @author sm + */ +const formatCacheHitRate = (cacheRead?: number, inputTokens?: number) => { + const read = cacheRead ?? 0 + const input = inputTokens ?? 0 + const total = read + input + + if (total === 0) return '0%' + + const rate = (read / total) * 100 + return `${rate.toFixed(1)}%` +} + const formatCurrency = (value?: number) => { if (value === undefined || value === null || Number.isNaN(value)) { return '$0.0000' @@ -547,13 +636,14 @@ const statsCards = computed(() => { key: 'tokens', label: t('components.logs.summary.tokens'), hint: t('components.logs.summary.tokenHint'), - value: data ? formatNumber(totalTokens) : '—', + value: data ? formatTokenNumber(totalTokens) : '—', }, { key: 'cacheReads', label: t('components.logs.summary.cache'), hint: t('components.logs.summary.cacheHint'), - value: data ? formatNumber(data.cache_read_tokens) : '—', + value: data ? formatTokenNumber(data.cache_read_tokens) : '—', + subValue: data ? formatCacheHitRate(data.cache_read_tokens, data.input_tokens) : '', }, { key: 'cost', @@ -646,6 +736,13 @@ onUnmounted(() => { color: #94a3b8; } +.summary-card__sub-value { + font-size: 0.65em; + font-weight: 400; + color: #64748b; + margin-left: 0.25rem; +} + html.dark .summary-card { border-color: rgba(255, 255, 255, 0.12); background: radial-gradient(circle at top, rgba(148, 163, 184, 0.2), rgba(15, 23, 42, 0.35)); @@ -663,6 +760,10 @@ html.dark .summary-card__hint { color: rgba(186, 194, 210, 0.8); } +html.dark .summary-card__sub-value { + color: #94a3b8; +} + @media (max-width: 768px) { .logs-summary { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); @@ -737,4 +838,54 @@ html.dark .cost-detail-item__name { color: #f97316; font-variant-numeric: tabular-nums; } + +/* Token 弹窗 */ +.token-detail-modal { + min-height: 80px; +} +.token-detail-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.token-detail-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: rgba(148, 163, 184, 0.08); + border-radius: 8px; + transition: background 0.15s ease; +} +.token-detail-item:hover { + background: rgba(148, 163, 184, 0.12); +} +html.dark .token-detail-item { + background: rgba(148, 163, 184, 0.12); +} +html.dark .token-detail-item:hover { + background: rgba(148, 163, 184, 0.18); +} +.token-detail-item__name { + font-weight: 500; + color: #1e293b; +} +html.dark .token-detail-item__name { + color: #f1f5f9; +} +.token-detail-item__value { + font-weight: 600; + color: #34d399; + font-variant-numeric: tabular-nums; +} + +/* 金额列 */ +.col-cost { + width: 80px; +} +.cost-cell { + color: #f97316; + font-weight: 500; + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/components/Main/Index.vue b/frontend/src/components/Main/Index.vue index a52abeb..8e6ef38 100644 --- a/frontend/src/components/Main/Index.vue +++ b/frontend/src/components/Main/Index.vue @@ -498,7 +498,7 @@ class="ghost-icon direct-apply-btn" :class="{ 'is-active': isDirectApplied(card) && !activeProxyState }" :disabled="activeProxyState" - :title="activeProxyState ? t('components.main.directApply.proxyEnabled') : (isDirectApplied(card) ? t('components.main.directApply.inUse') : t('components.main.directApply.title'))" + :data-tooltip="activeProxyState ? t('components.main.directApply.proxyEnabled') : (isDirectApplied(card) ? t('components.main.directApply.inUse') : t('components.main.directApply.title'))" @click.stop="!isDirectApplied(card) && handleDirectApply(card)" > {{ t('components.main.directApply.inUse') }} @@ -506,7 +506,7 @@ - + - + {{ t('components.main.form.labels.icon') }} - + @@ -667,8 +667,18 @@ + + + {{ iconName }} + + {{ t('components.main.form.noIconResults') }} + @@ -987,14 +1000,8 @@ import { computed, reactive, ref, onMounted, onUnmounted, watch } from 'vue' import { useI18n } from 'vue-i18n' import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/vue' import { Browser, Call, Events } from '@wailsio/runtime' -import { - buildUsageHeatmapMatrix, - generateFallbackUsageHeatmap, - DEFAULT_HEATMAP_DAYS, - calculateHeatmapDayRange, - type UsageHeatmapWeek, - type UsageHeatmapDay, -} from '../../data/usageHeatmap' +import { type UsageHeatmapDay } from '../../data/usageHeatmap' +import { useAdaptiveHeatmap } from '../../composables/useAdaptiveHeatmap' import { automationCardGroups, createAutomationCards, type AutomationCard } from '../../data/cards' import lobeIcons from '../../icons/lobeIconMap' import BaseButton from '../common/BaseButton.vue' @@ -1008,7 +1015,7 @@ import { LoadProviders, SaveProviders, DuplicateProvider } from '../../../bindin import { GetProviders as GetGeminiProviders, UpdateProvider as UpdateGeminiProvider, AddProvider as AddGeminiProvider, DeleteProvider as DeleteGeminiProvider, ReorderProviders as ReorderGeminiProviders } from '../../../bindings/codeswitch/services/geminiservice' import { fetchProxyStatus, enableProxy, disableProxy } from '../../services/claudeSettings' import { fetchGeminiProxyStatus, enableGeminiProxy, disableGeminiProxy } from '../../services/geminiSettings' -import { fetchHeatmapStats, fetchProviderDailyStats, type ProviderDailyStat } from '../../services/logs' +import { fetchProviderDailyStats, type ProviderDailyStat } from '../../services/logs' import { fetchCurrentVersion } from '../../services/version' import { fetchAppSettings, type AppSettings } from '../../services/appSettings' import { getUpdateState, restartApp, type UpdateState } from '../../services/update' @@ -1059,9 +1066,14 @@ const themeIcon = computed(() => (resolvedTheme.value === 'dark' ? 'moon' : 'sun const releasePageUrl = 'https://github.com/Rogers-F/code-switch-R/releases' const releaseApiUrl = 'https://api.github.com/repos/Rogers-F/code-switch-R/releases/latest' -const HEATMAP_DAYS = DEFAULT_HEATMAP_DAYS -const usageHeatmap = ref(generateFallbackUsageHeatmap(HEATMAP_DAYS)) const heatmapContainerRef = ref(null) +// 使用自适应热力图 composable +const { + displayData: usageHeatmap, + init: initHeatmap, + cleanup: cleanupHeatmap, + reload: reloadHeatmap, +} = useAdaptiveHeatmap(heatmapContainerRef) const tooltipRef = ref(null) const proxyStates = reactive>({ claude: false, @@ -1267,6 +1279,23 @@ const usageTooltip = reactive({ const formatMetric = (value: number) => value.toLocaleString() +/** + * 格式化 token 数值,支持 k/M/B 单位换算 + * @author sm + */ +const formatTokenNumber = (value: number) => { + if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(2)}B` + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(2)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(2)}k` + } + return value.toLocaleString() +} + const tooltipDateFormatter = computed(() => new Intl.DateTimeFormat(locale.value || 'en', { month: 'short', @@ -1312,17 +1341,17 @@ const usageTooltipMetrics = computed(() => [ { key: 'inputTokens', label: t('components.main.heatmap.metrics.inputTokens'), - value: formatMetric(usageTooltip.inputTokens), + value: formatTokenNumber(usageTooltip.inputTokens), }, { key: 'outputTokens', label: t('components.main.heatmap.metrics.outputTokens'), - value: formatMetric(usageTooltip.outputTokens), + value: formatTokenNumber(usageTooltip.outputTokens), }, { key: 'reasoningTokens', label: t('components.main.heatmap.metrics.reasoningTokens'), - value: formatMetric(usageTooltip.reasoningTokens), + value: formatTokenNumber(usageTooltip.reasoningTokens), }, ]) @@ -1489,18 +1518,6 @@ const compareVersions = (current: string, remote: string) => { return 0 } -const loadUsageHeatmap = async () => { - try { - const rangeDays = calculateHeatmapDayRange(HEATMAP_DAYS) - const stats = await fetchHeatmapStats(rangeDays) - usageHeatmap.value = buildUsageHeatmapMatrix(stats, HEATMAP_DAYS) - } catch (error) { - console.error('Failed to load usage heatmap stats', error) - // 加载热力图失败时提示用户 - showToast(t('components.main.errors.loadHeatmapFailed'), 'warning') - } -} - // 本地 GeminiProvider 类型定义(避免依赖 CI 生成的 bindings) interface GeminiProvider { id: string @@ -2030,7 +2047,7 @@ const refreshAllData = async () => { refreshing.value = true try { await Promise.all([ - loadUsageHeatmap(), + reloadHeatmap(), loadProvidersFromDisk(), ...providerTabIds.map(refreshProxyState), ...providerTabIds.map((tab) => refreshDirectAppliedStatus(tab)), @@ -2096,7 +2113,7 @@ const providerStatDisplay = (providerName: string): ProviderStatDisplay => { return { state: 'ready', requests: `${t('components.main.providers.requests')}: ${formatMetric(stat.total_requests)}`, - tokens: `${t('components.main.providers.tokens')}: ${formatMetric(totalTokens)}`, + tokens: `${t('components.main.providers.tokens')}: ${formatTokenNumber(totalTokens)}`, cost: `${t('components.main.providers.cost')}: ${currencyFormatter.value.format(Math.max(stat.cost_total, 0))}`, successRateLabel, successRateClass, @@ -2234,7 +2251,7 @@ let unsubscribeSwitched: (() => void) | undefined let unsubscribeBlacklisted: (() => void) | undefined onMounted(async () => { - void loadUsageHeatmap() + void initHeatmap() await loadProvidersFromDisk() await Promise.all(providerTabIds.map(refreshProxyState)) await Promise.all(providerTabIds.map((tab) => refreshDirectAppliedStatus(tab))) @@ -2300,6 +2317,7 @@ onMounted(async () => { }) onUnmounted(() => { + cleanupHeatmap() stopProviderStatsTimer() window.removeEventListener('app-settings-updated', handleAppSettingsUpdated) stopUpdateTimer() @@ -2512,6 +2530,14 @@ type VendorForm = { const iconOptions = Object.keys(lobeIcons).sort((a, b) => a.localeCompare(b)) const defaultIconKey = iconOptions[0] ?? 'aicoding' +// 图标搜索筛选 +const iconSearchQuery = ref('') +const filteredIconOptions = computed(() => { + const query = iconSearchQuery.value.toLowerCase().trim() + if (!query) return iconOptions + return iconOptions.filter(name => name.toLowerCase().includes(query)) +}) + const defaultFormValues = (platform?: string): VendorForm => ({ name: '', apiUrl: '', diff --git a/frontend/src/composables/useAdaptiveHeatmap.ts b/frontend/src/composables/useAdaptiveHeatmap.ts new file mode 100644 index 0000000..c5fa00c --- /dev/null +++ b/frontend/src/composables/useAdaptiveHeatmap.ts @@ -0,0 +1,208 @@ +/** + * 自适应热力图 Composable + * @author sm + * @description 封装热力图自适应逻辑,根据容器宽度动态计算显示的列数 + */ +import { ref, computed, type Ref } from 'vue' +import { + HEATMAP_ROWS, + BUCKETS_PER_DAY, + buildUsageHeatmapMatrix, + generateFallbackUsageHeatmap, + type UsageHeatmapWeek, +} from '../data/usageHeatmap' +import { fetchHeatmapStats } from '../services/logs' + +// 格子尺寸配置(与 CSS 媒体查询保持一致) +const CELL_SIZES = { + large: { cell: 14, gap: 4, padding: 32 }, // > 960px + medium: { cell: 12, gap: 3, padding: 24 }, // 640-960px + small: { cell: 10, gap: 2, padding: 16 }, // < 640px +} as const + +// 边界限制 +const MIN_COLUMNS = 9 // 最少显示 3 天 (3×3) +const MAX_COLUMNS = 63 // 最多显示 21 天 (21×3) +const MAX_DAYS = 21 // API 请求的最大天数 +const DEFAULT_DAYS = 14 // 默认天数 + +/** + * 自适应热力图 Composable + * @param containerRef 热力图容器的 ref 引用 + */ +export function useAdaptiveHeatmap(containerRef: Ref) { + // 响应式状态 + const containerWidth = ref(0) + const visibleColumns = ref(DEFAULT_DAYS * BUCKETS_PER_DAY) // 默认 14 天 + const heatmapData = ref(generateFallbackUsageHeatmap(DEFAULT_DAYS)) + const isLoading = ref(false) + const loadedDays = ref(0) // 已加载的天数 + + /** + * 获取当前视口下的格子尺寸配置 + */ + const cellConfig = computed(() => { + const width = containerWidth.value + if (width > 960) return CELL_SIZES.large + if (width > 640) return CELL_SIZES.medium + return CELL_SIZES.small + }) + + /** + * 计算可显示的列数 + * @param containerWidth 容器宽度 + */ + const calculateColumns = (containerWidth: number): number => { + const { cell, gap, padding } = cellConfig.value + const availableWidth = containerWidth - padding * 2 + const cellUnit = cell + gap + + // 计算可容纳的列数 + const cols = Math.floor((availableWidth + gap) / cellUnit) + + // 应用边界限制 + const bounded = Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, cols)) + + // 向下取整到 BUCKETS_PER_DAY (3) 的倍数,确保天数完整 + return Math.floor(bounded / BUCKETS_PER_DAY) * BUCKETS_PER_DAY + } + + /** + * 加载热力图数据 + * @param days 需要加载的天数 + */ + const loadHeatmapData = async (days: number) => { + // 如果已加载的天数足够,不重复请求 + if (loadedDays.value >= days && heatmapData.value.length > 0) { + return + } + + isLoading.value = true + try { + const stats = await fetchHeatmapStats(days) + heatmapData.value = buildUsageHeatmapMatrix(stats, days) + loadedDays.value = days + } catch (error) { + console.error('Failed to load heatmap data:', error) + } finally { + isLoading.value = false + } + } + + /** + * 节流函数 + */ + const throttle = void>(fn: T, delay: number) => { + let lastCall = 0 + let timeoutId: ReturnType | null = null + return (...args: Parameters) => { + const now = Date.now() + const remaining = delay - (now - lastCall) + if (remaining <= 0) { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + lastCall = now + fn(...args) + } else if (!timeoutId) { + timeoutId = setTimeout(() => { + lastCall = Date.now() + timeoutId = null + fn(...args) + }, remaining) + } + } + } + + /** + * 处理尺寸变化 + */ + const handleResize = throttle((width: number) => { + containerWidth.value = width + const newColumns = calculateColumns(width) + + // 只有列数变化时才更新 + if (newColumns !== visibleColumns.value) { + const newDays = Math.min(MAX_DAYS, newColumns / BUCKETS_PER_DAY) + visibleColumns.value = newColumns + + // 只在需要更多数据时重新请求 + if (newDays > loadedDays.value) { + void loadHeatmapData(newDays) + } + } + }, 150) // 150ms 节流 + + /** + * 裁剪显示的数据(只显示最新的 N 列) + */ + const displayData = computed(() => { + const data = heatmapData.value + if (data.length <= visibleColumns.value) { + return data + } + // 从最新的数据开始显示(数组末尾是最新的) + return data.slice(data.length - visibleColumns.value) + }) + + // ResizeObserver 实例 + let resizeObserver: ResizeObserver | null = null + + /** + * 初始化热力图 + */ + const init = async () => { + const container = containerRef.value + if (!container) return + + // 初始宽度计算 + const initialWidth = container.clientWidth + containerWidth.value = initialWidth + const initialColumns = calculateColumns(initialWidth) + visibleColumns.value = initialColumns + + // 加载初始数据 + const initialDays = Math.min(MAX_DAYS, initialColumns / BUCKETS_PER_DAY) + await loadHeatmapData(initialDays) + + // 设置 ResizeObserver + resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect + handleResize(width) + } + }) + resizeObserver.observe(container) + } + + /** + * 清理 ResizeObserver + */ + const cleanup = () => { + if (resizeObserver) { + resizeObserver.disconnect() + resizeObserver = null + } + } + + /** + * 重新加载数据 + */ + const reload = async () => { + loadedDays.value = 0 // 重置已加载天数,强制重新请求 + const days = Math.min(MAX_DAYS, visibleColumns.value / BUCKETS_PER_DAY) + await loadHeatmapData(days) + } + + return { + containerWidth, + visibleColumns, + displayData, + cellConfig, + isLoading, + init, + cleanup, + reload, + } +} diff --git a/frontend/src/data/usageHeatmap.ts b/frontend/src/data/usageHeatmap.ts index 268e79f..451f147 100644 --- a/frontend/src/data/usageHeatmap.ts +++ b/frontend/src/data/usageHeatmap.ts @@ -15,7 +15,7 @@ export type UsageHeatmapWeek = UsageHeatmapDay[] export const HEATMAP_ROWS = 8 export const BUCKETS_PER_DAY = 3 -export const DEFAULT_HEATMAP_DAYS = 14 +export const DEFAULT_HEATMAP_DAYS = 21 const HOURS_PER_BUCKET = 8 const LEVELS = 4 diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 7cea559..bc73f21 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -355,8 +355,10 @@ "connectivityTestModel": "Select or use default", "customModel": "Or enter custom model", "customEndpoint": "Or enter custom endpoint", - "customAuthHeader": "Custom header name (e.g. Authorization, X-Custom-Key)" + "customAuthHeader": "Custom header name (e.g. Authorization, X-Custom-Key)", + "searchIcon": "Search icons..." }, + "noIconResults": "No matching icons found", "hints": { "level": "Lower numbers = higher priority. Level 1 providers are tried first, then Level 2, etc.", "apiEndpoint": "Override platform default endpoint. Leave blank for default (claude: /v1/messages, codex: /responses). For GLM models use /v1/chat/completions", @@ -493,6 +495,7 @@ "httpCode": "HTTP", "stream": "Stream", "duration": "Duration", + "cost": "Cost", "tokens": "Tokens" }, "tokenLabels": { @@ -511,6 +514,9 @@ "costDetail": { "title": "Today's Cost Breakdown", "empty": "No cost records today" + }, + "tokenDetail": { + "title": "Today's Token Breakdown" } }, "general": { @@ -533,6 +539,8 @@ "theme": "Theme mode", "heatmap": "Show dashboard heatmap", "homeTitle": "Show home title", + "budgetTotal": "Budget total", + "budgetTotalHint": "Used for tray menu progress (USD)", "autoStart": "Launch at login", "switchNotify": "Switch Notification", "switchNotifyHint": "Send system notification when provider switches or gets blacklisted", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index e2cdb72..2ca35cf 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -356,8 +356,10 @@ "connectivityTestModel": "选择或使用默认", "customModel": "或输入自定义模型", "customEndpoint": "或输入自定义端点", - "customAuthHeader": "自定义 Header 名称(如 Authorization、X-Custom-Key)" + "customAuthHeader": "自定义 Header 名称(如 Authorization、X-Custom-Key)", + "searchIcon": "搜索图标..." }, + "noIconResults": "未找到匹配的图标", "hints": { "level": "数字越小优先级越高,Level 1 会被优先尝试,失败后依次尝试 Level 2、Level 3 等", "apiEndpoint": "覆盖平台默认端点。留空使用默认(claude: /v1/messages, codex: /responses)。GLM 模型请使用 /v1/chat/completions", @@ -494,6 +496,7 @@ "httpCode": "HTTP", "stream": "传输", "duration": "耗时", + "cost": "金额", "tokens": "Token 汇总" }, "tokenLabels": { @@ -512,6 +515,9 @@ "costDetail": { "title": "今日消费明细", "empty": "今日暂无消费记录" + }, + "tokenDetail": { + "title": "今日 Token 明细" } }, "general": { @@ -532,6 +538,8 @@ "theme": "主题模式", "heatmap": "显示主界面热力墙", "homeTitle": "显示首页标题", + "budgetTotal": "预算总额", + "budgetTotalHint": "用于托盘菜单进度条(USD)", "autoStart": "开机自启动", "switchNotify": "切换通知", "switchNotifyHint": "供应商切换或拉黑时发送系统通知", diff --git a/frontend/src/services/appSettings.ts b/frontend/src/services/appSettings.ts index b1f7365..339cc64 100644 --- a/frontend/src/services/appSettings.ts +++ b/frontend/src/services/appSettings.ts @@ -3,6 +3,7 @@ import { Call } from '@wailsio/runtime' export type AppSettings = { show_heatmap: boolean show_home_title: boolean + budget_total: number auto_start: boolean auto_update: boolean auto_connectivity_test: boolean @@ -13,6 +14,7 @@ export type AppSettings = { const DEFAULT_SETTINGS: AppSettings = { show_heatmap: true, show_home_title: true, + budget_total: 0, auto_start: false, auto_update: true, auto_connectivity_test: false, diff --git a/frontend/src/style.css b/frontend/src/style.css index f80d1bc..eec097a 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -376,37 +376,18 @@ html.dark .mac-sidebar-item:hover { position: relative; display: flex; gap: 4px; - overflow-x: auto; - padding-bottom: 8px; - scrollbar-width: thin; - scrollbar-color: rgba(255,255,255,0.2) transparent; + overflow: hidden; width: 100%; - justify-content: flex-start; -} - -.contrib-grid::-webkit-scrollbar { - height: 6px; -} - -.contrib-grid::-webkit-scrollbar-track { - background: transparent; -} - -.contrib-grid::-webkit-scrollbar-thumb { - background: rgba(255,255,255,0.2); - border-radius: 3px; -} - -.contrib-grid::-webkit-scrollbar-thumb:hover { - background: rgba(255,255,255,0.3); + justify-content: space-between; } .contrib-column { display: flex; flex-direction: column; gap: 4px; - flex: 0 0 auto; - min-width: 14px; + flex: 1 1 0; + min-width: 10px; + max-width: 18px; } .contrib-cell { @@ -500,7 +481,8 @@ html.dark .mac-sidebar-item:hover { } .contrib-column { - min-width: 12px; + min-width: 10px; + max-width: 15px; } .contrib-grid { @@ -519,13 +501,8 @@ html.dark .mac-sidebar-item:hover { } .contrib-column { - min-width: 10px; - } - - .contrib-cell, - .legend-box { - width: 10px; - height: 10px; + min-width: 8px; + max-width: 12px; } } .automation-section { @@ -1666,6 +1643,11 @@ html.dark .secondary-btn:active:not(:disabled) { padding-right: 32px; } +/* 通用宽度工具类 */ +.w-full { + width: 100%; +} + .icon-select { position: relative; width: 100%; @@ -1745,6 +1727,39 @@ html.dark .secondary-btn:active:not(:disabled) { .icon-select-label { font-weight: 500; } + +/* 图标搜索框样式 */ +.icon-search-wrapper { + padding: 8px; + border-bottom: 1px solid var(--mac-border); + position: sticky; + top: 0; + background: var(--mac-surface); + z-index: 1; +} + +.icon-search-input { + width: 100%; + border: 1px solid var(--mac-border); + border-radius: 8px; + padding: 8px 12px; + font-size: 0.85rem; + background: var(--mac-surface-strong); + color: var(--mac-text); + outline: none; +} + +.icon-search-input:focus { + border-color: var(--mac-accent); +} + +.icon-no-results { + padding: 16px; + text-align: center; + color: var(--mac-text-secondary); + font-size: 0.85rem; +} + .confirm-modal { max-width: 420px; } @@ -1778,7 +1793,7 @@ html.dark .secondary-btn:active:not(:disabled) { } /* 全局按钮样式修复 - 防止 Tailwind Preflight 导致的布局问题 */ -button:where(:not(.ghost-icon):not(.mac-switch):not(.nav-item):not(.platform-tab):not(.tab-pill):not(.collapse-btn):not(.toggle-switch):not(.action-btn):not(.remove-btn)) { +button:where(:not(.ghost-icon):not(.mac-switch):not(.nav-item):not(.platform-tab):not(.tab-pill):not(.collapse-btn):not(.toggle-switch):not(.action-btn):not(.remove-btn):not(.icon-select-button):not(.level-select-button)) { display: inline-flex !important; flex-direction: row !important; align-items: center !important; diff --git a/main.go b/main.go index 2fc0505..5eafaa1 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "log" + "math" "os" "path/filepath" "runtime" @@ -354,23 +355,23 @@ func main() { systray.SetDarkModeIcon(darkIcon) } - trayMenu := application.NewMenu() - trayMenu.Add("显示主窗口").OnClick(func(ctx *application.Context) { - showMainWindow(true) - }) - trayMenu.Add("退出").OnClick(func(ctx *application.Context) { - app.Quit() - }) - systray.SetMenu(trayMenu) - - systray.OnClick(func() { - if !mainWindow.IsVisible() { + refreshTrayMenu := func() { + used, total := getTrayUsage(logService, appSettings) + trayMenu := buildUsageTrayMenu(used, total, func() { showMainWindow(true) - return - } - if !mainWindow.IsFocused() { - focusMainWindow() - } + }, func() { + app.Quit() + }) + systray.SetMenu(trayMenu) + } + refreshTrayMenu() + systray.OnClick(func() { + refreshTrayMenu() + systray.OpenMenu() + }) + systray.OnRightClick(func() { + refreshTrayMenu() + systray.OpenMenu() }) appservice.SetApp(app) @@ -414,6 +415,82 @@ func handleDockVisibility(service *dock.DockService, show bool) { } } +const trayProgressBarWidth = 28 + +func getTrayUsage(logService *services.LogService, appSettings *services.AppSettingsService) (float64, float64) { + used := 0.0 + total := 0.0 + if logService != nil { + stats, err := logService.StatsSince("") + if err == nil { + used = stats.CostTotal + } + } + if appSettings != nil { + settings, err := appSettings.GetAppSettings() + if err == nil { + total = settings.BudgetTotal + } + } + if used < 0 { + used = 0 + } + if total < 0 { + total = 0 + } + return used, total +} + +func buildUsageTrayMenu(used float64, total float64, onShow func(), onQuit func()) *application.Menu { + menu := application.NewMenu() + menu.Add(trayUsageLabel(used, total)).SetDisabled(true) + menu.Add(trayProgressLabel(used, total)).SetDisabled(true) + menu.AddSeparator() + menu.Add("显示主窗口").OnClick(func(ctx *application.Context) { + onShow() + }) + menu.Add("退出").OnClick(func(ctx *application.Context) { + onQuit() + }) + return menu +} + +func trayUsageLabel(used float64, total float64) string { + usedLabel := formatCurrency(used) + if total <= 0 { + return fmt.Sprintf("今日已用 %s / 未设置", usedLabel) + } + return fmt.Sprintf("今日已用 %s / %s", usedLabel, formatCurrency(total)) +} + +func trayProgressLabel(used float64, total float64) string { + bar := strings.Repeat("-", trayProgressBarWidth) + if total <= 0 { + return fmt.Sprintf("进度 [%s] --%%", bar) + } + ratio := used / total + if ratio < 0 { + ratio = 0 + } + if ratio > 1 { + ratio = 1 + } + filled := int(math.Round(ratio * float64(trayProgressBarWidth))) + if filled < 0 { + filled = 0 + } + if filled > trayProgressBarWidth { + filled = trayProgressBarWidth + } + bar = strings.Repeat("#", filled) + strings.Repeat("-", trayProgressBarWidth-filled) + percent := int(math.Round(ratio * 100)) + return fmt.Sprintf("进度 [%s] %d%%", bar, percent) +} + +func formatCurrency(value float64) string { + return fmt.Sprintf("$%.2f", value) +} + // ============================================================ // 更新系统:启动恢复(全平台)和清理功能 // ============================================================ diff --git a/services/appsettings.go b/services/appsettings.go index 899f7f7..e405c3f 100644 --- a/services/appsettings.go +++ b/services/appsettings.go @@ -20,6 +20,7 @@ const ( type AppSettings struct { ShowHeatmap bool `json:"show_heatmap"` ShowHomeTitle bool `json:"show_home_title"` + BudgetTotal float64 `json:"budget_total"` AutoStart bool `json:"auto_start"` AutoUpdate bool `json:"auto_update"` AutoConnectivityTest bool `json:"auto_connectivity_test"` @@ -139,6 +140,7 @@ func (as *AppSettingsService) defaultSettings() AppSettings { return AppSettings{ ShowHeatmap: true, ShowHomeTitle: true, + BudgetTotal: 0, AutoStart: autoStartEnabled, AutoUpdate: true, // 默认开启自动更新 AutoConnectivityTest: true, // 默认开启自动可用性监控(开箱即用) diff --git a/services/consoleservice.go b/services/consoleservice.go index 830dda7..c73f002 100644 --- a/services/consoleservice.go +++ b/services/consoleservice.go @@ -5,6 +5,7 @@ import ( "io" "log" "os" + "strings" "sync" "time" ) @@ -18,12 +19,13 @@ type ConsoleLog struct { // ConsoleService 控制台日志服务 type ConsoleService struct { - logs []ConsoleLog - mutex sync.RWMutex - maxLogs int - writer *consoleWriter - oldStdout *os.File - oldStderr *os.File + logs []ConsoleLog + mutex sync.RWMutex + maxLogs int + writer *consoleWriter + oldStdout *os.File + oldStderr *os.File + pauseLogging bool // 暂停日志捕获标志 } // consoleWriter 自定义 writer,同时写入控制台和缓存 @@ -99,6 +101,16 @@ func (cs *ConsoleService) readPipe(reader *os.File, level string, output *os.Fil // addLog 添加日志到缓存 func (cs *ConsoleService) addLog(level, message string) { + // 如果暂停日志捕获,直接返回 + if cs.pauseLogging { + return + } + + // 过滤 Wails 框架的调试日志,避免日志递归 + if shouldFilterLog(message) { + return + } + cs.mutex.Lock() defer cs.mutex.Unlock() @@ -142,6 +154,10 @@ func (cs *ConsoleService) cleanOldLogs() { // GetLogs 获取所有日志 func (cs *ConsoleService) GetLogs() []ConsoleLog { + // 暂停日志捕获,避免 GetLogs 本身产生的日志被记录(导致递归) + cs.pauseLogging = true + defer func() { cs.pauseLogging = false }() + cs.mutex.RLock() defer cs.mutex.RUnlock() @@ -153,6 +169,10 @@ func (cs *ConsoleService) GetLogs() []ConsoleLog { // GetRecentLogs 获取最近 N 条日志 func (cs *ConsoleService) GetRecentLogs(count int) []ConsoleLog { + // 暂停日志捕获,避免递归 + cs.pauseLogging = true + defer func() { cs.pauseLogging = false }() + cs.mutex.RLock() defer cs.mutex.RUnlock() @@ -172,8 +192,51 @@ func (cs *ConsoleService) GetRecentLogs(count int) []ConsoleLog { // ClearLogs 清空日志 func (cs *ConsoleService) ClearLogs() { + // 暂停日志捕获,避免递归 + cs.pauseLogging = true + defer func() { cs.pauseLogging = false }() + cs.mutex.Lock() defer cs.mutex.Unlock() cs.logs = make([]ConsoleLog, 0, 1000) } + +// shouldFilterLog 判断是否应该过滤这条日志 +// 过滤掉 Wails 框架的调试日志和 JSON 序列化日志,避免日志递归爆炸 +func shouldFilterLog(message string) bool { + // 1. 过滤掉包含大量反斜杠的日志(JSON 序列化递归) + // 正常日志不应该有超过 10 个连续的反斜杠 + if strings.Contains(message, "\\\\\\\\\\\\\\\\\\\\") { + return true + } + + // 2. 过滤掉包含 JSON 结构的日志(GetLogs 的返回值被序列化) + // 检测是否包含日志的 JSON 结构特征 + if strings.Contains(message, `"timestamp":`) && + strings.Contains(message, `"level":`) && + strings.Contains(message, `"message":`) { + return true + } + + // 3. 过滤 Wails 框架的内部日志 + filterKeywords := []string{ + "Binding call started", + "Binding call complete", + "Asset Request", + "INF Binding call", + "INF Asset Request", + "/wails/runtime", + "ConsoleService.GetLogs", + "ConsoleService.GetRecentLogs", + "ConsoleService.ClearLogs", + } + + for _, keyword := range filterKeywords { + if strings.Contains(message, keyword) { + return true + } + } + + return false +} diff --git a/version_service.go b/version_service.go index b49faaf..df231c6 100644 --- a/version_service.go +++ b/version_service.go @@ -1,6 +1,6 @@ package main -const AppVersion = "v2.6.12" +const AppVersion = "v2.6.19" type VersionService struct { version string