mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-31 23:15:50 +00:00
实现 #584
This commit is contained in:
@@ -15,15 +15,31 @@ class CloudControlService {
|
|||||||
private timer: NodeJS.Timeout | null = null
|
private timer: NodeJS.Timeout | null = null
|
||||||
private pages: Set<string> = new Set()
|
private pages: Set<string> = new Set()
|
||||||
private platformVersionCache: string | null = null
|
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() {
|
async init() {
|
||||||
|
if (this.initialized) return
|
||||||
|
this.initialized = true
|
||||||
this.deviceId = this.getDeviceId()
|
this.deviceId = this.getDeviceId()
|
||||||
await wcdbService.cloudInit(300)
|
await wcdbService.cloudInit(300)
|
||||||
await this.reportOnline()
|
this.enqueueCurrentReport()
|
||||||
|
await this.flushQueue(true)
|
||||||
this.timer = setInterval(() => {
|
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||||
this.reportOnline()
|
this.nextDelayOverrideMs = null
|
||||||
}, 300000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDeviceId(): string {
|
private getDeviceId(): string {
|
||||||
@@ -33,8 +49,8 @@ class CloudControlService {
|
|||||||
return crypto.createHash('md5').update(machineId).digest('hex')
|
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reportOnline() {
|
private buildCurrentReport(): UsageStats {
|
||||||
const data: UsageStats = {
|
return {
|
||||||
appVersion: app.getVersion(),
|
appVersion: app.getVersion(),
|
||||||
platform: this.getPlatformVersion(),
|
platform: this.getPlatformVersion(),
|
||||||
deviceId: this.deviceId,
|
deviceId: this.deviceId,
|
||||||
@@ -42,11 +58,69 @@ class CloudControlService {
|
|||||||
online: true,
|
online: true,
|
||||||
pages: Array.from(this.pages)
|
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()
|
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 {
|
private getPlatformVersion(): string {
|
||||||
if (this.platformVersionCache) {
|
if (this.platformVersionCache) {
|
||||||
return this.platformVersionCache
|
return this.platformVersionCache
|
||||||
@@ -146,9 +220,16 @@ class CloudControlService {
|
|||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearInterval(this.timer)
|
clearTimeout(this.timer)
|
||||||
this.timer = null
|
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()
|
wcdbService.cloudStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,4 +239,3 @@ class CloudControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const cloudControlService = new CloudControlService()
|
export const cloudControlService = new CloudControlService()
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
&.clickable {
|
&.clickable {
|
||||||
cursor: pointer;
|
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;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||||
import './JumpToDateDialog.scss'
|
import './JumpToDateDialog.scss'
|
||||||
|
|
||||||
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
messageDates,
|
messageDates,
|
||||||
loadingDates = false
|
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 isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||||
|
const [yearPageStart, setYearPageStart] = useState<number>(
|
||||||
|
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
|
||||||
|
)
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
|
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
const days = generateCalendar()
|
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 (
|
return (
|
||||||
<div className="jump-date-overlay" onClick={onClose}>
|
<div className="jump-date-overlay" onClick={onClose}>
|
||||||
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
<div className="calendar-nav">
|
<div className="calendar-nav">
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
onClick={handlePrev}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
<button
|
||||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||||
</span>
|
onClick={handleTitleClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{navTitle}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
onClick={handleNext}
|
||||||
>
|
>
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showYearMonthPicker ? (
|
{viewMode === 'month' ? (
|
||||||
<div className="year-month-picker">
|
<div className="year-month-picker">
|
||||||
<div className="year-selector">
|
|
||||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
</button>
|
|
||||||
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
|
||||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="month-grid">
|
<div className="month-grid">
|
||||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
{monthNames.map((name, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||||
setShowYearMonthPicker(false)
|
setViewMode('day')
|
||||||
}}
|
}}
|
||||||
>{name}</button>
|
>{name}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : viewMode === 'year' ? (
|
||||||
|
<div className="year-month-picker">
|
||||||
|
<div className="year-grid">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||||
|
setViewMode('month')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}年
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||||
{loadingDates && (
|
{loadingDates && (
|
||||||
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
const d = new Date()
|
const d = new Date()
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>今天</button>
|
}}>今天</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() - 7)
|
d.setDate(d.getDate() - 7)
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>一周前</button>
|
}}>一周前</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setMonth(d.getMonth() - 1)
|
d.setMonth(d.getMonth() - 1)
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>一月前</button>
|
}}>一月前</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,20 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
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 {
|
.jump-date-popover .nav-btn {
|
||||||
@@ -83,6 +97,37 @@
|
|||||||
gap: 4px;
|
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 {
|
.jump-date-popover .day-cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|||||||
@@ -31,14 +31,20 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
loadingDates = false,
|
loadingDates = false,
|
||||||
loadingDateCounts = false
|
loadingDateCounts = false
|
||||||
}) => {
|
}) => {
|
||||||
|
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||||
|
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||||
|
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||||
|
const [yearPageStart, setYearPageStart] = useState<number>(getYearPageStart(new Date(currentDate).getFullYear()))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const normalized = new Date(currentDate)
|
const normalized = new Date(currentDate)
|
||||||
setCalendarDate(normalized)
|
setCalendarDate(normalized)
|
||||||
setSelectedDate(normalized)
|
setSelectedDate(normalized)
|
||||||
|
setViewMode('day')
|
||||||
|
setYearPageStart(getYearPageStart(normalized.getFullYear()))
|
||||||
}, [isOpen, currentDate])
|
}, [isOpen, currentDate])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
@@ -114,25 +120,78 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
const days = generateCalendar()
|
const days = generateCalendar()
|
||||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||||
|
|
||||||
const updateCalendarDate = (nextDate: Date) => {
|
const updateCalendarDate = (nextDate: Date) => {
|
||||||
setCalendarDate(nextDate)
|
setCalendarDate(nextDate)
|
||||||
onMonthChange?.(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 (
|
return (
|
||||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||||
<div className="calendar-nav">
|
<div className="calendar-nav">
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
onClick={handlePrev}
|
||||||
aria-label="上一月"
|
aria-label="上一月"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
<button
|
||||||
|
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||||
|
onClick={handleTitleClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{navTitle}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
onClick={handleNext}
|
||||||
aria-label="下一月"
|
aria-label="下一月"
|
||||||
>
|
>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
@@ -154,36 +213,74 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="calendar-grid">
|
{viewMode === 'day' && (
|
||||||
<div className="weekdays">
|
<div className="calendar-grid">
|
||||||
{weekdays.map(day => (
|
<div className="weekdays">
|
||||||
<div key={day} className="weekday">{day}</div>
|
{weekdays.map(day => (
|
||||||
|
<div key={day} className="weekday">{day}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="days">
|
||||||
|
{days.map((day, index) => {
|
||||||
|
if (day === null) return <div key={index} className="day-cell empty" />
|
||||||
|
const dateKey = toDateKey(day)
|
||||||
|
const hasMessageOnDay = hasMessage(day)
|
||||||
|
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||||
|
const showCount = count > 0
|
||||||
|
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={getDayClassName(day)}
|
||||||
|
onClick={() => handleDateClick(day)}
|
||||||
|
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="day-number">{day}</span>
|
||||||
|
{showCount && <span className="day-count">{count}</span>}
|
||||||
|
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'month' && (
|
||||||
|
<div className="month-grid">
|
||||||
|
{['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
className={`month-cell ${monthIndex === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear(), monthIndex, 1))
|
||||||
|
setViewMode('day')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="days">
|
)}
|
||||||
{days.map((day, index) => {
|
|
||||||
if (day === null) return <div key={index} className="day-cell empty" />
|
{viewMode === 'year' && (
|
||||||
const dateKey = toDateKey(day)
|
<div className="year-grid">
|
||||||
const hasMessageOnDay = hasMessage(day)
|
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
<button
|
||||||
const showCount = count > 0
|
key={year}
|
||||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
className={`year-cell ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||||
return (
|
onClick={() => {
|
||||||
<button
|
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||||
key={index}
|
setViewMode('month')
|
||||||
className={getDayClassName(day)}
|
}}
|
||||||
onClick={() => handleDateClick(day)}
|
type="button"
|
||||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
>
|
||||||
type="button"
|
{year}年
|
||||||
>
|
</button>
|
||||||
<span className="day-number">{day}</span>
|
))}
|
||||||
{showCount && <span className="day-count">{count}</span>}
|
|
||||||
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user