mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(chat): replace jump date modal with inline calendar popover
This commit is contained in:
@@ -1231,6 +1231,9 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
||||||
return chatService.getMessageDates(sessionId)
|
return chatService.getMessageDates(sessionId)
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => {
|
||||||
|
return chatService.getMessageDateCounts(sessionId)
|
||||||
|
})
|
||||||
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', 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),
|
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),
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||||
|
|||||||
@@ -6475,6 +6475,66 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; 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<string, number> = {}
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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 }> {
|
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// 1. 尝试从缓存获取会话表信息
|
// 1. 尝试从缓存获取会话表信息
|
||||||
|
|||||||
156
src/components/JumpToDatePopover.scss
Normal file
156
src/components/JumpToDatePopover.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/components/JumpToDatePopover.tsx
Normal file
180
src/components/JumpToDatePopover.tsx
Normal 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
|
||||||
@@ -490,6 +490,12 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
.jump-calendar-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -1626,6 +1632,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
|
.jump-calendar-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getEmojiPath } from 'wechat-emojis'
|
|||||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
import JumpToDatePopover from '../components/JumpToDatePopover'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import {
|
import {
|
||||||
emitOpenSingleExport,
|
emitOpenSingleExport,
|
||||||
@@ -452,14 +452,20 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [])
|
}, [])
|
||||||
const initialRevealTimerRef = useRef<number | null>(null)
|
const initialRevealTimerRef = useRef<number | null>(null)
|
||||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||||
|
const jumpCalendarWrapRef = useRef<HTMLDivElement>(null)
|
||||||
const [currentOffset, setCurrentOffset] = useState(0)
|
const [currentOffset, setCurrentOffset] = useState(0)
|
||||||
const [jumpStartTime, setJumpStartTime] = useState(0)
|
const [jumpStartTime, setJumpStartTime] = useState(0)
|
||||||
const [jumpEndTime, setJumpEndTime] = 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 isDateJumpRef = useRef(false)
|
||||||
const [messageDates, setMessageDates] = useState<Set<string>>(new Set())
|
const [messageDates, setMessageDates] = useState<Set<string>>(new Set())
|
||||||
|
const [hasLoadedMessageDates, setHasLoadedMessageDates] = useState(false)
|
||||||
const [loadingDates, setLoadingDates] = useState(false)
|
const [loadingDates, setLoadingDates] = useState(false)
|
||||||
const messageDatesCache = useRef<Map<string, Set<string>>>(new Map())
|
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 [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
|
||||||
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
|
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||||
@@ -568,6 +574,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const sessionListPersistTimerRef = useRef<number | null>(null)
|
const sessionListPersistTimerRef = useRef<number | null>(null)
|
||||||
const pendingExportRequestIdRef = useRef<string | null>(null)
|
const pendingExportRequestIdRef = useRef<string | null>(null)
|
||||||
const exportPrepareLongWaitTimerRef = useRef<number | null>(null)
|
const exportPrepareLongWaitTimerRef = useRef<number | null>(null)
|
||||||
|
const jumpDatesRequestSeqRef = useRef(0)
|
||||||
|
const jumpDateCountsRequestSeqRef = useRef(0)
|
||||||
|
|
||||||
const isGroupChatSession = useCallback((username: string) => {
|
const isGroupChatSession = useCallback((username: string) => {
|
||||||
return username.includes('@chatroom')
|
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(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = onExportSessionStatus((payload) => {
|
const unsubscribe = onExportSessionStatus((payload) => {
|
||||||
const ids = Array.isArray(payload?.inProgressSessionIds)
|
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 () => {
|
const loadLaterMessages = useCallback(async () => {
|
||||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
|
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
|
||||||
@@ -2300,6 +2410,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开
|
// 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开
|
||||||
|
setShowJumpPopover(false)
|
||||||
setShowDetailPanel(false)
|
setShowDetailPanel(false)
|
||||||
setShowGroupMembersPanel(false)
|
setShowGroupMembersPanel(false)
|
||||||
setGroupMemberSearchKeyword('')
|
setGroupMemberSearchKeyword('')
|
||||||
@@ -2624,6 +2735,29 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
searchKeywordRef.current = searchKeyword
|
searchKeywordRef.current = searchKeyword
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return
|
if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return
|
||||||
persistSessionPreviewCache(currentSessionId, messages)
|
persistSessionPreviewCache(currentSessionId, messages)
|
||||||
@@ -3636,53 +3770,26 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<ImageIcon size={18} />
|
<ImageIcon size={18} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="jump-calendar-anchor" ref={jumpCalendarWrapRef}>
|
||||||
className="icon-btn jump-to-time-btn"
|
<button
|
||||||
onClick={async () => {
|
className={`icon-btn jump-to-time-btn ${showJumpPopover ? 'active' : ''}`}
|
||||||
setShowJumpDialog(true)
|
onClick={handleToggleJumpPopover}
|
||||||
if (!currentSessionId) return
|
title="跳转到指定时间"
|
||||||
// 检查缓存
|
>
|
||||||
const cached = messageDatesCache.current.get(currentSessionId)
|
<Calendar size={18} />
|
||||||
if (cached) {
|
</button>
|
||||||
setMessageDates(cached)
|
<JumpToDatePopover
|
||||||
return
|
isOpen={showJumpPopover}
|
||||||
}
|
currentDate={jumpPopoverDate}
|
||||||
// 获取消息日期
|
onClose={() => setShowJumpPopover(false)}
|
||||||
setMessageDates(new Set()) // 清除旧数据
|
onSelect={handleJumpDateSelect}
|
||||||
setLoadingDates(true)
|
messageDates={messageDates}
|
||||||
try {
|
hasLoadedMessageDates={hasLoadedMessageDates}
|
||||||
const result = await (window as any).electronAPI.chat.getMessageDates(currentSessionId)
|
messageDateCounts={messageDateCounts}
|
||||||
if (result?.success && result.dates) {
|
loadingDates={loadingDates}
|
||||||
const dateSet = new Set<string>(result.dates)
|
loadingDateCounts={loadingDateCounts}
|
||||||
setMessageDates(dateSet)
|
/>
|
||||||
messageDatesCache.current.set(currentSessionId, dateSet)
|
</div>
|
||||||
}
|
|
||||||
} 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}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
className="icon-btn refresh-messages-btn"
|
className="icon-btn refresh-messages-btn"
|
||||||
onClick={handleRefreshMessages}
|
onClick={handleRefreshMessages}
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -288,6 +288,8 @@ export interface ElectronAPI {
|
|||||||
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
|
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
|
||||||
error?: string
|
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 }>
|
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 }>
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||||
|
|||||||
Reference in New Issue
Block a user