diff --git a/electron/main.ts b/electron/main.ts index d8e2d4b..2b7feb4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1231,6 +1231,9 @@ function registerIpcHandlers() { ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => { return chatService.getMessageDates(sessionId) }) + ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => { + return chatService.getMessageDateCounts(sessionId) + }) ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => { return chatService.resolveVoiceCache(sessionId, msgId) }) diff --git a/electron/preload.ts b/electron/preload.ts index 60b5a9f..46349a6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -182,6 +182,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId), getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), + getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index df0aa0a..4063e8c 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -6475,6 +6475,66 @@ class ChatService { } } + async getMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + let tables = this.sessionTablesCache.get(sessionId) + if (!tables) { + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return { success: false, error: '未找到会话消息表' } + } + tables = tableStats.tables + .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) + .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> + if (tables.length > 0) { + this.sessionTablesCache.set(sessionId, tables) + setTimeout(() => { + this.sessionTablesCache.delete(sessionId) + }, this.sessionTablesCacheTtl) + } + } + + const counts: Record = {} + let hasAnySuccess = false + + for (const { tableName, dbPath } of tables) { + try { + const escapedTableName = String(tableName).replace(/"/g, '""') + const sql = `SELECT strftime('%Y-%m-%d', CASE WHEN create_time > 10000000000 THEN create_time / 1000 ELSE create_time END, 'unixepoch', 'localtime') AS date_key, COUNT(*) AS message_count FROM "${escapedTableName}" WHERE create_time IS NOT NULL GROUP BY date_key` + const result = await wcdbService.execQuery('message', dbPath, sql) + if (!result.success || !Array.isArray(result.rows)) { + console.warn(`[ChatService] 查询每日消息数失败 (${dbPath}):`, result.error) + continue + } + hasAnySuccess = true + result.rows.forEach((row: Record) => { + const date = String(row.date_key || '').trim() + const count = Number(row.message_count || 0) + if (!date || !Number.isFinite(count) || count <= 0) return + counts[date] = (counts[date] || 0) + count + }) + } catch (error) { + console.warn(`[ChatService] 聚合每日消息数失败 (${dbPath}):`, error) + } + } + + if (!hasAnySuccess) { + return { success: false, error: '查询每日消息数失败' } + } + + console.log(`[ChatService] 会话 ${sessionId} 获取到 ${Object.keys(counts).length} 个日期的消息计数`) + return { success: true, counts } + } catch (error) { + console.error('[ChatService] 获取每日消息数失败:', error) + return { success: false, error: String(error) } + } + } + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { // 1. 尝试从缓存获取会话表信息 diff --git a/src/components/JumpToDatePopover.scss b/src/components/JumpToDatePopover.scss new file mode 100644 index 0000000..a9a6ee0 --- /dev/null +++ b/src/components/JumpToDatePopover.scss @@ -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); + } +} diff --git a/src/components/JumpToDatePopover.tsx b/src/components/JumpToDatePopover.tsx new file mode 100644 index 0000000..36e9c39 --- /dev/null +++ b/src/components/JumpToDatePopover.tsx @@ -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 + hasLoadedMessageDates?: boolean + messageDateCounts?: Record + loadingDates?: boolean + loadingDateCounts?: boolean +} + +const JumpToDatePopover: React.FC = ({ + isOpen, + onClose, + onSelect, + currentDate = new Date(), + messageDates, + hasLoadedMessageDates = false, + messageDateCounts, + loadingDates = false, + loadingDateCounts = false +}) => { + const [calendarDate, setCalendarDate] = useState(new Date(currentDate)) + const [selectedDate, setSelectedDate] = useState(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 => { + const daysInMonth = getDaysInMonth(calendarDate) + const firstDay = getFirstDayOfMonth(calendarDate) + const days: Array = [] + + 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 ( +
+
+ + {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 + +
+ +
+ {loadingDates && ( + + + 日期加载中 + + )} + {!loadingDates && loadingDateCounts && ( + + + 条数加载中 + + )} +
+ +
+
+ {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 ( + + ) + })} +
+
+
+ ) +} + +export default JumpToDatePopover diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 80e2a8b..7135f94 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -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 { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index a96ee5e..ea8c5b3 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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(null) const sessionListRef = useRef(null) + const jumpCalendarWrapRef = useRef(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(new Date()) const isDateJumpRef = useRef(false) const [messageDates, setMessageDates] = useState>(new Set()) + const [hasLoadedMessageDates, setHasLoadedMessageDates] = useState(false) const [loadingDates, setLoadingDates] = useState(false) const messageDatesCache = useRef>>(new Map()) + const [messageDateCounts, setMessageDateCounts] = useState>({}) + const [loadingDateCounts, setLoadingDateCounts] = useState(false) + const messageDateCountsCache = useRef>>(new Map()) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [myWxid, setMyWxid] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) @@ -568,6 +574,8 @@ function ChatPage(props: ChatPageProps) { const sessionListPersistTimerRef = useRef(null) const pendingExportRequestIdRef = useRef(null) const exportPrepareLongWaitTimerRef = useRef(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(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 = {} + 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) { )} - - 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} - /> +
+ + setShowJumpPopover(false)} + onSelect={handleJumpDateSelect} + messageDates={messageDates} + hasLoadedMessageDates={hasLoadedMessageDates} + messageDateCounts={messageDateCounts} + loadingDates={loadingDates} + loadingDateCounts={loadingDateCounts} + /> +