diff --git a/electron/main.ts b/electron/main.ts index 076c16d..6ba867a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -97,6 +97,9 @@ let mainWindowReady = false let shouldShowMain = true let isAppQuitting = false let tray: Tray | null = null +let isClosePromptVisible = false + +type WindowCloseBehavior = 'ask' | 'tray' | 'quit' // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -253,6 +256,19 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => { win.webContents.on('did-finish-load', emitMaximizeState) } +const getWindowCloseBehavior = (): WindowCloseBehavior => { + const behavior = configService?.get('windowCloseBehavior') + return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask' +} + +const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => { + if (isClosePromptVisible) return + isClosePromptVisible = true + win.webContents.send('window:confirmCloseRequested', { + canMinimizeToTray: Boolean(tray) + }) +} + function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options @@ -354,10 +370,22 @@ function createWindow(options: { autoShow?: boolean } = {}) { }) win.on('close', (e) => { - if (isAppQuitting) return - // 关闭主窗口时隐藏到状态栏而不是退出 + if (isAppQuitting || win !== mainWindow) return e.preventDefault() - win.hide() + const closeBehavior = getWindowCloseBehavior() + + if (closeBehavior === 'quit') { + isAppQuitting = true + app.quit() + return + } + + if (closeBehavior === 'tray' && tray) { + win.hide() + return + } + + requestMainWindowCloseConfirmation(win) }) win.on('closed', () => { @@ -365,6 +393,7 @@ function createWindow(options: { autoShow?: boolean } = {}) { mainWindow = null mainWindowReady = false + isClosePromptVisible = false if (process.platform !== 'darwin' && !isAppQuitting) { destroyNotificationWindow() @@ -1154,6 +1183,33 @@ function registerIpcHandlers() { BrowserWindow.fromWebContents(event.sender)?.close() }) + ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => { + if (!mainWindow || mainWindow.isDestroyed()) { + isClosePromptVisible = false + return false + } + + try { + if (action === 'tray') { + if (tray) { + mainWindow.hide() + return true + } + return false + } + + if (action === 'quit') { + isAppQuitting = true + app.quit() + return true + } + + return true + } finally { + isClosePromptVisible = false + } + }) + // 更新窗口控件主题色 ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => { const win = BrowserWindow.fromWebContents(event.sender) @@ -1893,6 +1949,18 @@ function registerIpcHandlers() { return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) }) + ipcMain.handle( + 'groupAnalytics:getGroupMemberMessages', + async ( + _, + chatroomId: string, + memberUsername: string, + options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } + ) => { + return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options) + } + ) + ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => { return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath) }) diff --git a/electron/preload.ts b/electron/preload.ts index 8a0f823..4cce51c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -94,6 +94,13 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener) }, close: () => ipcRenderer.send('window:close'), + onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => { + const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload) + ipcRenderer.on('window:confirmCloseRequested', listener) + return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener) + }, + respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => + ipcRenderer.invoke('window:respondCloseConfirm', action), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), @@ -285,6 +292,11 @@ contextBridge.exposeInMainWorld('electronAPI', { getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime), + getGroupMemberMessages: ( + chatroomId: string, + memberUsername: string, + options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } + ) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options), exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath), exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime) diff --git a/electron/services/config.ts b/electron/services/config.ts index 689521b..d783c49 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -50,6 +50,7 @@ interface ConfigSchema { notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] + windowCloseBehavior: 'ask' | 'tray' | 'quit' wordCloudExcludeWords: string[] } @@ -116,6 +117,7 @@ export class ConfigService { notificationPosition: 'top-right', notificationFilterMode: 'all', notificationFilterList: [], + windowCloseBehavior: 'ask', wordCloudExcludeWords: [] } }) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index dcf3956..cb62352 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -4453,6 +4453,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -5650,6 +5651,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) const contactCache = new Map() diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index fbdb32e..01c012d 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -49,6 +49,12 @@ export interface GroupMediaStats { total: number } +export interface GroupMemberMessagesPage { + messages: Message[] + hasMore: boolean + nextCursor: number +} + interface GroupMemberContactInfo { remark: string nickName: string @@ -771,6 +777,100 @@ class GroupAnalyticsService { return { success: true, data: matchedMessages } } + async getGroupMemberMessages( + chatroomId: string, + memberUsername: string, + options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } + ): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const normalizedChatroomId = String(chatroomId || '').trim() + const normalizedMemberUsername = String(memberUsername || '').trim() + if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' } + if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' } + + const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number' + ? Math.max(0, Math.floor(options.startTime)) + : 0 + const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number' + ? Math.max(0, Math.floor(options.endTime)) + : 0 + const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number' + ? Math.max(1, Math.min(100, Math.floor(options.limit))) + : 50 + let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number' + ? Math.max(0, Math.floor(options.cursor)) + : 0 + + const matchedMessages: Message[] = [] + const batchSize = Math.max(limit * 2, 100) + let hasMore = false + + while (matchedMessages.length < limit) { + const batch = await chatService.getMessages( + normalizedChatroomId, + cursor, + batchSize, + startTimeValue, + endTimeValue, + false + ) + if (!batch.success || !batch.messages) { + return { success: false, error: batch.error || '获取群成员消息失败' } + } + + const currentMessages = batch.messages + const nextCursor = typeof batch.nextOffset === 'number' + ? Math.max(cursor, Math.floor(batch.nextOffset)) + : cursor + currentMessages.length + + let overflowMatchFound = false + for (const message of currentMessages) { + if (!this.isSameAccountIdentity(normalizedMemberUsername, message.senderUsername)) { + continue + } + + if (matchedMessages.length < limit) { + matchedMessages.push(message) + } else { + overflowMatchFound = true + break + } + } + + cursor = nextCursor + + if (overflowMatchFound) { + hasMore = true + break + } + + if (currentMessages.length === 0 || !batch.hasMore) { + hasMore = false + break + } + + if (matchedMessages.length >= limit) { + hasMore = true + break + } + } + + return { + success: true, + data: { + messages: matchedMessages, + hasMore, + nextCursor: cursor + } + } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { try { const conn = await this.ensureConnected() diff --git a/src/App.tsx b/src/App.tsx index e287a68..6f41759 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import LockScreen from './components/LockScreen' import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal' +import WindowCloseDialog from './components/WindowCloseDialog' function RouteStateRedirect({ to }: { to: string }) { const location = useLocation() @@ -85,6 +86,8 @@ function App() { const isExportRoute = routeLocation.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [showCloseDialog, setShowCloseDialog] = useState(false) + const [canMinimizeToTray, setCanMinimizeToTray] = useState(false) // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -107,6 +110,15 @@ function App() { } }, [location]) + useEffect(() => { + const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => { + setCanMinimizeToTray(Boolean(payload.canMinimizeToTray)) + setShowCloseDialog(true) + }) + + return () => removeCloseConfirmListener() + }, []) + useEffect(() => { const root = document.documentElement const body = document.body @@ -315,6 +327,26 @@ function App() { setUpdateInfo(null) } + const handleWindowCloseAction = async ( + action: 'tray' | 'quit' | 'cancel', + rememberChoice = false + ) => { + setShowCloseDialog(false) + if (rememberChoice && action !== 'cancel') { + try { + await configService.setWindowCloseBehavior(action) + } catch (error) { + console.error('保存关闭偏好失败:', error) + } + } + + try { + await window.electronAPI.window.respondCloseConfirm(action) + } catch (error) { + console.error('处理关闭确认失败:', error) + } + } + // 启动时自动检查配置并连接数据库 useEffect(() => { if (isAgreementWindow || isOnboardingWindow) return @@ -593,6 +625,13 @@ function App() { progress={downloadProgress} /> + handleWindowCloseAction(action, rememberChoice)} + onCancel={() => handleWindowCloseAction('cancel')} + /> +
diff --git a/src/components/WindowCloseDialog.scss b/src/components/WindowCloseDialog.scss new file mode 100644 index 0000000..ecc6907 --- /dev/null +++ b/src/components/WindowCloseDialog.scss @@ -0,0 +1,306 @@ +.window-close-dialog-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: + radial-gradient(circle at top, rgba(36, 42, 54, 0.18), transparent 48%), + rgba(7, 10, 18, 0.56); + backdrop-filter: blur(10px); + z-index: 3000; + animation: windowCloseDialogFadeIn 0.2s ease-out; +} + +.window-close-dialog { + width: min(560px, 100%); + border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent); + border-radius: 24px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32); + overflow: hidden; + position: relative; + animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1); +} + +.window-close-dialog-header { + padding: 28px 30px 18px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + + .window-close-dialog-kicker { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + h2 { + margin: 14px 0 8px; + font-size: 26px; + line-height: 1.1; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 14px; + line-height: 1.7; + color: var(--text-secondary); + } +} + +.window-close-dialog-body { + padding: 20px 24px 10px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.window-close-dialog-option { + width: 100%; + display: flex; + align-items: flex-start; + gap: 14px; + padding: 18px 18px 18px 16px; + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 18px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 86%, white 14%) 0%, var(--bg-secondary) 100%); + color: inherit; + cursor: pointer; + text-align: left; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease; + + &:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color)); + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.1); + } + + &:active { + transform: translateY(0); + } + + &.is-danger:hover { + border-color: rgba(205, 73, 73, 0.42); + } +} + +.window-close-dialog-option-icon { + width: 42px; + height: 42px; + flex: 0 0 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: color-mix(in srgb, var(--primary) 14%, transparent); + color: var(--primary); +} + +.window-close-dialog-option.is-danger .window-close-dialog-option-icon { + background: rgba(205, 73, 73, 0.12); + color: #cd4949; +} + +.window-close-dialog-option-text { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + + strong { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + span { + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + } +} + +.window-close-dialog-actions { + padding: 8px 24px 24px; + display: flex; + justify-content: flex-end; +} + +.window-close-dialog-remember { + display: flex; + align-items: center; + gap: 10px; + margin: 4px 24px 0; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--bg-secondary) 76%, transparent); + cursor: pointer; + user-select: none; + + input { + position: absolute; + opacity: 0; + pointer-events: none; + } +} + +.window-close-dialog-checkbox { + width: 18px; + height: 18px; + flex: 0 0 18px; + border: 1.5px solid color-mix(in srgb, var(--border-color) 88%, transparent); + border-radius: 6px; + background: var(--bg-primary); + position: relative; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease; + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 1px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0.7); + opacity: 0; + transition: + opacity 0.18s ease, + transform 0.18s ease; + } +} + +.window-close-dialog-remember input:checked + .window-close-dialog-checkbox { + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent); +} + +.window-close-dialog-remember input:checked + .window-close-dialog-checkbox::after { + opacity: 1; + transform: rotate(45deg) scale(1); +} + +.window-close-dialog-remember-text { + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.window-close-dialog-cancel { + min-width: 112px; + padding: 12px 18px; + border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent); + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + border-color 0.18s ease; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 24%, var(--border-color)); + } +} + +.window-close-dialog-close { + position: absolute; + top: 18px; + right: 18px; + width: 34px; + height: 34px; + border: none; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg-secondary) 84%, transparent); + color: var(--text-secondary); + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + transform 0.18s ease; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: rotate(90deg); + } +} + +@media (max-width: 640px) { + .window-close-dialog-overlay { + padding: 16px; + align-items: flex-end; + } + + .window-close-dialog { + border-radius: 24px 24px 18px 18px; + } + + .window-close-dialog-header { + padding: 24px 22px 16px; + + h2 { + font-size: 22px; + } + } + + .window-close-dialog-body { + padding: 18px 18px 10px; + } + + .window-close-dialog-actions { + padding: 8px 18px 18px; + } + + .window-close-dialog-cancel { + width: 100%; + } +} + +@keyframes windowCloseDialogFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes windowCloseDialogSlideUp { + from { + transform: translateY(24px) scale(0.98); + opacity: 0; + } + + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} diff --git a/src/components/WindowCloseDialog.tsx b/src/components/WindowCloseDialog.tsx new file mode 100644 index 0000000..ea838ea --- /dev/null +++ b/src/components/WindowCloseDialog.tsx @@ -0,0 +1,115 @@ +import { Minimize2, Power, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import './WindowCloseDialog.scss' + +interface WindowCloseDialogProps { + open: boolean + canMinimizeToTray: boolean + onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void + onCancel: () => void +} + +export default function WindowCloseDialog({ + open, + canMinimizeToTray, + onSelect, + onCancel +}: WindowCloseDialogProps) { + const [rememberChoice, setRememberChoice] = useState(false) + + useEffect(() => { + if (!open) return + setRememberChoice(false) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + onCancel() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, onCancel]) + + if (!open) return null + + return ( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="window-close-dialog-title" + > + + +
+ 退出行为 +

关闭 WeFlow

+

+ {canMinimizeToTray + ? '你可以保留后台进程与本地 API,或者直接完全退出应用。' + : '当前系统托盘不可用,本次只能完全退出应用。'} +

+
+ +
+ {canMinimizeToTray && ( + + )} + + +
+ + + +
+ +
+
+
+ ) +} diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index d7f5184..b1b0eab 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -2,7 +2,9 @@ display: flex; flex-direction: column; gap: 16px; - min-height: 100%; + height: 100%; + min-height: 0; + overflow: hidden; } .group-analytics-page { @@ -10,6 +12,7 @@ flex: 1; min-height: 0; gap: 16px; + overflow: hidden; &.standalone { height: 100vh; @@ -197,6 +200,7 @@ flex-direction: column; min-width: 250px; max-width: 450px; + min-height: 0; background: var(--bg-secondary); border-radius: 16px; overflow: hidden; @@ -207,6 +211,7 @@ display: flex; align-items: center; min-height: 56px; + flex-shrink: 0; .search-row { flex: 1; @@ -296,6 +301,7 @@ .group-list { flex: 1; + min-height: 0; overflow-y: auto; overflow-x: hidden; @@ -468,11 +474,18 @@ display: flex; flex-direction: column; min-width: 0; + min-height: 0; background: var(--bg-secondary); border-radius: 16px; overflow: hidden; } +.detail-drag-region { + height: 16px; + flex-shrink: 0; + -webkit-app-region: drag; +} + .resize-handle { width: 4px; cursor: col-resize; @@ -495,22 +508,30 @@ .function-menu { flex: 1; + min-height: 0; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - padding: 32px; + gap: 20px; + padding: 24px; + overflow-y: auto; .selected-group-info { - text-align: center; - margin-bottom: 40px; + display: flex; + align-items: center; + gap: 18px; + padding: 20px 24px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 20px; + box-shadow: var(--shadow-sm); .group-avatar.large { width: 80px; height: 80px; border-radius: 10px; overflow: hidden; - margin: 0 auto 16px; + margin: 0; + flex-shrink: 0; img { width: 100%; @@ -529,45 +550,64 @@ } } + .selected-group-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + + .group-summary-label { + font-size: 12px; + color: var(--text-tertiary); + letter-spacing: 0.04em; + } + h2 { - font-size: 20px; + font-size: 22px; font-weight: 600; color: var(--text-primary); - margin-bottom: 4px; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } p { color: var(--text-secondary); font-size: 14px; + margin: 0; } } .function-grid { - display: flex; - flex-wrap: wrap; - gap: 20px; - justify-content: center; + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; } .function-card { - width: 140px; - padding: 24px 16px; - background: rgba(255, 255, 255, 0.15); + min-height: 148px; + padding: 20px 18px; + background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary)); border-radius: 16px; display: flex; flex-direction: column; - align-items: center; - gap: 12px; + align-items: flex-start; + justify-content: flex-start; + gap: 10px; cursor: pointer; transition: all 0.2s; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + box-shadow: var(--shadow-sm); backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.15); + border: 1px solid var(--border-color); + text-align: left; &:hover { transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); - background: rgba(255, 255, 255, 0.25); + box-shadow: var(--shadow-md); + background: color-mix(in srgb, var(--card-bg) 100%, var(--bg-hover)); } svg { @@ -575,15 +615,22 @@ } span { - font-size: 13px; - font-weight: 500; + font-size: 15px; + font-weight: 600; color: var(--text-primary); } + + small { + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + } } } .function-content { flex: 1; + min-height: 0; display: flex; flex-direction: column; overflow: hidden; @@ -694,6 +741,7 @@ .content-body { flex: 1; + min-height: 0; overflow-y: auto; padding: 20px 24px; display: flex; @@ -785,7 +833,8 @@ } } -.member-export-panel { +.member-export-panel, +.member-messages-panel { display: flex; flex-direction: column; gap: 16px; @@ -1121,6 +1170,153 @@ cursor: not-allowed; } } + + .member-message-empty { + padding: 20px; + border-radius: 12px; + background: var(--bg-tertiary); + color: var(--text-secondary); + text-align: center; + font-size: 14px; + } + + .member-message-toolbar { + display: grid; + gap: 12px; + grid-template-columns: minmax(240px, 360px) minmax(160px, 1fr); + align-items: end; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } + } + + .member-message-toolbar-actions { + display: flex; + justify-content: flex-end; + align-items: center; + + @media (max-width: 900px) { + justify-content: flex-start; + } + } + + .member-message-select-trigger { + border-radius: 12px; + } + + .member-message-summary-text { + align-self: flex-start; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; + } + + .member-message-summary-card { + min-height: 48px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 12px 14px; + border-radius: 14px; + background: color-mix(in srgb, var(--card-bg) 88%, var(--bg-secondary)); + border: 1px solid var(--border-color); + } + + .summary-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .summary-desc { + font-size: 12px; + color: var(--text-secondary); + } + + .member-message-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .member-message-item { + padding: 14px 16px; + border-radius: 14px; + background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary)); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + } + + .member-message-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + } + + .member-message-time { + font-size: 12px; + color: var(--text-secondary); + } + + .member-message-type { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 9999px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + font-size: 11px; + font-weight: 600; + } + + .member-message-content { + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + } + + .member-message-actions { + display: flex; + justify-content: center; + padding-top: 4px; + } + + .member-message-load-more { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 132px; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + } + + .member-message-end { + font-size: 12px; + color: var(--text-tertiary); + } } .rankings-list { @@ -1405,6 +1601,16 @@ background: rgba(30, 30, 30, 0.95); border: 1px solid rgba(255, 255, 255, 0.1); } + + .member-export-modal { + background: rgba(30, 30, 30, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + .member-result-modal { + background: rgba(30, 30, 30, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + } } // 成员详情弹框 @@ -1496,6 +1702,34 @@ gap: 12px; } + .member-modal-actions { + width: 100%; + margin-top: 18px; + display: flex; + justify-content: center; + } + + .member-modal-primary-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + border: none; + border-radius: 12px; + background: var(--primary); + color: #fff; + padding: 12px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.92; + } + } + .detail-row { display: flex; align-items: center; @@ -1537,3 +1771,141 @@ } } } + +.member-export-modal { + background: rgba(255, 255, 255, 0.97); + border-radius: 20px; + padding: 28px; + width: min(720px, calc(100vw - 32px)); + max-height: min(760px, calc(100vh - 32px)); + overflow-y: auto; + position: relative; + backdrop-filter: blur(20px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + + .modal-close { + position: absolute; + top: 16px; + right: 16px; + background: var(--bg-tertiary); + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .member-export-modal-header { + margin-bottom: 18px; + padding-right: 40px; + + h3 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 6px 0 0; + font-size: 13px; + color: var(--text-secondary); + } + } + + .member-export-panel { + gap: 18px; + } +} + +.member-result-modal { + background: rgba(255, 255, 255, 0.97); + border-radius: 20px; + padding: 28px; + width: min(420px, calc(100vw - 32px)); + position: relative; + backdrop-filter: blur(20px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + + &.success { + border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border-color)); + } + + &.error { + border: 1px solid color-mix(in srgb, #ef4444 38%, var(--border-color)); + } + + .modal-close { + position: absolute; + top: 16px; + right: 16px; + background: var(--bg-tertiary); + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } +} + +.member-result-modal-body { + padding-right: 40px; + + h3 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 10px 0 0; + font-size: 14px; + line-height: 1.6; + color: var(--text-secondary); + word-break: break-word; + } +} + +.member-result-modal-actions { + margin-top: 24px; + display: flex; + justify-content: flex-end; +} + +.member-result-modal-btn { + min-width: 96px; + border: none; + border-radius: 12px; + background: var(--primary); + color: #fff; + padding: 10px 18px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.92; + } +} diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index ae372ad..db14c4d 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,11 +1,12 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } from 'lucide-react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import * as configService from '../services/config' +import type { Message } from '../types/models' import { finishBackgroundTask, isBackgroundTaskCancelRequested, @@ -36,7 +37,7 @@ interface GroupMessageRank { messageCount: number } -type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats' +type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats' type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' interface MemberMessageExportOptions { @@ -57,14 +58,105 @@ interface MemberExportFormatOption { desc: string } +interface GroupMemberMessagesPage { + messages: Message[] + hasMore: boolean + nextCursor: number +} + +const MEMBER_MESSAGE_PAGE_SIZE = 40 + +const filterMembersByKeyword = (members: GroupMember[], keyword: string) => { + const normalizedKeyword = keyword.trim().toLowerCase() + if (!normalizedKeyword) return members + return members.filter(member => { + const fields = [ + member.username, + member.displayName, + member.nickname, + member.remark, + member.alias, + member.groupNickname + ] + return fields.some(field => String(field || '').toLowerCase().includes(normalizedKeyword)) + }) +} + +const formatMemberMessageTime = (createTime: number) => { + if (!createTime) return '-' + return new Date(createTime * 1000).toLocaleString('zh-CN', { hour12: false }) +} + +const getMemberMessageTypeLabel = (message: Message) => { + switch (message.localType) { + case 1: + return '文本' + case 3: + return '图片' + case 34: + return '语音' + case 42: + return '名片' + case 43: + return '视频' + case 47: + return '表情' + case 48: + return '位置' + case 49: + return message.fileName ? '文件' : '链接' + case 50: + return '通话' + case 10000: + case 10002: + return '系统' + default: + return `类型 ${message.localType}` + } +} + +const getMemberMessagePreview = (message: Message) => { + const text = (message.parsedContent || message.content || message.rawContent || '').trim() + switch (message.localType) { + case 1: + case 10000: + case 10002: + return text || '[空文本]' + case 3: + return text || '[图片]' + case 34: + return message.voiceDurationSeconds ? `[语音] ${message.voiceDurationSeconds} 秒` : '[语音]' + case 42: + return `[名片] ${message.cardNickname || message.cardUsername || text || '联系人名片'}` + case 43: + return text || '[视频]' + case 47: + return text || '[表情]' + case 48: + return `[位置] ${message.locationPoiname || message.locationLabel || text || '位置消息'}` + case 49: + if (message.fileName) return `[文件] ${message.fileName}` + if (message.linkTitle) return `[链接] ${message.linkTitle}` + return text || '[链接/文件]' + case 50: + return text || '[通话]' + default: + return text || `[消息类型 ${message.localType}]` + } +} + function GroupAnalyticsPage() { const location = useLocation() const [groups, setGroups] = useState([]) const [filteredGroups, setFilteredGroups] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [selectedGroup, setSelectedGroup] = useState(null) + const [selectedGroupId, setSelectedGroupId] = useState(null) const [selectedFunction, setSelectedFunction] = useState(null) const [searchQuery, setSearchQuery] = useState('') + const selectedGroup = useMemo( + () => (selectedGroupId ? groups.find(group => group.username === selectedGroupId) || null : null), + [groups, selectedGroupId] + ) // 功能数据 const [members, setMembers] = useState([]) @@ -74,7 +166,11 @@ function GroupAnalyticsPage() { const [functionLoading, setFunctionLoading] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) - const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('') + const [memberMessages, setMemberMessages] = useState([]) + const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) + const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) + const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) + const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('') const [exportFolder, setExportFolder] = useState('') const [memberExportOptions, setMemberExportOptions] = useState({ format: 'excel', @@ -91,11 +187,17 @@ function GroupAnalyticsPage() { // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) - const [showMemberSelect, setShowMemberSelect] = useState(false) + const [showMemberExportModal, setShowMemberExportModal] = useState(false) + const [exportResultDialog, setExportResultDialog] = useState<{ + title: string + message: string + tone: 'success' | 'error' + } | null>(null) + const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) - const [memberSearchKeyword, setMemberSearchKeyword] = useState('') - const memberSelectDropdownRef = useRef(null) + const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('') + const messageMemberSelectDropdownRef = useRef(null) const formatDropdownRef = useRef(null) const displayNameDropdownRef = useRef(null) @@ -141,9 +243,9 @@ function GroupAnalyticsPage() { { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } ]), []) - const selectedExportMember = useMemo( - () => members.find(member => member.username === selectedExportMemberUsername) || null, - [members, selectedExportMemberUsername] + const selectedMessageMember = useMemo( + () => members.find(member => member.username === selectedMessageMemberUsername) || null, + [members, selectedMessageMemberUsername] ) const selectedFormatOption = useMemo( () => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0], @@ -153,20 +255,26 @@ function GroupAnalyticsPage() { () => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0], [displayNameOptions, memberExportOptions.displayNamePreference] ) - const filteredMemberOptions = useMemo(() => { - const keyword = memberSearchKeyword.trim().toLowerCase() - if (!keyword) return members - return members.filter(member => { - const fields = [ - member.username, - member.displayName, - member.nickname, - member.remark, - member.alias - ] - return fields.some(field => String(field || '').toLowerCase().includes(keyword)) - }) - }, [memberSearchKeyword, members]) + const filteredMessageMemberOptions = useMemo(() => { + return filterMembersByKeyword(members, messageMemberSearchKeyword) + }, [members, messageMemberSearchKeyword]) + + const resetMemberMessageState = useCallback((clearSelection = true) => { + setMemberMessages([]) + setMemberMessagesHasMore(false) + setMemberMessagesCursor(0) + setMemberMessagesLoadingMore(false) + setShowMessageMemberSelect(false) + if (clearSelection) { + setSelectedMessageMemberUsername('') + setMessageMemberSearchKeyword('') + } + }, []) + + const getSelectedTimeRange = () => ({ + startTime: startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined, + endTime: endDate ? Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000) : undefined + }) const loadExportPath = useCallback(async () => { try { @@ -240,20 +348,20 @@ function GroupAnalyticsPage() { useEffect(() => { if (members.length === 0) { - setSelectedExportMemberUsername('') + setSelectedMessageMemberUsername('') return } - const exists = members.some(member => member.username === selectedExportMemberUsername) - if (!exists) { - setSelectedExportMemberUsername(members[0].username) + const messageExists = members.some(member => member.username === selectedMessageMemberUsername) + if (!messageExists) { + setSelectedMessageMemberUsername(members[0].username) } - }, [members, selectedExportMemberUsername]) + }, [members, selectedMessageMemberUsername]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node - if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { - setShowMemberSelect(false) + if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) { + setShowMessageMemberSelect(false) } if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) { setShowFormatSelect(false) @@ -264,7 +372,7 @@ function GroupAnalyticsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect, showFormatSelect, showMemberSelect]) + }, [showDisplayNameSelect, showFormatSelect, showMessageMemberSelect]) useEffect(() => { if (preselectAppliedRef.current) return @@ -274,7 +382,7 @@ function GroupAnalyticsPage() { preselectAppliedRef.current = true if (matchedGroup) { - setSelectedGroup(matchedGroup) + setSelectedGroupId(matchedGroup.username) setSelectedFunction(null) setSearchQuery('') } @@ -301,7 +409,7 @@ function GroupAnalyticsPage() { // 日期范围变化时自动刷新 useEffect(() => { - if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') { + if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { setDateRangeReady(false) loadFunctionData(selectedFunction) } @@ -311,9 +419,11 @@ function GroupAnalyticsPage() { const handleChange = () => { setGroups([]) setFilteredGroups([]) - setSelectedGroup(null) + setSelectedGroupId(null) setSelectedFunction(null) setMembers([]) + resetMemberMessageState() + setShowMemberExportModal(false) setRankings([]) setActiveHours({}) setMediaStats(null) @@ -322,41 +432,77 @@ function GroupAnalyticsPage() { } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadExportPath, loadGroups]) + }, [loadExportPath, loadGroups, resetMemberMessageState]) const handleGroupSelect = (group: GroupChatInfo) => { - if (selectedGroup?.username !== group.username) { - setSelectedGroup(group) - setSelectedFunction(null) - setSelectedExportMemberUsername('') - setMemberSearchKeyword('') - setShowMemberSelect(false) - setShowFormatSelect(false) - setShowDisplayNameSelect(false) - } + setSelectedGroupId(group.username) + setSelectedFunction(null) + setSelectedMember(null) + setShowMemberExportModal(false) + resetMemberMessageState() + setShowFormatSelect(false) + setShowDisplayNameSelect(false) } + const loadMemberMessagesPage = async ( + targetGroup: GroupChatInfo, + memberUsername: string, + options?: { + cursor?: number + append?: boolean + startTime?: number + endTime?: number + } + ): Promise => { + const result = await window.electronAPI.groupAnalytics.getGroupMemberMessages(targetGroup.username, memberUsername, { + startTime: options?.startTime, + endTime: options?.endTime, + limit: MEMBER_MESSAGE_PAGE_SIZE, + cursor: options?.cursor && options.cursor > 0 ? options.cursor : undefined + }) + if (!result.success || !result.data) { + throw new Error(result.error || '读取成员消息失败') + } + + setMemberMessages(prev => { + if (!options?.append) return result.data!.messages + const next = [...prev] + const seen = new Set(prev.map(message => message.messageKey)) + for (const message of result.data!.messages) { + if (seen.has(message.messageKey)) continue + seen.add(message.messageKey) + next.push(message) + } + return next + }) + setMemberMessagesHasMore(result.data.hasMore) + setMemberMessagesCursor(result.data.nextCursor || 0) + return result.data + } + const handleFunctionSelect = async (func: AnalysisFunction) => { if (!selectedGroup) return setSelectedFunction(func) await loadFunctionData(func) } - const loadFunctionData = async (func: AnalysisFunction) => { - if (!selectedGroup) return + const loadFunctionData = async ( + func: AnalysisFunction, + targetGroup: GroupChatInfo | null = selectedGroup, + preferredMemberUsername?: string + ) => { + if (!targetGroup) return const taskId = registerBackgroundTask({ sourcePage: 'groupAnalytics', title: `群分析:${func}`, - detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`, + detail: `正在读取 ${targetGroup.displayName || targetGroup.username} 的分析数据`, progressText: func, cancelable: true }) setFunctionLoading(true) - // 计算时间戳 - const startTime = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined - const endTime = endDate ? Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) : undefined + const { startTime, endTime } = getSelectedTimeRange() try { switch (func) { @@ -365,7 +511,7 @@ function GroupAnalyticsPage() { detail: '正在读取群成员列表', progressText: '成员列表' }) - const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' }) return @@ -377,20 +523,46 @@ function GroupAnalyticsPage() { }) break } - case 'memberExport': { + case 'memberMessages': { updateBackgroundTask(taskId, { - detail: '正在读取导出成员列表', - progressText: '成员导出' + detail: '正在读取成员列表与消息', + progressText: '成员消息' }) - const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username) if (isBackgroundTaskCancelRequested(taskId)) { - finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' }) + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员消息未继续写入' }) return } - if (result.success && result.data) setMembers(result.data) - finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { - detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取成员导出列表失败'), - progressText: result.success ? `${result.data?.length || 0} 人` : '失败' + if (!result.success || !result.data) { + resetMemberMessageState() + finishBackgroundTask(taskId, 'failed', { + detail: result.error || '读取群成员失败', + progressText: '失败' + }) + break + } + + setMembers(result.data) + const targetMember = result.data.find(member => member.username === (preferredMemberUsername || selectedMessageMemberUsername)) || result.data[0] + + if (!targetMember) { + resetMemberMessageState() + finishBackgroundTask(taskId, 'completed', { + detail: '当前群暂无可用成员数据', + progressText: '0 条' + }) + break + } + + setSelectedMessageMemberUsername(targetMember.username) + updateBackgroundTask(taskId, { + detail: `正在读取 ${targetMember.displayName || targetMember.username} 的发言记录`, + progressText: '消息分页' + }) + const page = await loadMemberMessagesPage(targetGroup, targetMember.username, { startTime, endTime }) + finishBackgroundTask(taskId, 'completed', { + detail: `成员消息加载完成,已读取 ${page.messages.length} 条`, + progressText: `${page.messages.length} 条` }) break } @@ -399,7 +571,7 @@ function GroupAnalyticsPage() { detail: '正在计算群消息排行', progressText: '消息排行' }) - const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) + const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(targetGroup.username, 20, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' }) return @@ -416,7 +588,7 @@ function GroupAnalyticsPage() { detail: '正在计算群活跃时段', progressText: '活跃时段' }) - const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime) + const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(targetGroup.username, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' }) return @@ -433,7 +605,7 @@ function GroupAnalyticsPage() { detail: '正在统计群消息类型', progressText: '消息类型' }) - const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime) + const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(targetGroup.username, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' }) return @@ -523,12 +695,11 @@ function GroupAnalyticsPage() { const handleRefresh = () => { if (selectedFunction) { - loadFunctionData(selectedFunction) + void loadFunctionData(selectedFunction) } } const handleDateRangeComplete = () => { - if (selectedFunction === 'memberExport') return setDateRangeReady(true) } @@ -537,6 +708,69 @@ function GroupAnalyticsPage() { setCopiedField(null) } + const openSelectedGroupChat = () => { + if (!selectedGroup) return + void window.electronAPI.window.openSessionChatWindow(selectedGroup.username, { + source: 'chat', + initialDisplayName: selectedGroup.displayName || selectedGroup.username, + initialAvatarUrl: selectedGroup.avatarUrl, + initialContactType: 'group' + }) + } + + const handleMessageMemberSelect = async (memberUsername: string) => { + if (!selectedGroup) return + setSelectedMessageMemberUsername(memberUsername) + setMessageMemberSearchKeyword('') + setShowMessageMemberSelect(false) + setFunctionLoading(true) + try { + const { startTime, endTime } = getSelectedTimeRange() + await loadMemberMessagesPage(selectedGroup, memberUsername, { startTime, endTime }) + } catch (e) { + console.error('读取成员消息失败:', e) + alert(`读取成员消息失败:${String(e)}`) + } finally { + setFunctionLoading(false) + } + } + + const handleLoadMoreMemberMessages = async () => { + if (!selectedGroup || !selectedMessageMemberUsername || !memberMessagesHasMore || memberMessagesLoadingMore) return + setMemberMessagesLoadingMore(true) + try { + const { startTime, endTime } = getSelectedTimeRange() + await loadMemberMessagesPage(selectedGroup, selectedMessageMemberUsername, { + cursor: memberMessagesCursor, + append: true, + startTime, + endTime + }) + } catch (e) { + console.error('加载更多成员消息失败:', e) + alert(`加载更多成员消息失败:${String(e)}`) + } finally { + setMemberMessagesLoadingMore(false) + } + } + + const handleViewMemberMessagesFromModal = async (member: GroupMember) => { + if (!selectedGroup) return + setSelectedMember(null) + setSelectedFunction('memberMessages') + setSelectedMessageMemberUsername(member.username) + setMessageMemberSearchKeyword('') + setShowMessageMemberSelect(false) + await loadFunctionData('memberMessages', selectedGroup, member.username) + } + + const handleOpenMemberExportModal = () => { + setShowMessageMemberSelect(false) + setShowFormatSelect(false) + setShowDisplayNameSelect(false) + setShowMemberExportModal(true) + } + const handleExportMembers = async () => { if (!selectedGroup || isExportingMembers) return setIsExportingMembers(true) @@ -554,13 +788,25 @@ function GroupAnalyticsPage() { const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath) if (result.success) { - alert(`导出成功,共 ${result.count ?? members.length} 人`) + setExportResultDialog({ + title: '导出成功', + message: `共导出 ${result.count ?? members.length} 人`, + tone: 'success' + }) } else { - alert(`导出失败:${result.error || '未知错误'}`) + setExportResultDialog({ + title: '导出失败', + message: result.error || '未知错误', + tone: 'error' + }) } } catch (e) { console.error('导出群成员失败:', e) - alert(`导出失败:${String(e)}`) + setExportResultDialog({ + title: '导出失败', + message: String(e), + tone: 'error' + }) } finally { setIsExportingMembers(false) } @@ -599,8 +845,8 @@ function GroupAnalyticsPage() { } const handleExportMemberMessages = async () => { - if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return - const member = members.find(item => item.username === selectedExportMemberUsername) + if (!selectedGroup || !selectedMessageMemberUsername || !exportFolder || isExportingMemberMessages) return + const member = members.find(item => item.username === selectedMessageMemberUsername) if (!member) { alert('请先选择成员') return @@ -634,13 +880,26 @@ function GroupAnalyticsPage() { } ) if (result.success && (result.successCount ?? 0) > 0) { - alert(`导出成功:${member.displayName || member.username}`) + setShowMemberExportModal(false) + setExportResultDialog({ + title: '导出成功', + message: `已导出 ${member.displayName || member.username}`, + tone: 'success' + }) } else { - alert(`导出失败:${result.error || '未知错误'}`) + setExportResultDialog({ + title: '导出失败', + message: result.error || '未知错误', + tone: 'error' + }) } } catch (e) { console.error('导出成员消息失败:', e) - alert(`导出失败:${String(e)}`) + setExportResultDialog({ + title: '导出失败', + message: String(e), + tone: 'error' + }) } finally { setIsExportingMemberMessages(false) } @@ -719,6 +978,16 @@ function GroupAnalyticsPage() {
)} +
+ +
@@ -770,7 +1039,7 @@ function GroupAnalyticsPage() { filteredGroups.map(group => (
handleGroupSelect(group)} >
@@ -794,29 +1063,37 @@ function GroupAnalyticsPage() {
-

{selectedGroup?.displayName}

-

{selectedGroup?.memberCount} 位成员

+
+ 已选择群聊 +

{selectedGroup?.displayName}

+

{selectedGroup?.memberCount} 位成员

+
handleFunctionSelect('members')}> 群成员查看 + 查看群成员列表和基础资料
-
handleFunctionSelect('memberExport')}> - - 成员消息导出 +
handleFunctionSelect('memberMessages')}> + + 成员消息筛选与导出 + 按成员查看群聊消息,并支持导出当前成员记录
handleFunctionSelect('ranking')}> 群聊发言排行 + 统计成员发言数量排行
handleFunctionSelect('activeHours')}> 群聊活跃时段 + 查看全天活跃时间分布
handleFunctionSelect('mediaStats')}> 媒体内容统计 + 统计文本、图片、语音等类型
@@ -826,7 +1103,7 @@ function GroupAnalyticsPage() { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' - case 'memberExport': return '成员消息导出' + case 'memberMessages': return '成员消息筛选与导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' @@ -861,6 +1138,12 @@ function GroupAnalyticsPage() { 导出成员 )} + {selectedFunction === 'memberMessages' && ( + + )} @@ -882,58 +1165,57 @@ function GroupAnalyticsPage() { ))}
)} - {selectedFunction === 'memberExport' && ( -
+ {selectedFunction === 'memberMessages' && ( +
{members.length === 0 ? ( -
暂无群成员数据,请先刷新。
+
暂无群成员数据,请先刷新。
) : ( <> -
-
- 导出成员 +
已加载 {memberMessages.length} 条消息
+ +
+
+ 查看成员 - {showMemberSelect && ( + {showMessageMemberSelect && (
setMemberSearchKeyword(e.target.value)} + value={messageMemberSearchKeyword} + onChange={e => setMessageMemberSearchKeyword(e.target.value)} placeholder="搜索 wxid / 昵称 / 备注 / 微信号" />
- {filteredMemberOptions.length === 0 ? ( + {filteredMessageMemberOptions.length === 0 ? (
无匹配成员
) : ( - filteredMemberOptions.map(member => ( + filteredMessageMemberOptions.map(member => ( )) @@ -950,162 +1233,51 @@ function GroupAnalyticsPage() {
)}
-
- 导出格式 +
- {showFormatSelect && ( -
- {memberExportFormatOptions.map(option => ( - - ))} -
- )} -
-
- 导出目录 -
- - -
-
-
- 媒体导出 - -
-
- 媒体类型 -
- - - - -
-
-
- 附加选项 -
- - -
-
-
- 显示名称规则 - - {showDisplayNameSelect && ( -
- {displayNameOptions.map(option => ( - - ))} + {memberMessages.length === 0 ? ( +
当前时间范围内暂无该成员消息。
+ ) : ( +
+ {memberMessages.map(message => ( +
+
+ {formatMemberMessageTime(message.createTime)} + {getMemberMessageTypeLabel(message)} +
+
{getMemberMessagePreview(message)}
+ ))} +
+ )} + + {(memberMessagesHasMore || memberMessages.length > 0) && ( +
+ {memberMessagesHasMore ? ( + + ) : ( + 已显示当前可读取的全部消息 )}
-
- -
- -
+ )} )}
@@ -1171,18 +1343,226 @@ function GroupAnalyticsPage() { const renderDetailPanel = () => { + if (selectedFunction) { + return renderFunctionContent() + } + if (!selectedGroup) { return ( -
- + <> + + ) } - if (!selectedFunction) { - return renderFunctionMenu() - } - return renderFunctionContent() + return ( + <> +
{renderMemberModal()} + {renderMemberExportModal()} + {renderExportResultDialog()}
) } export default GroupAnalyticsPage + diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6aff733..e52e3cb 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -107,9 +107,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterList, setNotificationFilterList] = useState([]) + const [windowCloseBehavior, setWindowCloseBehavior] = useState('ask') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) + const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState([]) const [excludeWordsInput, setExcludeWordsInput] = useState('') @@ -253,15 +255,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { if (!target.closest('.custom-select')) { setFilterModeDropdownOpen(false) setPositionDropdownOpen(false) + setCloseBehaviorDropdownOpen(false) } } - if (filterModeDropdownOpen || positionDropdownOpen) { + if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) { document.addEventListener('click', handleClickOutside) } return () => { document.removeEventListener('click', handleClickOutside) } - }, [filterModeDropdownOpen, positionDropdownOpen]) + }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen]) const loadConfig = async () => { @@ -283,6 +286,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationPosition = await configService.getNotificationPosition() const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() + const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() @@ -318,6 +322,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationPosition(savedNotificationPosition) setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) + setWindowCloseBehavior(savedWindowCloseBehavior) const savedExcludeWords = await configService.getWordCloudExcludeWords() setWordCloudExcludeWords(savedExcludeWords) @@ -1024,6 +1029,61 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
))}
+ +
+ +
+ + 设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。 +
+
setCloseBehaviorDropdownOpen(!closeBehaviorDropdownOpen)} + > + + {windowCloseBehavior === 'tray' + ? '最小化到系统托盘' + : windowCloseBehavior === 'quit' + ? '完全关闭' + : '每次询问'} + + +
+
+ {[ + { + value: 'ask' as const, + label: '每次询问', + successMessage: '已恢复关闭确认弹窗' + }, + { + value: 'tray' as const, + label: '最小化到系统托盘', + successMessage: '关闭按钮已改为最小化到托盘' + }, + { + value: 'quit' as const, + label: '完全关闭', + successMessage: '关闭按钮已改为完全关闭' + } + ].map(option => ( +
{ + setWindowCloseBehavior(option.value) + setCloseBehaviorDropdownOpen(false) + await configService.setWindowCloseBehavior(option.value) + showMessage(option.successMessage, true) + }} + > + {option.label} + {windowCloseBehavior === option.value && } +
+ ))} +
+
+
) diff --git a/src/services/config.ts b/src/services/config.ts index 2cd8787..5fce0f1 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -62,6 +62,7 @@ export const CONFIG_KEYS = { NOTIFICATION_POSITION: 'notificationPosition', NOTIFICATION_FILTER_MODE: 'notificationFilterMode', NOTIFICATION_FILTER_LIST: 'notificationFilterList', + WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', // 词云 WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords', @@ -85,6 +86,8 @@ export interface ExportDefaultMediaConfig { emojis: boolean } +export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' + const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { images: true, videos: true, @@ -1188,6 +1191,16 @@ export async function setNotificationFilterList(list: string[]): Promise { await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list) } +export async function getWindowCloseBehavior(): Promise { + const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR) + if (value === 'tray' || value === 'quit') return value + return 'ask' +} + +export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Promise { + await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior) +} + // 获取词云排除词列表 export async function getWordCloudExcludeWords(): Promise { const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 7bfd316..a035f50 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -14,6 +14,8 @@ export interface ElectronAPI { isMaximized: () => Promise onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void close: () => void + onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void + respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise openAgreementWindow: () => Promise completeOnboarding: () => Promise openOnboardingWindow: () => Promise @@ -492,6 +494,19 @@ export interface ElectronAPI { } error?: string }> + getGroupMemberMessages: ( + chatroomId: string, + memberUsername: string, + options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } + ) => Promise<{ + success: boolean + data?: { + messages: Message[] + hasMore: boolean + nextCursor: number + } + error?: string + }> exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{ success: boolean count?: number