From be069e9aed711a8614a4d3e388a787d7329a9847 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:24:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20#584?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/cloudControlService.ts | 100 +++++++++++++-- src/components/JumpToDateDialog.scss | 31 ++++- src/components/JumpToDateDialog.tsx | 111 +++++++++++++--- src/components/JumpToDatePopover.scss | 45 +++++++ src/components/JumpToDatePopover.tsx | 157 ++++++++++++++++++----- 5 files changed, 383 insertions(+), 61 deletions(-) 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/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) => ( + + ))}
-
+ )}
) }