diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cefd3f9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + target-branch: "dev" \ No newline at end of file diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml new file mode 100644 index 0000000..92419cf --- /dev/null +++ b/.github/workflows/dev-daily-fixed.yml @@ -0,0 +1,208 @@ +name: Dev Daily (Fixed Release) + +on: + schedule: + # GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00 + - cron: "0 16 * * *" + workflow_dispatch: + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + FIXED_DEV_TAG: nightly-dev + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + dev_version: ${{ steps.meta.outputs.dev_version }} + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Generate daily dev version + id: meta + shell: bash + run: | + set -euo pipefail + BASE_VERSION="$(node -p "require('./package.json').version.split('-')[0]")" + YEAR_2="$(TZ=Asia/Shanghai date +%y)" + MONTH="$(TZ=Asia/Shanghai date +%-m)" + DAY="$(TZ=Asia/Shanghai date +%-d)" + DEV_VERSION="${BASE_VERSION}-dev.${YEAR_2}.${MONTH}.${DAY}" + echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT" + echo "Dev version: $DEV_VERSION" + + - name: Ensure fixed prerelease exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --prerelease + else + gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "固定开发版发布页(每日覆盖更新)" --prerelease + fi + + dev-mac-arm64: + needs: prepare + runs-on: macos-14 + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Set dev version + shell: bash + run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package macOS arm64 dev artifacts + run: | + npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}' + + - name: Upload macOS arm64 assets to fixed release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + gh release upload "$FIXED_DEV_TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber + + dev-linux: + needs: prepare + runs-on: ubuntu-latest + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Set dev version + shell: bash + run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package Linux dev artifacts + run: | + npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}' + + - name: Upload Linux assets to fixed release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + gh release upload "$FIXED_DEV_TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber + + dev-win-x64: + needs: prepare + runs-on: windows-latest + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Set dev version + shell: bash + run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package Windows x64 dev artifacts + run: | + npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}' + + - name: Upload Windows x64 assets to fixed release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + gh release upload "$FIXED_DEV_TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber + + dev-win-arm64: + needs: prepare + runs-on: windows-latest + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Set dev version + shell: bash + run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package Windows arm64 dev artifacts + run: | + npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}' + + - name: Upload Windows arm64 assets to fixed release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + gh release upload "$FIXED_DEV_TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml new file mode 100644 index 0000000..3f7f1de --- /dev/null +++ b/.github/workflows/preview-nightly-main.yml @@ -0,0 +1,197 @@ +name: Preview Nightly (Main) + +on: + schedule: + # GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00 + - cron: "0 16 * * *" + workflow_dispatch: + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.meta.outputs.should_build }} + preview_version: ${{ steps.meta.outputs.preview_version }} + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Decide whether to build and generate preview version + id: meta + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + git fetch origin main --depth=1 + COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + SHOULD_BUILD=true + elif [ "$COMMITS_24H" -gt 0 ]; then + SHOULD_BUILD=true + else + SHOULD_BUILD=false + fi + + BASE_VERSION="$(node -p "require('./package.json').version.split('-')[0]")" + YEAR_2="$(TZ=Asia/Shanghai date +%y)" + EXISTING_COUNT="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" --jq "[.[].tag_name | select(test(\"^v${BASE_VERSION}-preview\\.${YEAR_2}\\.[0-9]+$\"))] | length")" + NEXT_COUNT=$((EXISTING_COUNT + 1)) + PREVIEW_VERSION="${BASE_VERSION}-preview.${YEAR_2}.${NEXT_COUNT}" + + echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT" + echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT" + echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H)" + + preview-mac-arm64: + needs: prepare + if: needs.prepare.outputs.should_build == 'true' + runs-on: macos-14 + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Set preview version + shell: bash + run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish macOS arm64 preview + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + npx electron-builder --mac dmg --arm64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview' + + preview-linux: + needs: prepare + if: needs.prepare.outputs.should_build == 'true' + runs-on: ubuntu-latest + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Set preview version + shell: bash + run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish Linux preview + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npx electron-builder --linux --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview' + + preview-win-x64: + needs: prepare + if: needs.prepare.outputs.should_build == 'true' + runs-on: windows-latest + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Set preview version + shell: bash + run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish Windows x64 preview + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npx electron-builder --win nsis --x64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}' + + preview-win-arm64: + needs: prepare + if: needs.prepare.outputs.should_build == 'true' + runs-on: windows-latest + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Set preview version + shell: bash + run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish Windows arm64 preview + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npx electron-builder --win nsis --arm64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 82c328c..01f45a0 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -4,6 +4,8 @@ on: schedule: - cron: '0 2 * * *' # 每天 UTC 02:00(北京时间 10:00) workflow_dispatch: # 支持手动触发 + pull_request: # 监控 PR + branches: [ main, dev ] permissions: contents: read diff --git a/.gitignore b/.gitignore index d026e51..95745fb 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,5 @@ resources/wx_send 概述.md pnpm-lock.yaml /pnpm-workspace.yaml -wechat-research-site \ No newline at end of file +wechat-research-site +.codex \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index bf22d19..57be647 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -36,10 +36,41 @@ import { messagePushService } from './services/messagePushService' autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载 -// Windows x64 与 arm64 使用不同更新通道,避免 latest.yml 互相覆盖导致下错架构安装包。 -if (process.platform === 'win32' && process.arch === 'arm64') { - autoUpdater.channel = 'latest-arm64' +// 更新通道策略: +// - 稳定版(如 4.3.0)默认走 latest +// - 预览版(如 4.3.0-preview.26.1)默认走 preview +// - 开发版(如 4.3.0-dev.26.3.4)默认走 dev +// - 用户可在设置页切换稳定/预览/开发,切换后即时生效 +// 同时区分 Windows x64 / arm64,避免更新清单互相覆盖。 +const appVersion = app.getVersion() +const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => { + if (/-preview\.\d+\.\d+$/i.test(appVersion)) return 'preview' + if (/-dev\.\d+\.\d+\.\d+$/i.test(appVersion)) return 'dev' + if (/(alpha|beta|rc)/i.test(appVersion)) return 'dev' + return 'stable' +})() +const isPrereleaseBuild = defaultUpdateTrack !== 'stable' +let configService: ConfigService | null = null + +const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => { + if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw + return null } + +const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => { + const configuredTrack = normalizeUpdateTrack(configService?.get('updateChannel')) + const track: 'stable' | 'preview' | 'dev' = configuredTrack || defaultUpdateTrack + const baseUpdateChannel = track === 'stable' ? 'latest' : track + autoUpdater.allowPrerelease = track !== 'stable' + autoUpdater.allowDowngrade = isPrereleaseBuild && track === 'stable' + autoUpdater.channel = + process.platform === 'win32' && process.arch === 'arm64' + ? `${baseUpdateChannel}-arm64` + : baseUpdateChannel + console.log(`[Update](${reason}) 当前版本 ${appVersion},渠道偏好: ${track},更新通道: ${autoUpdater.channel}`) +} + +applyAutoUpdateChannel('startup') const AUTO_UPDATE_ENABLED = process.env.AUTO_UPDATE_ENABLED === 'true' || process.env.AUTO_UPDATE_ENABLED === '1' || @@ -87,7 +118,6 @@ function sanitizePathEnv() { sanitizePathEnv() // 单例服务 -let configService: ConfigService | null = null // 协议窗口实例 let agreementWindow: BrowserWindow | null = null @@ -1117,6 +1147,9 @@ function registerIpcHandlers() { ipcMain.handle('config:set', async (_, key: string, value: any) => { const result = configService?.set(key as any, value) + if (key === 'updateChannel') { + applyAutoUpdateChannel('settings') + } void messagePushService.handleConfigChanged(key) return result }) @@ -2741,6 +2774,7 @@ app.whenReady().then(async () => { // 初始化配置服务 updateSplashProgress(5, '正在加载配置...') configService = new ConfigService() + applyAutoUpdateChannel('startup') // 将用户主题配置推送给 Splash 窗口 if (splashWindow && !splashWindow.isDestroyed()) { diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts index c43dcd2..39b3345 100644 --- a/electron/services/cloudControlService.ts +++ b/electron/services/cloudControlService.ts @@ -15,15 +15,31 @@ class CloudControlService { private timer: NodeJS.Timeout | null = null private pages: Set = new Set() private platformVersionCache: string | null = null + private pendingReports: UsageStats[] = [] + private flushInProgress = false + private retryDelayMs = 5_000 + private consecutiveFailures = 0 + private circuitOpenedAt = 0 + private nextDelayOverrideMs: number | null = null + private initialized = false + + private static readonly BASE_FLUSH_MS = 300_000 + private static readonly JITTER_MS = 30_000 + private static readonly MAX_BUFFER_REPORTS = 200 + private static readonly MAX_BATCH_REPORTS = 20 + private static readonly MAX_RETRY_MS = 120_000 + private static readonly CIRCUIT_FAIL_THRESHOLD = 5 + private static readonly CIRCUIT_COOLDOWN_MS = 120_000 async init() { + if (this.initialized) return + this.initialized = true this.deviceId = this.getDeviceId() await wcdbService.cloudInit(300) - await this.reportOnline() - - this.timer = setInterval(() => { - this.reportOnline() - }, 300000) + this.enqueueCurrentReport() + await this.flushQueue(true) + this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined) + this.nextDelayOverrideMs = null } private getDeviceId(): string { @@ -33,8 +49,8 @@ class CloudControlService { return crypto.createHash('md5').update(machineId).digest('hex') } - private async reportOnline() { - const data: UsageStats = { + private buildCurrentReport(): UsageStats { + return { appVersion: app.getVersion(), platform: this.getPlatformVersion(), deviceId: this.deviceId, @@ -42,11 +58,69 @@ class CloudControlService { online: true, pages: Array.from(this.pages) } + } - await wcdbService.cloudReport(JSON.stringify(data)) + private enqueueCurrentReport() { + const report = this.buildCurrentReport() + this.pendingReports.push(report) + if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) { + this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS) + } this.pages.clear() } + private isCircuitOpen(nowMs: number): boolean { + if (this.circuitOpenedAt <= 0) return false + return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS + } + + private scheduleNextFlush(delayMs?: number) { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS) + const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter) + this.timer = setTimeout(() => { + this.enqueueCurrentReport() + this.flushQueue(false).finally(() => { + this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined) + this.nextDelayOverrideMs = null + }) + }, nextDelay) + } + + private async flushQueue(force: boolean) { + if (this.flushInProgress) return + if (this.pendingReports.length === 0) return + const now = Date.now() + if (!force && this.isCircuitOpen(now)) { + return + } + this.flushInProgress = true + try { + while (this.pendingReports.length > 0) { + const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS) + const result = await wcdbService.cloudReport(JSON.stringify(batch)) + if (!result || result.success !== true) { + this.consecutiveFailures += 1 + this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2) + if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) { + this.circuitOpenedAt = Date.now() + } + this.nextDelayOverrideMs = this.retryDelayMs + return + } + this.pendingReports.splice(0, batch.length) + this.consecutiveFailures = 0 + this.retryDelayMs = 5_000 + this.circuitOpenedAt = 0 + } + } finally { + this.flushInProgress = false + } + } + private getPlatformVersion(): string { if (this.platformVersionCache) { return this.platformVersionCache @@ -146,9 +220,16 @@ class CloudControlService { stop() { if (this.timer) { - clearInterval(this.timer) + clearTimeout(this.timer) this.timer = null } + this.pendingReports = [] + this.flushInProgress = false + this.retryDelayMs = 5_000 + this.consecutiveFailures = 0 + this.circuitOpenedAt = 0 + this.nextDelayOverrideMs = null + this.initialized = false wcdbService.cloudStop() } @@ -158,4 +239,3 @@ class CloudControlService { } export const cloudControlService = new CloudControlService() - diff --git a/electron/services/config.ts b/electron/services/config.ts index 5fdd609..3269b0b 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -45,6 +45,7 @@ interface ConfigSchema { // 更新相关 ignoredUpdateVersion: string + updateChannel: 'auto' | 'stable' | 'preview' | 'dev' // 通知 notificationEnabled: boolean @@ -119,6 +120,7 @@ export class ConfigService { authUseHello: false, authHelloSecret: '', ignoredUpdateVersion: '', + updateChannel: 'auto', notificationEnabled: true, notificationPosition: 'top-right', notificationFilterMode: 'all', diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 4f0d97b..b5d039b 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -2681,7 +2681,7 @@ export class WcdbCore { } try { const outCursor = [0] - const result = this.wcdbOpenMessageCursorLite( + let result = this.wcdbOpenMessageCursorLite( this.handle, sessionId, batchSize, @@ -2690,6 +2690,29 @@ export class WcdbCore { endTimestamp, outCursor ) + + // result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空 + // 自动强制重连并重试一次 + if (result === -3 && outCursor[0] <= 0) { + this.writeLog('openMessageCursorLite: result=-3 (no message db), attempting forceReopen...', true) + const reopened = await this.forceReopen() + if (reopened && this.handle !== null) { + outCursor[0] = 0 + result = this.wcdbOpenMessageCursorLite( + this.handle, + sessionId, + batchSize, + ascending ? 1 : 0, + beginTimestamp, + endTimestamp, + outCursor + ) + this.writeLog(`openMessageCursorLite retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true) + } else { + this.writeLog('openMessageCursorLite forceReopen failed, giving up', true) + } + } + if (result !== 0 || outCursor[0] <= 0) { await this.printLogs(true) this.writeLog( diff --git a/package-lock.json b/package-lock.json index b057112..cb123a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3077,15 +3077,15 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", @@ -5323,21 +5323,11 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" }, "node_modules/fd-slicer": { "version": "1.1.0", @@ -6348,9 +6338,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-schema-typed": { @@ -8390,6 +8380,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", @@ -8656,15 +8655,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmmirror.com/resedit/-/resedit-1.7.2.tgz", @@ -9936,6 +9926,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", diff --git a/package.json b/package.json index 2ed4b45..cd81f75 100644 --- a/package.json +++ b/package.json @@ -194,6 +194,6 @@ "picomatch": "^4.0.4", "tar": "^7.5.13", "immutable": "^5.1.5", - "ajv": ">=6.14.0" + "ajv": "^6.14.0" } } diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll index a622386..de6bce8 100644 Binary files a/resources/arm64/wcdb_api.dll and b/resources/arm64/wcdb_api.dll differ diff --git a/resources/libwcdb_api.dylib b/resources/libwcdb_api.dylib index 208a952..d185cfc 100755 Binary files a/resources/libwcdb_api.dylib and b/resources/libwcdb_api.dylib differ diff --git a/resources/libwcdb_api.so b/resources/libwcdb_api.so index 7dc674d..d3c686a 100755 Binary files a/resources/libwcdb_api.so and b/resources/libwcdb_api.so differ diff --git a/resources/linux/libwcdb_api.so b/resources/linux/libwcdb_api.so index 3505134..d3c686a 100755 Binary files a/resources/linux/libwcdb_api.so and b/resources/linux/libwcdb_api.so differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index 208a952..d185cfc 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index efc2929..c9609ad 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/wcdb_api_arm64.dll b/resources/wcdb_api_arm64.dll index 16416fd..de6bce8 100644 Binary files a/resources/wcdb_api_arm64.dll and b/resources/wcdb_api_arm64.dll differ diff --git a/src/App.tsx b/src/App.tsx index 83af689..d5f2512 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,6 +103,7 @@ function App() { // 数据收集同意状态 const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) + const [analyticsConsent, setAnalyticsConsent] = useState(null) const [showWaylandWarning, setShowWaylandWarning] = useState(false) @@ -252,6 +253,7 @@ function App() { // 协议已同意,检查数据收集同意状态 const consent = await configService.getAnalyticsConsent() const denyCount = await configService.getAnalyticsDenyCount() + setAnalyticsConsent(consent) // 如果未设置同意状态且拒绝次数小于2次,显示弹窗 if (consent === null && denyCount < 2) { setShowAnalyticsConsent(true) @@ -266,18 +268,21 @@ function App() { checkAgreement() }, []) - // 初始化数据收集 + // 初始化数据收集(仅在用户同意后) useEffect(() => { - cloudControl.initCloudControl() - }, []) + if (analyticsConsent === true) { + cloudControl.initCloudControl() + } + }, [analyticsConsent]) - // 记录页面访问 + // 记录页面访问(仅在用户同意后) useEffect(() => { + if (analyticsConsent !== true) return const path = location.pathname if (path && path !== '/') { cloudControl.recordPage(path) } - }, [location.pathname]) + }, [location.pathname, analyticsConsent]) const handleAgree = async () => { if (!agreementChecked) return @@ -296,6 +301,7 @@ function App() { const handleAnalyticsAllow = async () => { await configService.setAnalyticsConsent(true) + setAnalyticsConsent(true) setShowAnalyticsConsent(false) } diff --git a/src/components/JumpToDateDialog.scss b/src/components/JumpToDateDialog.scss index 5e03962..0cdcadb 100644 --- a/src/components/JumpToDateDialog.scss +++ b/src/components/JumpToDateDialog.scss @@ -75,6 +75,8 @@ font-size: 15px; font-weight: 600; color: var(--text-primary); + border: none; + background: transparent; &.clickable { cursor: pointer; @@ -172,6 +174,33 @@ } } } + + .year-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + + .year-btn { + padding: 10px 0; + border: none; + background: transparent; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + &.active { + background: var(--primary); + color: #fff; + } + } + } } } @@ -372,4 +401,4 @@ opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/src/components/JumpToDateDialog.tsx b/src/components/JumpToDateDialog.tsx index 90044b2..47bfb80 100644 --- a/src/components/JumpToDateDialog.tsx +++ b/src/components/JumpToDateDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react' +import React, { useState } from 'react' import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react' import './JumpToDateDialog.scss' @@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC = ({ messageDates, loadingDates = false }) => { + type CalendarViewMode = 'day' | 'month' | 'year' + const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12 const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime()) const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date()) const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) - const [showYearMonthPicker, setShowYearMonthPicker] = useState(false) + const [viewMode, setViewMode] = useState('day') + const [yearPageStart, setYearPageStart] = useState( + getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear()) + ) if (!isOpen) return null @@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC = ({ const weekdays = ['日', '一', '二', '三', '四', '五', '六'] const days = generateCalendar() + const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'] + + const updateCalendarDate = (nextDate: Date) => { + setCalendarDate(nextDate) + } + + const openMonthView = () => setViewMode('month') + const openYearView = () => { + setYearPageStart(getYearPageStart(calendarDate.getFullYear())) + setViewMode('year') + } + + const handleTitleClick = () => { + if (viewMode === 'day') { + openMonthView() + return + } + if (viewMode === 'month') { + openYearView() + } + } + + const handlePrev = () => { + if (viewMode === 'day') { + updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1)) + return + } + if (viewMode === 'month') { + updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1)) + return + } + setYearPageStart((prev) => prev - 12) + } + + const handleNext = () => { + if (viewMode === 'day') { + updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1)) + return + } + if (viewMode === 'month') { + updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1)) + return + } + setYearPageStart((prev) => prev + 12) + } + + const navTitle = viewMode === 'day' + ? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月` + : viewMode === 'month' + ? `${calendarDate.getFullYear()}年` + : `${yearPageStart}年 - ${yearPageStart + 11}年` return (
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC = ({
- setShowYearMonthPicker(!showYearMonthPicker)}> - {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 - +
- {showYearMonthPicker ? ( + {viewMode === 'month' ? (
-
- - {calendarDate.getFullYear()}年 - -
- {['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => ( + {monthNames.map((name, i) => ( ))}
+ ) : viewMode === 'year' ? ( +
+
+ {Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => ( + + ))} +
+
) : (
{loadingDates && ( @@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC = ({ const d = new Date() setSelectedDate(d) setCalendarDate(new Date(d)) + setViewMode('day') }}>今天
diff --git a/src/components/JumpToDatePopover.scss b/src/components/JumpToDatePopover.scss index f9839a6..eb7d447 100644 --- a/src/components/JumpToDatePopover.scss +++ b/src/components/JumpToDatePopover.scss @@ -28,6 +28,20 @@ font-size: 14px; font-weight: 600; color: var(--text-primary); + border: none; + background: transparent; + border-radius: 8px; + padding: 4px 8px; +} + +.jump-date-popover .current-month.clickable { + cursor: pointer; + transition: all 0.18s ease; +} + +.jump-date-popover .current-month.clickable:hover { + color: var(--primary); + background: var(--bg-hover); } .jump-date-popover .nav-btn { @@ -83,6 +97,37 @@ gap: 4px; } +.jump-date-popover .month-grid, +.jump-date-popover .year-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + min-height: 256px; +} + +.jump-date-popover .month-cell, +.jump-date-popover .year-cell { + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + transition: all 0.18s ease; +} + +.jump-date-popover .month-cell:hover, +.jump-date-popover .year-cell:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.jump-date-popover .month-cell.active, +.jump-date-popover .year-cell.active { + background: var(--primary); + color: #fff; +} + .jump-date-popover .day-cell { position: relative; border: 1px solid transparent; diff --git a/src/components/JumpToDatePopover.tsx b/src/components/JumpToDatePopover.tsx index ef3c807..e88b502 100644 --- a/src/components/JumpToDatePopover.tsx +++ b/src/components/JumpToDatePopover.tsx @@ -31,14 +31,20 @@ const JumpToDatePopover: React.FC = ({ loadingDates = false, loadingDateCounts = false }) => { + type CalendarViewMode = 'day' | 'month' | 'year' + const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12 const [calendarDate, setCalendarDate] = useState(new Date(currentDate)) const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) + const [viewMode, setViewMode] = useState('day') + const [yearPageStart, setYearPageStart] = useState(getYearPageStart(new Date(currentDate).getFullYear())) useEffect(() => { if (!isOpen) return const normalized = new Date(currentDate) setCalendarDate(normalized) setSelectedDate(normalized) + setViewMode('day') + setYearPageStart(getYearPageStart(normalized.getFullYear())) }, [isOpen, currentDate]) if (!isOpen) return null @@ -114,25 +120,78 @@ const JumpToDatePopover: React.FC = ({ const weekdays = ['日', '一', '二', '三', '四', '五', '六'] const days = generateCalendar() const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim() + const updateCalendarDate = (nextDate: Date) => { setCalendarDate(nextDate) onMonthChange?.(nextDate) } + const openMonthView = () => setViewMode('month') + const openYearView = () => { + setYearPageStart(getYearPageStart(calendarDate.getFullYear())) + setViewMode('year') + } + + const handleTitleClick = () => { + if (viewMode === 'day') { + openMonthView() + return + } + if (viewMode === 'month') { + openYearView() + } + } + + const handlePrev = () => { + if (viewMode === 'day') { + updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1)) + return + } + if (viewMode === 'month') { + updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1)) + return + } + setYearPageStart((prev) => prev - 12) + } + + const handleNext = () => { + if (viewMode === 'day') { + updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1)) + return + } + if (viewMode === 'month') { + updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1)) + return + } + setYearPageStart((prev) => prev + 12) + } + + const navTitle = viewMode === 'day' + ? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月` + : viewMode === 'month' + ? `${calendarDate.getFullYear()}年` + : `${yearPageStart}年 - ${yearPageStart + 11}年` + return (
- {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 +
-
-
- {weekdays.map(day => ( -
{day}
+ {viewMode === 'day' && ( +
+
+ {weekdays.map(day => ( +
{day}
+ ))} +
+
+ {days.map((day, index) => { + if (day === null) return
+ const dateKey = toDateKey(day) + const hasMessageOnDay = hasMessage(day) + const count = Number(messageDateCounts?.[dateKey] || 0) + const showCount = count > 0 + const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount + return ( + + ) + })} +
+
+ )} + + {viewMode === 'month' && ( +
+ {['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => ( + ))}
-
- {days.map((day, index) => { - if (day === null) return
- const dateKey = toDateKey(day) - const hasMessageOnDay = hasMessage(day) - const count = Number(messageDateCounts?.[dateKey] || 0) - const showCount = count > 0 - const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount - return ( - - ) - })} + )} + + {viewMode === 'year' && ( +
+ {Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => ( + + ))}
-
+ )}
) } diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 5862037..8ebad97 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1401,6 +1401,220 @@ } } +// 版本更新页面 +.updates-tab { + display: flex; + flex-direction: column; + gap: 16px; +} + +.updates-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 22px; + border-radius: 18px; + border: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border-color)); + background: + linear-gradient(110deg, color-mix(in srgb, var(--bg-primary) 98%, #ffffff 2%), color-mix(in srgb, var(--bg-primary) 97%, var(--primary) 3%)); + box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 8%, transparent); +} + +.updates-hero-main { + min-width: 0; + + h2 { + margin: 10px 0 6px; + font-size: 30px; + line-height: 1; + letter-spacing: -0.03em; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + } +} + +.updates-chip { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: var(--primary); + background: color-mix(in srgb, var(--primary) 12%, transparent); +} + +.updates-hero-action { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; +} + +.updates-card { + padding: 18px; + border-radius: 16px; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-primary) 96%, var(--bg-secondary)); +} + +.updates-progress-card { + padding: 16px 18px; + border-radius: 16px; + border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border-color)); + background: color-mix(in srgb, var(--bg-primary) 95%, var(--primary) 4%); +} + +.updates-progress-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + + h3 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } + + strong { + color: var(--primary); + font-size: 14px; + font-variant-numeric: tabular-nums; + } + + span { + font-size: 12px; + color: var(--text-secondary); + } +} + +.updates-card-header { + margin-bottom: 12px; + + h3 { + margin: 0 0 4px; + font-size: 16px; + color: var(--text-primary); + } + + span { + font-size: 12px; + color: var(--text-tertiary); + } +} + +.update-channel-grid { + display: grid; + gap: 10px; +} + +.update-channel-card { + width: 100%; + text-align: left; + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 12px 14px; + background: var(--bg-primary); + color: var(--text-primary); + transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + transform: translateY(-1px); + box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 12%, transparent); + } + + &:disabled { + cursor: default; + } + + &.active { + border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + } +} + +.update-channel-title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + + .title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + svg { + color: var(--primary); + } +} + +.update-channel-card .desc { + display: block; + font-size: 12px; + color: var(--text-secondary); +} + +.updates-progress-track { + height: 10px; + border-radius: 999px; + background: var(--bg-tertiary); + overflow: hidden; +} + +.updates-progress-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 72%, #ffffff)); + transition: width 0.3s ease; + position: relative; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.26), transparent); + animation: updatesShimmer 1.7s linear infinite; + } +} + +.updates-ignore-btn { + margin-top: 12px; +} + +@media (max-width: 920px) { + .updates-hero { + flex-direction: column; + align-items: flex-start; + } + + .updates-hero-action { + align-items: flex-start; + } +} + +@keyframes updatesShimmer { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(100%); + } +} + // 关于页面 .about-tab { diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 36e1776..55c5779 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -15,7 +15,7 @@ import { import { Avatar } from '../components/Avatar' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics' +type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, @@ -24,9 +24,9 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'models', label: '模型管理', icon: Mic }, { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'api', label: 'API 服务', icon: Globe }, - { id: 'analytics', label: '分析', icon: BarChart2 }, { id: 'security', label: '安全', icon: ShieldCheck }, + { id: 'updates', label: '版本更新', icon: RefreshCw }, { id: 'about', label: '关于', icon: Info } ] @@ -68,7 +68,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setDownloadProgress, showUpdateDialog, setShowUpdateDialog, - setUpdateError } = useAppStore() const resetChatStore = useChatStore((state) => state.reset) @@ -141,6 +140,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [notificationFilterList, setNotificationFilterList] = useState([]) const [windowCloseBehavior, setWindowCloseBehavior] = useState('ask') const [quoteLayout, setQuoteLayout] = useState('quote-top') + const [updateChannel, setUpdateChannel] = useState('stable') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) @@ -339,6 +339,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedMessagePushEnabled = await configService.getMessagePushEnabled() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedQuoteLayout = await configService.getQuoteLayout() + const savedUpdateChannel = await configService.getUpdateChannel() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() @@ -387,6 +388,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setMessagePushEnabled(savedMessagePushEnabled) setWindowCloseBehavior(savedWindowCloseBehavior) setQuoteLayout(savedQuoteLayout) + if (savedUpdateChannel) { + setUpdateChannel(savedUpdateChannel) + } else { + const currentVersion = await window.electronAPI.app.getVersion() + if (/-preview\.\d+\.\d+$/i.test(currentVersion)) { + setUpdateChannel('preview') + } else if (/-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) { + setUpdateChannel('dev') + } else { + setUpdateChannel('stable') + } + } const savedExcludeWords = await configService.getWordCloudExcludeWords() setWordCloudExcludeWords(savedExcludeWords) @@ -512,7 +525,22 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } } + const handleUpdateChannelChange = async (channel: configService.UpdateChannel) => { + if (channel === updateChannel) return + try { + setUpdateChannel(channel) + await configService.setUpdateChannel(channel) + await configService.setIgnoredUpdateVersion('') + setUpdateInfo(null) + setShowUpdateDialog(false) + const channelLabel = channel === 'stable' ? '稳定版' : channel === 'preview' ? '预览版' : '开发版' + showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true) + await handleCheckUpdate() + } catch (e: any) { + showMessage(`切换更新渠道失败: ${e}`, false) + } + } const showMessage = (text: string, success: boolean) => { setMessage({ text, success }) @@ -1474,6 +1502,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const renderDatabaseTab = () => (
+
+ + 检测当前数据库配置是否可用 + +
+ +
+
64位十六进制密钥 @@ -2400,35 +2438,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { WeFlow

WeFlow

-

WeFlow

v{appVersion || '...'}

- -
- {updateInfo?.hasUpdate ? ( - <> -

新版 v{updateInfo.version} 可用

- {isDownloading ? ( -
-
-
-
- {(downloadProgress?.percent || 0).toFixed(0)}% -
- ) : ( - - )} - - ) : ( -
- -
- )} -
@@ -2440,7 +2450,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { · { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}>用户协议
-

© 2025 WeFlow. All rights reserved.

+

© 2026 WeFlow. All rights reserved.

匿名数据收集 @@ -2463,6 +2473,82 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) + const renderUpdatesTab = () => { + const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0))) + const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [ + { id: 'stable', title: '稳定版', desc: '正式发布的版本,适合日常使用' }, + { id: 'preview', title: '预览版', desc: '正式发布前的预览体验版本' }, + { id: 'dev', title: '开发版', desc: '即刻体验我们的屎山代码' } + ] + + return ( +
+
+
+ 当前版本 +

{appVersion || '...'}

+

{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更新'}

+
+
+ {updateInfo?.hasUpdate ? ( + + ) : ( + + )} +
+
+ + {(isDownloading || updateInfo?.hasUpdate) && ( +
+
+

{isDownloading ? `正在下载 v${updateInfo?.version || ''}` : `新版本 v${updateInfo?.version} 已就绪`}

+ {isDownloading ? {downloadPercent.toFixed(0)}% : 可立即安装} +
+
+
+
+ {updateInfo?.hasUpdate && !isDownloading && ( + + )} +
+ )} + +
+
+

更新渠道

+ 切换渠道后会自动重新检查 +
+
+ {channelCards.map((channel) => { + const active = updateChannel === channel.id + return ( + + ) + })} +
+
+
+ ) + } + return (
event.stopPropagation()}> @@ -2510,9 +2596,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {

设置

- {onClose && (