diff --git a/electron/main.ts b/electron/main.ts index d0cb133..c59b487 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' +import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' import { Worker } from 'worker_threads' import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' @@ -17,6 +17,7 @@ import { exportService, ExportOptions, ExportProgress } from './services/exportS import { KeyService } from './services/keyService' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' +import { snsService } from './services/snsService' // 配置自动更新 @@ -614,8 +615,8 @@ function registerIpcHandlers() { return chatService.enrichSessionsContactInfo(usernames) }) - ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { - return chatService.getMessages(sessionId, offset, limit) + ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => { + return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending) }) ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => { @@ -672,6 +673,10 @@ function registerIpcHandlers() { return chatService.getMessageById(sessionId, localId) }) + ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { + return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) + }) + // 私聊克隆 @@ -977,6 +982,17 @@ app.whenReady().then(() => { createOnboardingWindow() } + // 解决朋友圈图片无法加载问题(添加 Referer) + session.defaultSession.webRequest.onBeforeSendHeaders( + { + urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*'] + }, + (details, callback) => { + details.requestHeaders['Referer'] = 'https://wx.qq.com/' + callback({ requestHeaders: details.requestHeaders }) + } + ) + // 启动时检测更新 checkForUpdatesOnStartup() diff --git a/electron/preload.ts b/electron/preload.ts index e70b70b..6b02eba 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -98,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessions: () => ipcRenderer.invoke('chat:getSessions'), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), - getMessages: (sessionId: string, offset?: number, limit?: number) => - ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), + getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => + ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending), getLatestMessages: (sessionId: string, limit?: number) => ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), @@ -207,5 +207,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('whisper:downloadProgress') } + }, + + // 朋友圈 + sns: { + getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => + ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime) } }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e37540a..057927b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -74,7 +74,7 @@ const emojiDownloading: Map> = new Map() class ChatService { private configService: ConfigService private connected = false - private messageCursors: Map = new Map() + private messageCursors: Map = new Map() private readonly messageBatchDefault = 50 private avatarCache: Map private readonly avatarCacheTtlMs = 10 * 60 * 1000 @@ -430,9 +430,9 @@ class ChatService { } if (!headImageDbPath) { - console.log('[头像加载] 未找到 head_image.db', { - accountDir, - checkedPaths: headImageDbPaths + console.log('[头像加载] 未找到 head_image.db', { + accountDir, + checkedPaths: headImageDbPaths }) return result } @@ -444,7 +444,7 @@ class ChatService { try { const stmt = db.prepare('SELECT username, image_buffer FROM head_image WHERE username = ?') - + for (const username of usernames) { try { const row = stmt.get(username) as any @@ -454,9 +454,9 @@ class ChatService { result[username] = `data:image/jpeg;base64,${base64}` } else { // 只输出没有找到头像的 - console.log(`[头像加载] 未找到头像: ${username}`, { - hasRow: !!row, - hasBuffer: row ? !!row.image_buffer : false + console.log(`[头像加载] 未找到头像: ${username}`, { + hasRow: !!row, + hasBuffer: row ? !!row.image_buffer : false }) } } catch (e) { @@ -501,7 +501,10 @@ class ChatService { async getMessages( sessionId: string, offset: number = 0, - limit: number = 50 + limit: number = 50, + startTime: number = 0, + endTime: number = 0, + ascending: boolean = false ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { try { const connectResult = await this.ensureConnected() @@ -516,7 +519,14 @@ class ChatService { // 1. 没有游标状态 // 2. offset 为 0 (重新加载会话) // 3. batchSize 改变 - const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize + // 4. startTime 改变 + // 5. ascending 改变 + const needNewCursor = !state || + offset === 0 || + state.batchSize !== batchSize || + state.startTime !== startTime || + state.endTime !== endTime || + state.ascending !== ascending if (needNewCursor) { // 关闭旧游标 @@ -529,13 +539,16 @@ class ChatService { } // 创建新游标 - const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0) + // 注意:WeFlow 数据库中的 create_time 是以秒为单位的 + const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime + const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime + const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp) if (!cursorResult.success || !cursorResult.cursor) { console.error('[ChatService] 打开消息游标失败:', cursorResult.error) return { success: false, error: cursorResult.error || '打开消息游标失败' } } - state = { cursor: cursorResult.cursor, fetched: 0, batchSize } + state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) // 如果需要跳过消息(offset > 0),逐批获取但不返回 diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts new file mode 100644 index 0000000..bf674f4 --- /dev/null +++ b/electron/services/snsService.ts @@ -0,0 +1,64 @@ +import { wcdbService } from './wcdbService' +import { ConfigService } from './config' +import { ContactCacheService } from './contactCacheService' + +export interface SnsPost { + id: string + username: string + nickname: string + avatarUrl?: string + createTime: number + contentDesc: string + type?: number + media: { url: string; thumb: string }[] + likes: string[] + comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] +} + +class SnsService { + private contactCache: ContactCacheService + + constructor() { + const config = new ConfigService() + this.contactCache = new ContactCacheService(config.get('cachePath') as string) + } + + async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { + console.log('[SnsService] getTimeline called with:', { limit, offset, usernames, keyword, startTime, endTime }) + + const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) + + console.log('[SnsService] getSnsTimeline result:', { + success: result.success, + timelineCount: result.timeline?.length, + error: result.error + }) + + if (result.success && result.timeline) { + const enrichedTimeline = result.timeline.map((post: any) => { + const contact = this.contactCache.get(post.username) + + // 修复媒体 URL,如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https,但通常支持) + const fixedMedia = post.media.map((m: any) => ({ + url: m.url.replace('http://', 'https://'), + thumb: m.thumb.replace('http://', 'https://') + })) + + return { + ...post, + avatarUrl: contact?.avatarUrl, + nickname: post.nickname || contact?.displayName || post.username, + media: fixedMedia + } + }) + + console.log('[SnsService] Returning enriched timeline with', enrichedTimeline.length, 'posts') + return { ...result, timeline: enrichedTimeline } + } + + console.log('[SnsService] Returning result:', result) + return result + } +} + +export const snsService = new SnsService() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index e3c9baf..93952e8 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -55,6 +55,7 @@ export class WcdbCore { private wcdbGetEmoticonCdnUrl: any = null private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null + private wcdbGetSnsTimeline: any = null private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 private logTimer: NodeJS.Timeout | null = null @@ -116,7 +117,8 @@ export class WcdbCore { private writeLog(message: string, force = false): void { if (!force && !this.isLogEnabled()) return const line = `[${new Date().toISOString()}] ${message}` - // 移除控制台日志,只写入文件 + // 同时输出到控制台和文件 + console.log('[WCDB]', message) try { const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const dir = join(base, 'logs') @@ -385,6 +387,13 @@ export class WcdbCore { this.wcdbGetVoiceData = null } + // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json) + try { + this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsTimeline = null + } + // 初始化 const initResult = this.wcdbInit() if (initResult !== 0) { @@ -401,8 +410,8 @@ export class WcdbCore { this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) lastDllInitError = errorMsg // 检查是否是常见的 VC++ 运行时缺失错误 - if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') || - errorMsg.includes('The specified module could not be found')) { + if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') || + errorMsg.includes('The specified module could not be found')) { lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。' } else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) { lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。' @@ -1388,4 +1397,32 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' } + try { + const outPtr = [null as any] + const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : '' + const result = this.wcdbGetSnsTimeline( + this.handle, + limit, + offset, + usernamesJson, + keyword || '', + startTime || 0, + endTime || 0, + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取朋友圈失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' } + const timeline = JSON.parse(jsonStr) + return { success: true, timeline } + } catch (e) { + return { success: false, error: String(e) } + } + } } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index bdcd2bc..4a1d76c 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -362,6 +362,13 @@ export class WcdbService { return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId }) } + /** + * 获取朋友圈 + */ + async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { + return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime }) + } + } export const wcdbService = new WcdbService() diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index d836a79..64f001d 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -116,6 +116,9 @@ if (parentPort) { console.error('[wcdbWorker] getVoiceData failed:', result.error) } break + case 'getSnsTimeline': + result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) + break default: result = { success: false, error: `Unknown method: ${type}` } } diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 753d734..09af338 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index 98fe58b..5bad3d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import DataManagementPage from './pages/DataManagementPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' +import SnsPage from './pages/SnsPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' @@ -205,11 +206,11 @@ function App() { // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 // 其他错误可能需要重新配置 const errorMsg = result.error || '' - if (errorMsg.includes('Visual C++') || - errorMsg.includes('DLL') || - errorMsg.includes('Worker') || - errorMsg.includes('126') || - errorMsg.includes('模块')) { + if (errorMsg.includes('Visual C++') || + errorMsg.includes('DLL') || + errorMsg.includes('Worker') || + errorMsg.includes('126') || + errorMsg.includes('模块')) { console.warn('检测到可能的运行时依赖问题:', errorMsg) // 不清除配置,让用户安装 VC++ 后重试 } @@ -336,6 +337,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/JumpToDateDialog.scss b/src/components/JumpToDateDialog.scss new file mode 100644 index 0000000..cd20c37 --- /dev/null +++ b/src/components/JumpToDateDialog.scss @@ -0,0 +1,238 @@ +.jump-date-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + animation: fadeIn 0.2s ease-out; +} + +.jump-date-modal { + background: var(--card-bg); + width: 340px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.jump-date-header { + padding: 18px 20px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + + .title-area { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); + + svg { + color: var(--primary); + } + + h3 { + font-size: 16px; + font-weight: 600; + margin: 0; + } + } + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } +} + +.calendar-view { + padding: 20px; + + .calendar-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + .current-month { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .nav-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + color: var(--primary); + } + } + } +} + +.calendar-grid { + .weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 8px; + + .weekday { + text-align: center; + font-size: 12px; + font-weight: 500; + color: var(--text-tertiary); + padding: 4px 0; + } + } + + .days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + + .day-cell { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--text-primary); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + + &.empty { + cursor: default; + } + + &:not(.empty):hover { + background: var(--bg-hover); + } + + &.selected { + background: var(--primary); + color: #fff; + font-weight: 600; + } + + &.today:not(.selected) { + color: var(--primary); + font-weight: 600; + background: var(--primary-light); + } + } + } +} + +.quick-options { + display: flex; + gap: 8px; + padding: 0 20px 16px; + + button { + flex: 1; + padding: 8px; + font-size: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--primary); + border-color: var(--primary); + } + } +} + +.dialog-footer { + padding: 16px 20px; + display: flex; + gap: 12px; + background: var(--bg-secondary); + + button { + flex: 1; + padding: 10px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .cancel-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .confirm-btn { + background: var(--primary); + border: none; + color: #fff; + + &:hover { + background: var(--primary-hover); + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes modalSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/src/components/JumpToDateDialog.tsx b/src/components/JumpToDateDialog.tsx new file mode 100644 index 0000000..6876069 --- /dev/null +++ b/src/components/JumpToDateDialog.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react' +import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react' +import './JumpToDateDialog.scss' + +interface JumpToDateDialogProps { + isOpen: boolean + onClose: () => void + onSelect: (date: Date) => void + currentDate?: Date +} + +const JumpToDateDialog: React.FC = ({ + isOpen, + onClose, + onSelect, + currentDate = new Date() +}) => { + const [calendarDate, setCalendarDate] = useState(new Date(currentDate)) + const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) + + if (!isOpen) return null + + const getDaysInMonth = (date: Date) => { + const year = date.getFullYear() + const month = date.getMonth() + return new Date(year, month + 1, 0).getDate() + } + + const getFirstDayOfMonth = (date: Date) => { + const year = date.getFullYear() + const month = date.getMonth() + return new Date(year, month, 1).getDay() + } + + const generateCalendar = () => { + const daysInMonth = getDaysInMonth(calendarDate) + const firstDay = getFirstDayOfMonth(calendarDate) + const days: (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) => { + const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) + setSelectedDate(newDate) + } + + const handleConfirm = () => { + onSelect(selectedDate) + onClose() + } + + const isToday = (day: number) => { + const today = new Date() + return day === today.getDate() && + calendarDate.getMonth() === today.getMonth() && + calendarDate.getFullYear() === today.getFullYear() + } + + const isSelected = (day: number) => { + return day === selectedDate.getDate() && + calendarDate.getMonth() === selectedDate.getMonth() && + calendarDate.getFullYear() === selectedDate.getFullYear() + } + + const weekdays = ['日', '一', '二', '三', '四', '五', '六'] + const days = generateCalendar() + + return ( +
+
e.stopPropagation()}> +
+
+ +

跳转到日期

+
+ +
+ +
+
+ + + {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 + + +
+ +
+
+ {weekdays.map(d =>
{d}
)} +
+
+ {days.map((day, i) => ( +
day !== null && handleDateClick(day)} + > + {day} +
+ ))} +
+
+
+ +
+ + + +
+ +
+ + +
+
+
+ ) +} + +export default JumpToDateDialog diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b2fd84b..08188e5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react' +import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react' import './Sidebar.scss' function Sidebar() { @@ -34,6 +34,16 @@ function Sidebar() { 聊天 + {/* 朋友圈 */} + + + 朋友圈 + + {/* 私聊分析 */} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 33e89bb..80fb8ca 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -489,8 +489,21 @@ } .load-more-trigger { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 0; color: var(--text-tertiary); - font-size: 12px; + font-size: 13px; + + &.later { + padding: 24px 0 12px; + } + + svg { + animation: spin 1s linear infinite; + } } .empty-chat { @@ -1660,7 +1673,7 @@ max-width: 100%; min-width: 0; // 允许收缩 -webkit-app-region: no-drag; - + // 让气泡宽度由内容决定,而不是被父容器撑开 width: fit-content; } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 174c02f..c5a4467 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -7,6 +7,7 @@ import { getEmojiPath } from 'wechat-emojis' import { ImagePreview } from '../components/ImagePreview' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' +import JumpToDateDialog from '../components/JumpToDateDialog' import * as configService from '../services/config' import './ChatPage.scss' @@ -132,15 +133,25 @@ function ChatPage(_props: ChatPageProps) { setLoadingMessages, setLoadingMore, setHasMoreMessages, + hasMoreLater, + setHasMoreLater, setSearchKeyword } = useChatStore() const messageListRef = useRef(null) const searchInputRef = useRef(null) const sidebarRef = useRef(null) + + const getMessageKey = useCallback((msg: Message): string => { + if (msg.localId && msg.localId > 0) return `l:${msg.localId}` + return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` + }, []) const initialRevealTimerRef = useRef(null) const sessionListRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) + const [jumpStartTime, setJumpStartTime] = useState(0) + const [jumpEndTime, setJumpEndTime] = useState(0) + const [showJumpDialog, setShowJumpDialog] = useState(false) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [myWxid, setMyWxid] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) @@ -477,6 +488,9 @@ function ChatPage(_props: ChatPageProps) { // 刷新会话列表 const handleRefresh = async () => { + setJumpStartTime(0) + setJumpEndTime(0) + setHasMoreLater(false) await loadSessions({ silent: true }) } @@ -484,6 +498,9 @@ function ChatPage(_props: ChatPageProps) { const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) const handleRefreshMessages = async () => { if (!currentSessionId || isRefreshingMessages) return + setJumpStartTime(0) + setJumpEndTime(0) + setHasMoreLater(false) setIsRefreshingMessages(true) try { // 获取最新消息并增量添加 @@ -518,7 +535,7 @@ function ChatPage(_props: ChatPageProps) { } // 加载消息 - const loadMessages = async (sessionId: string, offset = 0) => { + const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => { const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 @@ -535,7 +552,7 @@ function ChatPage(_props: ChatPageProps) { const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null try { - const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit) + const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) @@ -601,6 +618,14 @@ function ChatPage(_props: ChatPageProps) { } } setHasMoreMessages(result.hasMore ?? false) + // 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息 + if (offset === 0) { + if (endTime > 0) { + setHasMoreLater(true) + } else { + setHasMoreLater(false) + } + } setCurrentOffset(offset + result.messages.length) } else if (!result.success) { setConnectionError(result.error || '加载消息失败') @@ -616,12 +641,41 @@ function ChatPage(_props: ChatPageProps) { } } + // 加载更晚的消息 + const loadLaterMessages = useCallback(async () => { + if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return + + setLoadingMore(true) + try { + const lastMsg = messages[messages.length - 1] + // 从最后一条消息的时间开始往后找 + const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) + + if (result.success && result.messages) { + // 过滤掉已经在列表中的重复消息 + const existingKeys = messageKeySetRef.current + const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) + + if (newMsgs.length > 0) { + appendMessages(newMsgs, false) + } + setHasMoreLater(result.hasMore ?? false) + } + } catch (e) { + console.error('加载后续消息失败:', e) + } finally { + setLoadingMore(false) + } + }, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore]) + // 选择会话 const handleSelectSession = (session: ChatSession) => { if (session.username === currentSessionId) return setCurrentSession(session.username) setCurrentOffset(0) - loadMessages(session.username, 0) + setJumpStartTime(0) + setJumpEndTime(0) + loadMessages(session.username, 0, 0, 0) // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { @@ -678,16 +732,21 @@ function ChatPage(_props: ChatPageProps) { if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { const threshold = clientHeight * 0.3 if (scrollTop < threshold) { - loadMessages(currentSessionId, currentOffset) + loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + } + } + + // 预加载更晚的消息 + if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) { + const threshold = clientHeight * 0.3 + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + if (distanceFromBottom < threshold) { + loadLaterMessages() } } }) - }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages]) + }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages]) - const getMessageKey = useCallback((msg: Message): string => { - if (msg.localId && msg.localId > 0) return `l:${msg.localId}` - return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` - }, []) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { return ( @@ -1102,6 +1161,25 @@ function ChatPage(_props: ChatPageProps) { )}
+ + setShowJumpDialog(false)} + onSelect={(date) => { + if (!currentSessionId) return + const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000) + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(end) + loadMessages(currentSessionId, 0, 0, end) + }} + />
+ )} + {/* 回到底部按钮 */}
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 929c3a3..9783061 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -231,12 +231,12 @@ function ExportPage() { exportImages: options.exportMedia && options.exportImages, exportVoices: options.exportMedia && options.exportVoices, exportEmojis: options.exportMedia && options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia + exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 excelCompactColumns: options.excelCompactColumns, sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), - // ?????????????????????????????????23:59:59,?????????????????????????????? + // 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录 end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) } : null } @@ -249,10 +249,10 @@ function ExportPage() { ) setExportResult(result) } else { - setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` }) + setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` }) } } catch (e) { - console.error('????????????:', e) + console.error('导出过程中发生异常:', e) setExportResult({ success: false, error: String(e) }) } finally { setIsExporting(false) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss new file mode 100644 index 0000000..005e2b4 --- /dev/null +++ b/src/pages/SnsPage.scss @@ -0,0 +1,579 @@ +.sns-page { + height: 100%; + background: var(--bg-primary); + color: var(--text-primary); + overflow: hidden; + + .sns-container { + display: flex; + height: 100%; + } + + .sns-sidebar { + width: 280px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; + + &.closed { + width: 0; + opacity: 0; + pointer-events: none; + } + + .sidebar-header { + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + } + + .toggle-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + + &:hover { + background: var(--hover-bg); + color: var(--text-primary); + } + } + } + + .filter-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + + /* 自定义滚动条 */ + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 10px; + } + } + + .filter-group { + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color); + } + + .filter-section { + padding: 10px 20px; + + label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; + font-weight: 500; + } + + input[type="text"] { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px 12px; + color: var(--text-primary); + font-size: 13px; + outline: none; + + &:focus { + border-color: var(--accent-color); + } + } + + .date-inputs { + display: flex; + flex-direction: column; + gap: 6px; + + input[type="date"] { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 10px; + color: var(--text-primary); + font-size: 12px; + outline: none; + width: 100%; + + &:focus { + border-color: var(--accent-color); + } + } + + span { + font-size: 11px; + color: var(--text-tertiary); + text-align: center; + } + } + } + + .contact-filter-section { + flex: 1; + display: flex; + flex-direction: column; + min-height: 200px; + padding: 12px 0 0 0; + + .section-header { + padding: 0 20px 8px 20px; + display: flex; + justify-content: space-between; + align-items: center; + + label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; + } + + .selected-count { + font-size: 11px; + background: var(--accent-color); + color: white; + padding: 1px 6px; + border-radius: 10px; + } + } + + .contact-search { + margin: 0 20px 10px 20px; + position: relative; + + .search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + } + + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 6px 10px 6px 28px; + font-size: 12px; + color: var(--text-primary); + outline: none; + + &:focus { + border-color: var(--accent-color); + } + } + } + + .contact-list { + flex: 1; + overflow-y: auto; + padding: 0 10px; + + .contact-item { + display: flex; + align-items: center; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + gap: 10px; + margin-bottom: 2px; + position: relative; + + &:hover { + background: var(--hover-bg); + } + + &.active { + background: rgba(var(--accent-color-rgb), 0.1); + + .contact-name { + color: var(--accent-color); + font-weight: 600; + } + } + + .contact-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-secondary); + } + + .check-mark { + color: var(--accent-color); + font-size: 12px; + font-weight: bold; + } + } + + .empty-contacts { + text-align: center; + padding: 20px; + font-size: 12px; + color: var(--text-tertiary); + } + } + } + + .sidebar-footer { + padding: 16px 20px; + border-top: 1px solid var(--border-color); + + .clear-btn { + width: 100%; + padding: 8px; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--text-primary); + } + } + } + } + + .sns-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + + .sns-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + height: 60px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + + .header-left { + display: flex; + align-items: center; + gap: 12px; + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + } + + .icon-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--hover-bg); + color: var(--text-primary); + } + } + + .spinning { + animation: spin 1s linear infinite; + } + } + + .sns-content { + flex: 1; + overflow-y: auto; + padding: 24px; + scroll-behavior: smooth; + + .active-filters { + max-width: 680px; + margin: 0 auto 16px auto; + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(var(--accent-color-rgb), 0.05); + border: 1px solid rgba(var(--accent-color-rgb), 0.2); + padding: 8px 16px; + border-radius: 8px; + font-size: 13px; + color: var(--accent-color); + + .clear-chip-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + font-size: 12px; + text-decoration: underline; + + &:hover { + color: var(--text-secondary); + } + } + } + + .sns-post { + background: var(--bg-secondary); + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; + max-width: 680px; + margin-left: auto; + margin-right: auto; + border: 1px solid var(--border-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + + .post-header { + display: flex; + align-items: center; + margin-bottom: 14px; + + .post-info { + margin-left: 12px; + + .nickname { + font-weight: 600; + margin-bottom: 2px; + color: var(--accent-color); + } + + .time { + font-size: 12px; + color: var(--text-tertiary); + } + } + } + + .post-body { + margin-bottom: 16px; + + .post-text { + margin-bottom: 12px; + white-space: pre-wrap; + line-height: 1.6; + font-size: 15px; + word-break: break-word; + } + + .post-media-grid { + display: grid; + gap: 4px; + width: fit-content; + max-width: 100%; + + &.media-count-1 { + grid-template-columns: 1fr; + + .media-item { + max-width: 400px; + aspect-ratio: unset; + } + + img { + height: auto; + max-height: 500px; + } + } + + &.media-count-2, + &.media-count-4 { + grid-template-columns: repeat(2, 1fr); + } + + &.media-count-3, + &.media-count-5, + &.media-count-6, + &.media-count-7, + &.media-count-8, + &.media-count-9 { + grid-template-columns: repeat(3, 1fr); + } + + .media-item { + aspect-ratio: 1; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + &.error { + background: transparent; + border: 1px dashed var(--border-color); + color: var(--text-tertiary); + font-size: 12px; + min-width: 100px; + min-height: 100px; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.85; + } + } + } + } + + .post-video-placeholder { + display: inline-flex; + align-items: center; + background: rgba(var(--accent-color-rgb), 0.1); + color: var(--accent-color); + padding: 6px 12px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + } + } + + .post-footer { + background: var(--bg-tertiary); + border-radius: 6px; + padding: 10px 12px; + font-size: 13.5px; + + .likes-section { + display: flex; + align-items: flex-start; + color: var(--accent-color); + padding-bottom: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 8px; + + &:last-child { + padding-bottom: 0; + border-bottom: none; + margin-bottom: 0; + } + + .icon { + margin-top: 3.5px; + margin-right: 8px; + flex-shrink: 0; + } + + .likes-list { + line-height: 1.5; + } + } + + .comments-section { + .comment-item { + margin-bottom: 6px; + line-height: 1.5; + + &:last-child { + margin-bottom: 0; + } + + .comment-user { + color: var(--accent-color); + font-weight: 600; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .reply-text { + color: var(--text-tertiary); + margin: 0 4px; + font-size: 13px; + } + + .comment-separator { + color: var(--text-secondary); + margin-left: -2px; + margin-right: 4px; + } + + .comment-content { + color: var(--text-secondary); + } + } + } + } + } + + .loading-more, + .no-more, + .no-results { + text-align: center; + padding: 40px 20px; + color: var(--text-tertiary); + font-size: 14px; + + .reset-inline { + margin-top: 12px; + background: var(--accent-color); + color: white; + border: none; + padding: 8px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + box-shadow: 0 2px 6px rgba(var(--accent-color-rgb), 0.3); + } + } + } + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx new file mode 100644 index 0000000..04bf2d1 --- /dev/null +++ b/src/pages/SnsPage.tsx @@ -0,0 +1,421 @@ +import { useEffect, useState, useRef, useCallback } from 'react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react' +import { Avatar } from '../components/Avatar' +import { ImagePreview } from '../components/ImagePreview' +import './SnsPage.scss' + +interface SnsPost { + id: string + username: string + nickname: string + avatarUrl?: string + createTime: number + contentDesc: string + type?: number + media: { url: string; thumb: string }[] + likes: string[] + comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] +} + +const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { + const [error, setError] = useState(false); + + if (error) { + return ( +
+ 无法加载 +
+ ); + } + + return ( +
+ setError(true)} + /> +
+ ); +}; + +interface Contact { + username: string + displayName: string + avatarUrl?: string +} + +export default function SnsPage() { + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(false) + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(true) + const loadingRef = useRef(false) + + // 筛选与搜索状态 + const [searchKeyword, setSearchKeyword] = useState('') + const [selectedUsernames, setSelectedUsernames] = useState([]) + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [isSidebarOpen, setIsSidebarOpen] = useState(true) + + // 联系人列表状态 + const [contacts, setContacts] = useState([]) + const [contactSearch, setContactSearch] = useState('') + const [contactsLoading, setContactsLoading] = useState(false) + const [previewImage, setPreviewImage] = useState(null) + + const loadPosts = useCallback(async (reset = false) => { + if (loadingRef.current) return + loadingRef.current = true + setLoading(true) + + try { + const currentOffset = reset ? 0 : offset + const limit = 20 + + // 转换日期为秒级时间戳 + const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined + const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天 + + const result = await window.electronAPI.sns.getTimeline( + limit, + currentOffset, + selectedUsernames, + searchKeyword, + startTs, + endTs + ) + + if (result.success && result.timeline) { + if (reset) { + setPosts(result.timeline) + setOffset(limit) + setHasMore(result.timeline.length >= limit) + } else { + setPosts(prev => [...prev, ...result.timeline!]) + setOffset(prev => prev + limit) + if (result.timeline.length < limit) { + setHasMore(false) + } + } + } + } catch (error) { + console.error('Failed to load SNS timeline:', error) + } finally { + setLoading(false) + loadingRef.current = false + } + }, [offset, selectedUsernames, searchKeyword, startDate, endDate]) + + // 获取联系人列表 + const loadContacts = async () => { + setContactsLoading(true) + try { + const result = await window.electronAPI.chat.getSessions() + if (result.success && result.sessions) { + // 系统账号和特殊前缀 + const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder']; + + // 初步提取并过滤联系人 + const initialContacts = result.sessions + .filter((s: any) => { + if (!s.username) return false; + const u = s.username.toLowerCase(); + + // 1. 排除群聊 (WeChat 群组以 @chatroom 结尾) + if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) { + return false; + } + + // 2. 排除公众号 (通常以 gh_ 开头) + if (u.startsWith('gh_')) { + return false; + } + + // 3. 排除系统账号 + if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) { + return false; + } + + return true; + }) + .map((s: any) => ({ + username: s.username, + displayName: s.displayName || s.username, + avatarUrl: s.avatarUrl + })) + setContacts(initialContacts) + + // 异步进一步富化(获取更多准确的昵称和头像) + const usernames = initialContacts.map(c => c.username) + const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) + if (enriched.success && enriched.contacts) { + setContacts(prev => prev.map(c => { + const extra = enriched.contacts![c.username] + if (extra) { + return { + ...c, + displayName: extra.displayName || c.displayName, + avatarUrl: extra.avatarUrl || c.avatarUrl + } + } + return c + })) + } + } + } catch (error) { + console.error('Failed to load contacts:', error) + } finally { + setContactsLoading(false) + } + } + + useEffect(() => { + loadContacts() + }, []) + + useEffect(() => { + loadPosts(true) + }, [selectedUsernames, searchKeyword, startDate, endDate]) + + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, clientHeight, scrollHeight } = e.currentTarget + if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) { + loadPosts() + } + } + + const formatTime = (ts: number) => { + const date = new Date(ts * 1000) + const isCurrentYear = date.getFullYear() === new Date().getFullYear() + + return date.toLocaleString('zh-CN', { + year: isCurrentYear ? undefined : 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + const toggleUserSelection = (username: string) => { + setSelectedUsernames(prev => { + if (prev.includes(username)) { + return prev.filter(u => u !== username) + } else { + return [...prev, username] + } + }) + } + + const clearFilters = () => { + setSearchKeyword('') + setSelectedUsernames([]) + setStartDate('') + setEndDate('') + } + + const filteredContacts = contacts.filter(c => + c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || + c.username.toLowerCase().includes(contactSearch.toLowerCase()) + ) + + return ( +
+
+ {/* 侧边栏:过滤与搜索 */} + + +
+
+
+ {!isSidebarOpen && ( + + )} +

朋友圈

+
+
+ +
+
+ +
+ {selectedUsernames.length > 0 && ( +
+ 筛选中: {selectedUsernames.length} 位好友 + +
+ )} + + {posts.map(post => ( +
+
+ +
+
{post.nickname}
+
{formatTime(post.createTime)}
+
+
+ +
+ {post.contentDesc &&
{post.contentDesc}
} + + {post.type === 15 ? ( +
+ [视频] +
+ ) : post.media.length > 0 && ( +
+ {post.media.map((m, idx) => ( + setPreviewImage(m.url)} /> + ))} +
+ )} +
+ + {(post.likes.length > 0 || post.comments.length > 0) && ( +
+ {post.likes.length > 0 && ( +
+ + + {post.likes.join('、')} + +
+ )} + + {post.comments.length > 0 && ( +
+ {post.comments.map((c, idx) => ( +
+ {c.nickname} + {c.refNickname && ( + <> + 回复 + {c.refNickname} + + )} + : + {c.content} +
+ ))} +
+ )} +
+ )} +
+ ))} + + {loading &&
加载中...
} + {!hasMore && posts.length > 0 &&
没有更多了
} + {!loading && posts.length === 0 && ( +
+

没有找到符合条件的朋友圈

+ {selectedUsernames.length > 0 && ( + + )} +
+ )} +
+
+
+ {previewImage && ( + setPreviewImage(null)} /> + )} +
+ ) +} diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index c296059..561f568 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -6,25 +6,26 @@ export interface ChatState { isConnected: boolean isConnecting: boolean connectionError: string | null - + // 会话列表 sessions: ChatSession[] filteredSessions: ChatSession[] currentSessionId: string | null isLoadingSessions: boolean - + // 消息 messages: Message[] isLoadingMessages: boolean isLoadingMore: boolean hasMoreMessages: boolean - + hasMoreLater: boolean + // 联系人缓存 contacts: Map - + // 搜索 searchKeyword: string - + // 操作 setConnected: (connected: boolean) => void setConnecting: (connecting: boolean) => void @@ -38,6 +39,7 @@ export interface ChatState { setLoadingMessages: (loading: boolean) => void setLoadingMore: (loading: boolean) => void setHasMoreMessages: (hasMore: boolean) => void + setHasMoreLater: (hasMore: boolean) => void setContacts: (contacts: Contact[]) => void addContact: (contact: Contact) => void setSearchKeyword: (keyword: string) => void @@ -56,48 +58,51 @@ export const useChatStore = create((set, get) => ({ isLoadingMessages: false, isLoadingMore: false, hasMoreMessages: true, + hasMoreLater: false, contacts: new Map(), searchKeyword: '', setConnected: (connected) => set({ isConnected: connected }), setConnecting: (connecting) => set({ isConnecting: connecting }), setConnectionError: (error) => set({ connectionError: error }), - + setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), - - setCurrentSession: (sessionId) => set({ + + setCurrentSession: (sessionId) => set({ currentSessionId: sessionId, messages: [], - hasMoreMessages: true + hasMoreMessages: true, + hasMoreLater: false }), - + setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), - + setMessages: (messages) => set({ messages }), - + appendMessages: (newMessages, prepend = false) => set((state) => ({ - messages: prepend + messages: prepend ? [...newMessages, ...state.messages] : [...state.messages, ...newMessages] })), - + setLoadingMessages: (loading) => set({ isLoadingMessages: loading }), setLoadingMore: (loading) => set({ isLoadingMore: loading }), setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }), - - setContacts: (contacts) => set({ - contacts: new Map(contacts.map(c => [c.username, c])) + setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }), + + setContacts: (contacts) => set({ + contacts: new Map(contacts.map(c => [c.username, c])) }), - + addContact: (contact) => set((state) => { const newContacts = new Map(state.contacts) newContacts.set(contact.username, contact) return { contacts: newContacts } }), - + setSearchKeyword: (keyword) => set({ searchKeyword: keyword }), - + reset: () => set({ isConnected: false, isConnecting: false, @@ -110,6 +115,7 @@ export const useChatStore = create((set, get) => ({ isLoadingMessages: false, isLoadingMore: false, hasMoreMessages: true, + hasMoreLater: false, contacts: new Map(), searchKeyword: '' }) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 2298766..7893cf7 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -63,7 +63,7 @@ export interface ElectronAPI { contacts?: Record error?: string }> - getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ + getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; @@ -321,6 +321,24 @@ export interface ElectronAPI { getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }> onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void } + sns: { + getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{ + success: boolean + timeline?: Array<{ + id: string + username: string + nickname: string + avatarUrl?: string + createTime: number + contentDesc: string + type?: number + media: Array<{ url: string; thumb: string }> + likes: Array + comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }> + }> + error?: string + }> + } } export interface ExportOptions {