feat(chat): replace jump date modal with inline calendar popover

This commit is contained in:
tisonhuang
2026-03-04 19:20:00 +08:00
parent 1652ebc4ad
commit 4b57e3e350
8 changed files with 570 additions and 49 deletions

View File

@@ -0,0 +1,156 @@
.jump-date-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 312px;
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--card-bg);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
padding: 12px;
z-index: 1600;
}
.jump-date-popover .calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.jump-date-popover .current-month {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.jump-date-popover .nav-btn {
width: 28px;
height: 28px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.18s ease;
}
.jump-date-popover .nav-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--bg-hover);
}
.jump-date-popover .status-line {
min-height: 16px;
margin-bottom: 6px;
}
.jump-date-popover .status-item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-tertiary);
font-size: 11px;
}
.jump-date-popover .calendar-grid .weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 6px;
}
.jump-date-popover .calendar-grid .weekday {
text-align: center;
font-size: 11px;
color: var(--text-tertiary);
}
.jump-date-popover .calendar-grid .days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
}
.jump-date-popover .day-cell {
position: relative;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 13px;
transition: all 0.18s ease;
}
.jump-date-popover .day-cell .day-number {
position: relative;
z-index: 1;
}
.jump-date-popover .day-cell.empty {
cursor: default;
background: transparent;
}
.jump-date-popover .day-cell:not(.empty):not(.no-message):hover {
background: var(--bg-hover);
}
.jump-date-popover .day-cell.today {
border-color: var(--primary-light);
color: var(--primary);
}
.jump-date-popover .day-cell.selected {
background: var(--primary);
color: #fff;
}
.jump-date-popover .day-cell.no-message {
opacity: 0.5;
cursor: default;
}
.jump-date-popover .day-count {
position: absolute;
right: 3px;
top: 2px;
font-size: 10px;
line-height: 1;
color: var(--text-secondary);
font-weight: 600;
}
.jump-date-popover .day-cell.selected .day-count {
color: rgba(255, 255, 255, 0.92);
}
.jump-date-popover .day-count-loading {
position: absolute;
right: 3px;
top: 2px;
color: var(--text-tertiary);
}
.jump-date-popover .spin {
animation: jump-date-spin 1s linear infinite;
}
@keyframes jump-date-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,180 @@
import React, { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import './JumpToDatePopover.scss'
interface JumpToDatePopoverProps {
isOpen: boolean
onClose: () => void
onSelect: (date: Date) => void
currentDate?: Date
messageDates?: Set<string>
hasLoadedMessageDates?: boolean
messageDateCounts?: Record<string, number>
loadingDates?: boolean
loadingDateCounts?: boolean
}
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
isOpen,
onClose,
onSelect,
currentDate = new Date(),
messageDates,
hasLoadedMessageDates = false,
messageDateCounts,
loadingDates = false,
loadingDateCounts = false
}) => {
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
useEffect(() => {
if (!isOpen) return
const normalized = new Date(currentDate)
setCalendarDate(normalized)
setSelectedDate(normalized)
}, [isOpen, currentDate])
if (!isOpen) return null
const getDaysInMonth = (date: Date): number => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date): number => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const toDateKey = (day: number): string => {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth() + 1
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
const hasMessage = (day: number): boolean => {
if (!hasLoadedMessageDates) return true
if (!messageDates || messageDates.size === 0) return false
return messageDates.has(toDateKey(day))
}
const isToday = (day: number): boolean => {
const today = new Date()
return day === today.getDate()
&& calendarDate.getMonth() === today.getMonth()
&& calendarDate.getFullYear() === today.getFullYear()
}
const isSelected = (day: number): boolean => {
return day === selectedDate.getDate()
&& calendarDate.getMonth() === selectedDate.getMonth()
&& calendarDate.getFullYear() === selectedDate.getFullYear()
}
const generateCalendar = (): Array<number | null> => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: Array<number | null> = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
const handleDateClick = (day: number) => {
if (hasLoadedMessageDates && !hasMessage(day)) return
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(targetDate)
onSelect(targetDate)
onClose()
}
const getDayClassName = (day: number | null): string => {
if (day === null) return 'day-cell empty'
const classes = ['day-cell']
if (isToday(day)) classes.push('today')
if (isSelected(day)) classes.push('selected')
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
return classes.join(' ')
}
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
return (
<div className="jump-date-popover" role="dialog" aria-label="跳转日期">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
aria-label="上一月"
>
<ChevronLeft size={16} />
</button>
<span className="current-month">{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}</span>
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
aria-label="下一月"
>
<ChevronRight size={16} />
</button>
</div>
<div className="status-line">
{loadingDates && (
<span className="status-item">
<Loader2 size={12} className="spin" />
<span></span>
</span>
)}
{!loadingDates && loadingDateCounts && (
<span className="status-item">
<Loader2 size={12} className="spin" />
<span></span>
</span>
)}
</div>
<div className="calendar-grid">
<div className="weekdays">
{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>
</div>
)
}
export default JumpToDatePopover

View File

@@ -490,6 +490,12 @@
gap: 8px;
-webkit-app-region: no-drag;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
}
.icon-btn {
width: 34px;
height: 34px;
@@ -1626,6 +1632,12 @@
display: flex;
align-items: center;
gap: 8px;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
}
}
.icon-btn {

View File

@@ -10,7 +10,7 @@ import { getEmojiPath } from 'wechat-emojis'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDateDialog from '../components/JumpToDateDialog'
import JumpToDatePopover from '../components/JumpToDatePopover'
import * as configService from '../services/config'
import {
emitOpenSingleExport,
@@ -452,14 +452,20 @@ function ChatPage(props: ChatPageProps) {
}, [])
const initialRevealTimerRef = useRef<number | null>(null)
const sessionListRef = useRef<HTMLDivElement>(null)
const jumpCalendarWrapRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0)
const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [showJumpPopover, setShowJumpPopover] = useState(false)
const [jumpPopoverDate, setJumpPopoverDate] = useState<Date>(new Date())
const isDateJumpRef = useRef(false)
const [messageDates, setMessageDates] = useState<Set<string>>(new Set())
const [hasLoadedMessageDates, setHasLoadedMessageDates] = useState(false)
const [loadingDates, setLoadingDates] = useState(false)
const messageDatesCache = useRef<Map<string, Set<string>>>(new Map())
const [messageDateCounts, setMessageDateCounts] = useState<Record<string, number>>({})
const [loadingDateCounts, setLoadingDateCounts] = useState(false)
const messageDateCountsCache = useRef<Map<string, Record<string, number>>>(new Map())
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
@@ -568,6 +574,8 @@ function ChatPage(props: ChatPageProps) {
const sessionListPersistTimerRef = useRef<number | null>(null)
const pendingExportRequestIdRef = useRef<string | null>(null)
const exportPrepareLongWaitTimerRef = useRef<number | null>(null)
const jumpDatesRequestSeqRef = useRef(0)
const jumpDateCountsRequestSeqRef = useRef(0)
const isGroupChatSession = useCallback((username: string) => {
return username.includes('@chatroom')
@@ -583,6 +591,95 @@ function ChatPage(props: ChatPageProps) {
}
}, [])
const resolveCurrentViewDate = useCallback(() => {
if (jumpStartTime > 0) {
return new Date(jumpStartTime * 1000)
}
const fallbackMessage = messages[messages.length - 1] || messages[0]
const rawTimestamp = Number(fallbackMessage?.createTime || 0)
if (Number.isFinite(rawTimestamp) && rawTimestamp > 0) {
return new Date(rawTimestamp > 10000000000 ? rawTimestamp : rawTimestamp * 1000)
}
return new Date()
}, [jumpStartTime, messages])
const loadJumpCalendarData = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
const cachedDates = messageDatesCache.current.get(normalizedSessionId)
if (cachedDates) {
setMessageDates(new Set(cachedDates))
setHasLoadedMessageDates(true)
setLoadingDates(false)
} else {
setLoadingDates(true)
setHasLoadedMessageDates(false)
setMessageDates(new Set())
const requestSeq = jumpDatesRequestSeqRef.current + 1
jumpDatesRequestSeqRef.current = requestSeq
try {
const result = await window.electronAPI.chat.getMessageDates(normalizedSessionId)
if (requestSeq !== jumpDatesRequestSeqRef.current || currentSessionRef.current !== normalizedSessionId) return
if (result?.success && Array.isArray(result.dates)) {
const dateSet = new Set<string>(result.dates)
messageDatesCache.current.set(normalizedSessionId, dateSet)
setMessageDates(new Set(dateSet))
setHasLoadedMessageDates(true)
}
} catch (error) {
console.error('获取消息日期失败:', error)
} finally {
if (requestSeq === jumpDatesRequestSeqRef.current && currentSessionRef.current === normalizedSessionId) {
setLoadingDates(false)
}
}
}
const cachedCounts = messageDateCountsCache.current.get(normalizedSessionId)
if (cachedCounts) {
setMessageDateCounts({ ...cachedCounts })
setLoadingDateCounts(false)
return
}
setLoadingDateCounts(true)
setMessageDateCounts({})
const requestSeq = jumpDateCountsRequestSeqRef.current + 1
jumpDateCountsRequestSeqRef.current = requestSeq
try {
const result = await window.electronAPI.chat.getMessageDateCounts(normalizedSessionId)
if (requestSeq !== jumpDateCountsRequestSeqRef.current || currentSessionRef.current !== normalizedSessionId) return
if (result?.success && result.counts) {
const normalizedCounts: Record<string, number> = {}
Object.entries(result.counts).forEach(([date, value]) => {
const count = Number(value)
if (!date || !Number.isFinite(count) || count <= 0) return
normalizedCounts[date] = count
})
messageDateCountsCache.current.set(normalizedSessionId, normalizedCounts)
setMessageDateCounts(normalizedCounts)
}
} catch (error) {
console.error('获取每日消息数失败:', error)
} finally {
if (requestSeq === jumpDateCountsRequestSeqRef.current && currentSessionRef.current === normalizedSessionId) {
setLoadingDateCounts(false)
}
}
}, [])
const handleToggleJumpPopover = useCallback(() => {
if (!currentSessionId) return
if (showJumpPopover) {
setShowJumpPopover(false)
return
}
setJumpPopoverDate(resolveCurrentViewDate())
setShowJumpPopover(true)
void loadJumpCalendarData(currentSessionId)
}, [currentSessionId, loadJumpCalendarData, resolveCurrentViewDate, showJumpPopover])
useEffect(() => {
const unsubscribe = onExportSessionStatus((payload) => {
const ids = Array.isArray(payload?.inProgressSessionIds)
@@ -2209,6 +2306,19 @@ function ChatPage(props: ChatPageProps) {
}
}
const handleJumpDateSelect = useCallback((date: Date) => {
if (!currentSessionId) return
const targetDate = new Date(date)
const start = Math.floor(targetDate.setHours(0, 0, 0, 0) / 1000)
const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000)
isDateJumpRef.current = true
setCurrentOffset(0)
setJumpStartTime(start)
setJumpEndTime(end)
setShowJumpPopover(false)
void loadMessages(currentSessionId, 0, start, end, true)
}, [currentSessionId, loadMessages])
// 加载更晚的消息
const loadLaterMessages = useCallback(async () => {
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
@@ -2300,6 +2410,7 @@ function ChatPage(props: ChatPageProps) {
})
}
// 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开
setShowJumpPopover(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setGroupMemberSearchKeyword('')
@@ -2624,6 +2735,29 @@ function ChatPage(props: ChatPageProps) {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
useEffect(() => {
if (!showJumpPopover) return
const handleGlobalPointerDown = (event: MouseEvent) => {
const target = event.target as Node | null
if (!target) return
if (jumpCalendarWrapRef.current?.contains(target)) return
setShowJumpPopover(false)
}
document.addEventListener('mousedown', handleGlobalPointerDown)
return () => {
document.removeEventListener('mousedown', handleGlobalPointerDown)
}
}, [showJumpPopover])
useEffect(() => {
setShowJumpPopover(false)
setLoadingDates(false)
setLoadingDateCounts(false)
setHasLoadedMessageDates(false)
setMessageDates(new Set())
setMessageDateCounts({})
}, [currentSessionId])
useEffect(() => {
if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return
persistSessionPreviewCache(currentSessionId, messages)
@@ -3636,53 +3770,26 @@ function ChatPage(props: ChatPageProps) {
<ImageIcon size={18} />
)}
</button>
<button
className="icon-btn jump-to-time-btn"
onClick={async () => {
setShowJumpDialog(true)
if (!currentSessionId) return
// 检查缓存
const cached = messageDatesCache.current.get(currentSessionId)
if (cached) {
setMessageDates(cached)
return
}
// 获取消息日期
setMessageDates(new Set()) // 清除旧数据
setLoadingDates(true)
try {
const result = await (window as any).electronAPI.chat.getMessageDates(currentSessionId)
if (result?.success && result.dates) {
const dateSet = new Set<string>(result.dates)
setMessageDates(dateSet)
messageDatesCache.current.set(currentSessionId, dateSet)
}
} catch (e) {
console.error('获取消息日期失败:', e)
} finally {
setLoadingDates(false)
}
}}
title="跳转到指定时间"
>
<Calendar size={18} />
</button>
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => setShowJumpDialog(false)}
onSelect={(date) => {
if (!currentSessionId) return
const start = Math.floor(date.setHours(0, 0, 0, 0) / 1000)
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
isDateJumpRef.current = true
setCurrentOffset(0)
setJumpStartTime(start)
setJumpEndTime(end)
loadMessages(currentSessionId, 0, start, end, true)
}}
messageDates={messageDates}
loadingDates={loadingDates}
/>
<div className="jump-calendar-anchor" ref={jumpCalendarWrapRef}>
<button
className={`icon-btn jump-to-time-btn ${showJumpPopover ? 'active' : ''}`}
onClick={handleToggleJumpPopover}
title="跳转到指定时间"
>
<Calendar size={18} />
</button>
<JumpToDatePopover
isOpen={showJumpPopover}
currentDate={jumpPopoverDate}
onClose={() => setShowJumpPopover(false)}
onSelect={handleJumpDateSelect}
messageDates={messageDates}
hasLoadedMessageDates={hasLoadedMessageDates}
messageDateCounts={messageDateCounts}
loadingDates={loadingDates}
loadingDateCounts={loadingDateCounts}
/>
</div>
<button
className="icon-btn refresh-messages-btn"
onClick={handleRefreshMessages}

View File

@@ -288,6 +288,8 @@ export interface ElectronAPI {
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
error?: string
}>
getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; error?: string }>
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void