From bf48e865acc7152500d2137762ba9079d0357ff6 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 16 Mar 2026 16:48:01 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9C=80=E5=B0=8F?= =?UTF-8?q?=E5=8C=96=E7=AA=97=E5=8F=A3=20https://github.com/hicccc77/WeFlo?= =?UTF-8?q?w/issues/359?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 39 ++++- electron/preload.ts | 7 + src/App.tsx | 29 ++++ src/components/WindowCloseDialog.scss | 240 ++++++++++++++++++++++++++ src/components/WindowCloseDialog.tsx | 100 +++++++++++ src/types/electron.d.ts | 2 + 6 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 src/components/WindowCloseDialog.scss create mode 100644 src/components/WindowCloseDialog.tsx diff --git a/electron/main.ts b/electron/main.ts index 076c16d..0727f62 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -97,6 +97,7 @@ let mainWindowReady = false let shouldShowMain = true let isAppQuitting = false let tray: Tray | null = null +let isClosePromptVisible = false // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -354,10 +355,14 @@ function createWindow(options: { autoShow?: boolean } = {}) { }) win.on('close', (e) => { - if (isAppQuitting) return - // 关闭主窗口时隐藏到状态栏而不是退出 + if (isAppQuitting || win !== mainWindow) return e.preventDefault() - win.hide() + if (isClosePromptVisible) return + + isClosePromptVisible = true + win.webContents.send('window:confirmCloseRequested', { + canMinimizeToTray: Boolean(tray) + }) }) win.on('closed', () => { @@ -365,6 +370,7 @@ function createWindow(options: { autoShow?: boolean } = {}) { mainWindow = null mainWindowReady = false + isClosePromptVisible = false if (process.platform !== 'darwin' && !isAppQuitting) { destroyNotificationWindow() @@ -1154,6 +1160,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) diff --git a/electron/preload.ts b/electron/preload.ts index 8a0f823..7d56dba 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'), diff --git a/src/App.tsx b/src/App.tsx index e287a68..8b9a903 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,15 @@ function App() { setUpdateInfo(null) } + const handleWindowCloseAction = async (action: 'tray' | 'quit' | 'cancel') => { + setShowCloseDialog(false) + try { + await window.electronAPI.window.respondCloseConfirm(action) + } catch (error) { + console.error('处理关闭确认失败:', error) + } + } + // 启动时自动检查配置并连接数据库 useEffect(() => { if (isAgreementWindow || isOnboardingWindow) return @@ -593,6 +614,14 @@ function App() { progress={downloadProgress} /> + handleWindowCloseAction('tray')} + onQuit={() => handleWindowCloseAction('quit')} + onCancel={() => handleWindowCloseAction('cancel')} + /> +
diff --git a/src/components/WindowCloseDialog.scss b/src/components/WindowCloseDialog.scss new file mode 100644 index 0000000..282ac55 --- /dev/null +++ b/src/components/WindowCloseDialog.scss @@ -0,0 +1,240 @@ +.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-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..3c992c9 --- /dev/null +++ b/src/components/WindowCloseDialog.tsx @@ -0,0 +1,100 @@ +import { Minimize2, Power, X } from 'lucide-react' +import { useEffect } from 'react' +import './WindowCloseDialog.scss' + +interface WindowCloseDialogProps { + open: boolean + canMinimizeToTray: boolean + onTray: () => void + onQuit: () => void + onCancel: () => void +} + +export default function WindowCloseDialog({ + open, + canMinimizeToTray, + onTray, + onQuit, + onCancel +}: WindowCloseDialogProps) { + useEffect(() => { + if (!open) return + + 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/types/electron.d.ts b/src/types/electron.d.ts index 7bfd316..e9023da 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 From d730ae5bef0f63013758cf682ff333e38b3e9dd1 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 16 Mar 2026 17:12:12 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BE=A4=E8=81=8A?= =?UTF-8?q?=E5=88=86=E6=9E=90=E7=99=BD=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/GroupAnalyticsPage.scss | 78 ++++++++++++++++++++++--------- src/pages/GroupAnalyticsPage.tsx | 56 +++++++++++++--------- 2 files changed, 88 insertions(+), 46 deletions(-) diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index d7f5184..d6a2645 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -497,20 +497,27 @@ flex: 1; 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 +536,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,10 +601,16 @@ } 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); + } } } diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index ae372ad..9103f2f 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -62,9 +62,13 @@ function GroupAnalyticsPage() { 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([]) @@ -274,7 +278,7 @@ function GroupAnalyticsPage() { preselectAppliedRef.current = true if (matchedGroup) { - setSelectedGroup(matchedGroup) + setSelectedGroupId(matchedGroup.username) setSelectedFunction(null) setSearchQuery('') } @@ -311,7 +315,7 @@ function GroupAnalyticsPage() { const handleChange = () => { setGroups([]) setFilteredGroups([]) - setSelectedGroup(null) + setSelectedGroupId(null) setSelectedFunction(null) setMembers([]) setRankings([]) @@ -325,15 +329,13 @@ function GroupAnalyticsPage() { }, [loadExportPath, loadGroups]) 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) + setSelectedExportMemberUsername('') + setMemberSearchKeyword('') + setShowMemberSelect(false) + setShowFormatSelect(false) + setShowDisplayNameSelect(false) } @@ -343,12 +345,12 @@ function GroupAnalyticsPage() { await loadFunctionData(func) } - const loadFunctionData = async (func: AnalysisFunction) => { - if (!selectedGroup) return + const loadFunctionData = async (func: AnalysisFunction, targetGroup: GroupChatInfo | null = selectedGroup) => { + if (!targetGroup) return const taskId = registerBackgroundTask({ sourcePage: 'groupAnalytics', title: `群分析:${func}`, - detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`, + detail: `正在读取 ${targetGroup.displayName || targetGroup.username} 的分析数据`, progressText: func, cancelable: true }) @@ -365,7 +367,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 @@ -382,7 +384,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 @@ -399,7 +401,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 +418,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 +435,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 @@ -770,7 +772,7 @@ function GroupAnalyticsPage() { filteredGroups.map(group => (
handleGroupSelect(group)} >
@@ -794,29 +796,37 @@ function GroupAnalyticsPage() {
-

{selectedGroup?.displayName}

-

{selectedGroup?.memberCount} 位成员

+
+ 已选择群聊 +

{selectedGroup?.displayName}

+

{selectedGroup?.memberCount} 位成员

+
handleFunctionSelect('members')}> 群成员查看 + 查看群成员列表和基础资料
handleFunctionSelect('memberExport')}> 成员消息导出 + 按成员筛选并导出群聊记录
handleFunctionSelect('ranking')}> 群聊发言排行 + 统计成员发言数量排行
handleFunctionSelect('activeHours')}> 群聊活跃时段 + 查看全天活跃时间分布
handleFunctionSelect('mediaStats')}> 媒体内容统计 + 统计文本、图片、语音等类型
From 999ddaeb9acd4435c8404832255e5ec77681db14 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 16 Mar 2026 17:18:49 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=BB=91=E5=8A=A8=E6=9D=A1=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/GroupAnalyticsPage.scss | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index d6a2645..6066448 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,6 +474,7 @@ display: flex; flex-direction: column; min-width: 0; + min-height: 0; background: var(--bg-secondary); border-radius: 16px; overflow: hidden; @@ -495,6 +502,7 @@ .function-menu { flex: 1; + min-height: 0; display: flex; flex-direction: column; gap: 20px; @@ -616,6 +624,7 @@ .function-content { flex: 1; + min-height: 0; display: flex; flex-direction: column; overflow: hidden; @@ -726,6 +735,7 @@ .content-body { flex: 1; + min-height: 0; overflow-y: auto; padding: 20px 24px; display: flex; From f2b1b07f5803eb753f0a1358d0caf111e950918d Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 16 Mar 2026 17:21:59 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AF=A2=E9=97=AE?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 33 ++++++++++++-- electron/services/config.ts | 2 + src/App.tsx | 16 +++++-- src/components/WindowCloseDialog.scss | 66 +++++++++++++++++++++++++++ src/components/WindowCloseDialog.tsx | 29 +++++++++--- src/pages/SettingsPage.tsx | 64 +++++++++++++++++++++++++- src/services/config.ts | 13 ++++++ 7 files changed, 206 insertions(+), 17 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 0727f62..34084c9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -99,6 +99,8 @@ let isAppQuitting = false let tray: Tray | null = null let isClosePromptVisible = false +type WindowCloseBehavior = 'ask' | 'tray' | 'quit' + // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false let downloadProgressHandler: ((progress: any) => void) | null = null @@ -254,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 @@ -357,12 +372,20 @@ function createWindow(options: { autoShow?: boolean } = {}) { win.on('close', (e) => { if (isAppQuitting || win !== mainWindow) return e.preventDefault() - if (isClosePromptVisible) return + const closeBehavior = getWindowCloseBehavior() - isClosePromptVisible = true - win.webContents.send('window:confirmCloseRequested', { - canMinimizeToTray: Boolean(tray) - }) + if (closeBehavior === 'quit') { + isAppQuitting = true + app.quit() + return + } + + if (closeBehavior === 'tray' && tray) { + win.hide() + return + } + + requestMainWindowCloseConfirmation(win) }) win.on('closed', () => { 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/src/App.tsx b/src/App.tsx index 8b9a903..6f41759 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -327,8 +327,19 @@ function App() { setUpdateInfo(null) } - const handleWindowCloseAction = async (action: 'tray' | 'quit' | 'cancel') => { + 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) { @@ -617,8 +628,7 @@ function App() { handleWindowCloseAction('tray')} - onQuit={() => handleWindowCloseAction('quit')} + onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)} onCancel={() => handleWindowCloseAction('cancel')} /> diff --git a/src/components/WindowCloseDialog.scss b/src/components/WindowCloseDialog.scss index 282ac55..ecc6907 100644 --- a/src/components/WindowCloseDialog.scss +++ b/src/components/WindowCloseDialog.scss @@ -140,6 +140,72 @@ 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; diff --git a/src/components/WindowCloseDialog.tsx b/src/components/WindowCloseDialog.tsx index 3c992c9..ea838ea 100644 --- a/src/components/WindowCloseDialog.tsx +++ b/src/components/WindowCloseDialog.tsx @@ -1,24 +1,25 @@ import { Minimize2, Power, X } from 'lucide-react' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import './WindowCloseDialog.scss' interface WindowCloseDialogProps { open: boolean canMinimizeToTray: boolean - onTray: () => void - onQuit: () => void + onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void onCancel: () => void } export default function WindowCloseDialog({ open, canMinimizeToTray, - onTray, - onQuit, + onSelect, onCancel }: WindowCloseDialogProps) { + const [rememberChoice, setRememberChoice] = useState(false) + useEffect(() => { if (!open) return + setRememberChoice(false) const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -63,7 +64,11 @@ export default function WindowCloseDialog({
{canMinimizeToTray && ( -
+ +
))}
+ +
+ +
+ + 设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。 +
+
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) From 79e40f6a53377a2e2fa35a35358a3429572edb9f Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 16 Mar 2026 17:51:13 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E5=8D=95=E4=B8=AA=E7=BE=A4=E6=88=90=E5=91=98=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 12 + electron/preload.ts | 5 + electron/services/groupAnalyticsService.ts | 100 +++++ src/pages/GroupAnalyticsPage.scss | 156 +++++++- src/pages/GroupAnalyticsPage.tsx | 440 +++++++++++++++++++-- src/types/electron.d.ts | 13 + 6 files changed, 701 insertions(+), 25 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 34084c9..6ba867a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1949,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 7d56dba..4cce51c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -292,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/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/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 6066448..796a65c 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -827,7 +827,8 @@ } } -.member-export-panel { +.member-export-panel, +.member-messages-panel { display: flex; flex-direction: column; gap: 16px; @@ -1163,6 +1164,131 @@ 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(0, 1fr); + align-items: start; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } + } + + .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 { @@ -1538,6 +1664,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; diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 9103f2f..93891b8 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' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats' type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' interface MemberMessageExportOptions { @@ -57,6 +58,93 @@ 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([]) @@ -78,7 +166,12 @@ function GroupAnalyticsPage() { const [functionLoading, setFunctionLoading] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) + const [memberMessages, setMemberMessages] = useState([]) + const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) + const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) + const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('') + const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('') const [exportFolder, setExportFolder] = useState('') const [memberExportOptions, setMemberExportOptions] = useState({ format: 'excel', @@ -95,10 +188,13 @@ function GroupAnalyticsPage() { // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) + const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false) const [showMemberSelect, setShowMemberSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) + const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('') const [memberSearchKeyword, setMemberSearchKeyword] = useState('') + const messageMemberSelectDropdownRef = useRef(null) const memberSelectDropdownRef = useRef(null) const formatDropdownRef = useRef(null) const displayNameDropdownRef = useRef(null) @@ -149,6 +245,10 @@ function GroupAnalyticsPage() { () => 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], [memberExportFormatOptions, memberExportOptions.format] @@ -158,19 +258,28 @@ function GroupAnalyticsPage() { [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)) - }) + return filterMembersByKeyword(members, memberSearchKeyword) }, [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 { @@ -245,17 +354,25 @@ function GroupAnalyticsPage() { useEffect(() => { if (members.length === 0) { setSelectedExportMemberUsername('') + setSelectedMessageMemberUsername('') return } - const exists = members.some(member => member.username === selectedExportMemberUsername) - if (!exists) { + const exportExists = members.some(member => member.username === selectedExportMemberUsername) + if (!exportExists) { setSelectedExportMemberUsername(members[0].username) } - }, [members, selectedExportMemberUsername]) + const messageExists = members.some(member => member.username === selectedMessageMemberUsername) + if (!messageExists) { + setSelectedMessageMemberUsername(members[0].username) + } + }, [members, selectedExportMemberUsername, selectedMessageMemberUsername]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node + if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) { + setShowMessageMemberSelect(false) + } if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { setShowMemberSelect(false) } @@ -268,7 +385,7 @@ function GroupAnalyticsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect, showFormatSelect, showMemberSelect]) + }, [showDisplayNameSelect, showFormatSelect, showMemberSelect, showMessageMemberSelect]) useEffect(() => { if (preselectAppliedRef.current) return @@ -318,6 +435,7 @@ function GroupAnalyticsPage() { setSelectedGroupId(null) setSelectedFunction(null) setMembers([]) + resetMemberMessageState() setRankings([]) setActiveHours({}) setMediaStats(null) @@ -326,11 +444,13 @@ 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) => { setSelectedGroupId(group.username) setSelectedFunction(null) + setSelectedMember(null) + resetMemberMessageState() setSelectedExportMemberUsername('') setMemberSearchKeyword('') setShowMemberSelect(false) @@ -339,13 +459,53 @@ function GroupAnalyticsPage() { } + 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, targetGroup: GroupChatInfo | null = selectedGroup) => { + const loadFunctionData = async ( + func: AnalysisFunction, + targetGroup: GroupChatInfo | null = selectedGroup, + preferredMemberUsername?: string + ) => { if (!targetGroup) return const taskId = registerBackgroundTask({ sourcePage: 'groupAnalytics', @@ -356,9 +516,7 @@ function GroupAnalyticsPage() { }) 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) { @@ -379,6 +537,49 @@ function GroupAnalyticsPage() { }) break } + case 'memberMessages': { + updateBackgroundTask(taskId, { + detail: '正在读取成员列表与消息', + progressText: '成员消息' + }) + const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员消息未继续写入' }) + return + } + 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 + } case 'memberExport': { updateBackgroundTask(taskId, { detail: '正在读取导出成员列表', @@ -525,7 +726,7 @@ function GroupAnalyticsPage() { const handleRefresh = () => { if (selectedFunction) { - loadFunctionData(selectedFunction) + void loadFunctionData(selectedFunction) } } @@ -539,6 +740,62 @@ 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 handleExportMembers = async () => { if (!selectedGroup || isExportingMembers) return setIsExportingMembers(true) @@ -721,6 +978,16 @@ function GroupAnalyticsPage() { )} +
+ +
@@ -808,6 +1075,11 @@ function GroupAnalyticsPage() { 群成员查看 查看群成员列表和基础资料 +
handleFunctionSelect('memberMessages')}> + + 成员消息查看 + 按成员筛选并分页查看群聊消息 +
handleFunctionSelect('memberExport')}> 成员消息导出 @@ -836,6 +1108,7 @@ function GroupAnalyticsPage() { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' + case 'memberMessages': return '成员消息查看' case 'memberExport': return '成员消息导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' @@ -871,6 +1144,12 @@ function GroupAnalyticsPage() { 导出成员 )} + {selectedFunction === 'memberMessages' && ( + + )} @@ -892,6 +1171,118 @@ function GroupAnalyticsPage() { ))}
)} + {selectedFunction === 'memberMessages' && ( +
+ {members.length === 0 ? ( +
暂无群成员数据,请先刷新。
+ ) : ( + <> +
+
+ 查看成员 + + {showMessageMemberSelect && ( +
+
+ + setMessageMemberSearchKeyword(e.target.value)} + placeholder="搜索 wxid / 昵称 / 备注 / 微信号" + /> +
+
+ {filteredMessageMemberOptions.length === 0 ? ( +
无匹配成员
+ ) : ( + filteredMessageMemberOptions.map(member => ( + + )) + )} +
+
+ )} +
+
+ 已加载 {memberMessages.length} 条消息 + + 当前成员:{selectedMessageMember?.displayName || selectedMessageMember?.username || '未选择成员'} + +
+
+ + {memberMessages.length === 0 ? ( +
当前时间范围内暂无该成员消息。
+ ) : ( +
+ {memberMessages.map(message => ( +
+
+ {formatMemberMessageTime(message.createTime)} + {getMemberMessageTypeLabel(message)} +
+
{getMemberMessagePreview(message)}
+
+ ))} +
+ )} + + {(memberMessagesHasMore || memberMessages.length > 0) && ( +
+ {memberMessagesHasMore ? ( + + ) : ( + 已显示当前可读取的全部消息 + )} +
+ )} + + )} +
+ )} {selectedFunction === 'memberExport' && (
{members.length === 0 ? ( @@ -1211,3 +1602,4 @@ function GroupAnalyticsPage() { } export default GroupAnalyticsPage + diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e9023da..a035f50 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -494,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 From 7fad75fad07cc34b691249f439e952ece60ae64a Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 16 Mar 2026 18:19:49 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E7=BE=A4=E6=88=90=E5=91=98=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=AF=BC=E5=87=BA=E6=94=BE=E5=9C=A8=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E9=87=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 2 + src/pages/GroupAnalyticsPage.scss | 93 +++++- src/pages/GroupAnalyticsPage.tsx | 516 +++++++++++++---------------- 3 files changed, 315 insertions(+), 296 deletions(-) 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/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 796a65c..95c71e6 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -480,6 +480,12 @@ overflow: hidden; } +.detail-drag-region { + height: 16px; + flex-shrink: 0; + -webkit-app-region: drag; +} + .resize-handle { width: 4px; cursor: col-resize; @@ -1177,14 +1183,36 @@ .member-message-toolbar { display: grid; gap: 12px; - grid-template-columns: minmax(240px, 360px) minmax(0, 1fr); - align-items: start; + 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; @@ -1573,6 +1601,11 @@ 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); + } } // 成员详情弹框 @@ -1733,3 +1766,59 @@ } } } + +.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; + } +} diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 93891b8..84aaf89 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -37,7 +37,7 @@ interface GroupMessageRank { messageCount: number } -type AnalysisFunction = 'members' | 'memberMessages' | '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 { @@ -170,7 +170,6 @@ function GroupAnalyticsPage() { const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) - const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('') const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('') const [exportFolder, setExportFolder] = useState('') const [memberExportOptions, setMemberExportOptions] = useState({ @@ -188,14 +187,12 @@ function GroupAnalyticsPage() { // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) + const [showMemberExportModal, setShowMemberExportModal] = useState(false) const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false) - const [showMemberSelect, setShowMemberSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('') - const [memberSearchKeyword, setMemberSearchKeyword] = useState('') const messageMemberSelectDropdownRef = useRef(null) - const memberSelectDropdownRef = useRef(null) const formatDropdownRef = useRef(null) const displayNameDropdownRef = useRef(null) @@ -241,10 +238,6 @@ 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] @@ -257,9 +250,6 @@ function GroupAnalyticsPage() { () => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0], [displayNameOptions, memberExportOptions.displayNamePreference] ) - const filteredMemberOptions = useMemo(() => { - return filterMembersByKeyword(members, memberSearchKeyword) - }, [memberSearchKeyword, members]) const filteredMessageMemberOptions = useMemo(() => { return filterMembersByKeyword(members, messageMemberSearchKeyword) }, [members, messageMemberSearchKeyword]) @@ -353,19 +343,14 @@ function GroupAnalyticsPage() { useEffect(() => { if (members.length === 0) { - setSelectedExportMemberUsername('') setSelectedMessageMemberUsername('') return } - const exportExists = members.some(member => member.username === selectedExportMemberUsername) - if (!exportExists) { - setSelectedExportMemberUsername(members[0].username) - } const messageExists = members.some(member => member.username === selectedMessageMemberUsername) if (!messageExists) { setSelectedMessageMemberUsername(members[0].username) } - }, [members, selectedExportMemberUsername, selectedMessageMemberUsername]) + }, [members, selectedMessageMemberUsername]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -373,9 +358,6 @@ function GroupAnalyticsPage() { if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) { setShowMessageMemberSelect(false) } - if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { - setShowMemberSelect(false) - } if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) { setShowFormatSelect(false) } @@ -385,7 +367,7 @@ function GroupAnalyticsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect, showFormatSelect, showMemberSelect, showMessageMemberSelect]) + }, [showDisplayNameSelect, showFormatSelect, showMessageMemberSelect]) useEffect(() => { if (preselectAppliedRef.current) return @@ -422,7 +404,7 @@ function GroupAnalyticsPage() { // 日期范围变化时自动刷新 useEffect(() => { - if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') { + if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { setDateRangeReady(false) loadFunctionData(selectedFunction) } @@ -436,6 +418,7 @@ function GroupAnalyticsPage() { setSelectedFunction(null) setMembers([]) resetMemberMessageState() + setShowMemberExportModal(false) setRankings([]) setActiveHours({}) setMediaStats(null) @@ -450,10 +433,8 @@ function GroupAnalyticsPage() { setSelectedGroupId(group.username) setSelectedFunction(null) setSelectedMember(null) + setShowMemberExportModal(false) resetMemberMessageState() - setSelectedExportMemberUsername('') - setMemberSearchKeyword('') - setShowMemberSelect(false) setShowFormatSelect(false) setShowDisplayNameSelect(false) } @@ -580,23 +561,6 @@ function GroupAnalyticsPage() { }) break } - case 'memberExport': { - updateBackgroundTask(taskId, { - detail: '正在读取导出成员列表', - progressText: '成员导出' - }) - const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username) - if (isBackgroundTaskCancelRequested(taskId)) { - 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} 人` : '失败' - }) - break - } case 'ranking': { updateBackgroundTask(taskId, { detail: '正在计算群消息排行', @@ -731,7 +695,6 @@ function GroupAnalyticsPage() { } const handleDateRangeComplete = () => { - if (selectedFunction === 'memberExport') return setDateRangeReady(true) } @@ -796,6 +759,13 @@ function GroupAnalyticsPage() { 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) @@ -858,8 +828,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 @@ -893,6 +863,7 @@ function GroupAnalyticsPage() { } ) if (result.success && (result.successCount ?? 0) > 0) { + setShowMemberExportModal(false) alert(`导出成功:${member.displayName || member.username}`) } else { alert(`导出失败:${result.error || '未知错误'}`) @@ -1080,11 +1051,6 @@ function GroupAnalyticsPage() { 成员消息查看 按成员筛选并分页查看群聊消息
-
handleFunctionSelect('memberExport')}> - - 成员消息导出 - 按成员筛选并导出群聊记录 -
handleFunctionSelect('ranking')}> 群聊发言排行 @@ -1109,7 +1075,6 @@ function GroupAnalyticsPage() { switch (selectedFunction) { case 'members': return '群成员查看' case 'memberMessages': return '成员消息查看' - case 'memberExport': return '成员消息导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' @@ -1177,15 +1142,16 @@ function GroupAnalyticsPage() {
暂无群成员数据,请先刷新。
) : ( <> +
已加载 {memberMessages.length} 条消息
+
查看成员
)}
-
- 已加载 {memberMessages.length} 条消息 - - 当前成员:{selectedMessageMember?.displayName || selectedMessageMember?.username || '未选择成员'} - +
+
@@ -1283,234 +1253,6 @@ function GroupAnalyticsPage() { )}
)} - {selectedFunction === 'memberExport' && ( -
- {members.length === 0 ? ( -
暂无群成员数据,请先刷新。
- ) : ( - <> -
-
- 导出成员 - - {showMemberSelect && ( -
-
- - setMemberSearchKeyword(e.target.value)} - placeholder="搜索 wxid / 昵称 / 备注 / 微信号" - /> -
-
- {filteredMemberOptions.length === 0 ? ( -
无匹配成员
- ) : ( - filteredMemberOptions.map(member => ( - - )) - )} -
-
- )} -
-
- 导出格式 - - {showFormatSelect && ( -
- {memberExportFormatOptions.map(option => ( - - ))} -
- )} -
-
- 导出目录 -
- - -
-
-
- -
-
- 媒体导出 - -
-
- 媒体类型 -
- - - - -
-
-
- 附加选项 -
- - -
-
-
- 显示名称规则 - - {showDisplayNameSelect && ( -
- {displayNameOptions.map(option => ( - - ))} -
- )} -
-
- -
- -
- - )} -
- )} {selectedFunction === 'ranking' && (
{rankings.map((item, index) => ( @@ -1572,18 +1314,203 @@ function GroupAnalyticsPage() { const renderDetailPanel = () => { + if (selectedFunction) { + return renderFunctionContent() + } + if (!selectedGroup) { return ( -
- + <> + + ) } - if (!selectedFunction) { - return renderFunctionMenu() - } - return renderFunctionContent() + return ( + <> +
{renderMemberModal()} + {renderMemberExportModal()}
) } From 072c49a037a29d9e103e227aeb27d7e6f457bba5 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 16 Mar 2026 18:23:02 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=94=B9=E5=90=8D=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/GroupAnalyticsPage.scss | 87 +++++++++++++++++++++++++++++++ src/pages/GroupAnalyticsPage.tsx | 71 +++++++++++++++++++++---- 2 files changed, 149 insertions(+), 9 deletions(-) diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 95c71e6..b1b0eab 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -1606,6 +1606,11 @@ 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); + } } // 成员详情弹框 @@ -1822,3 +1827,85 @@ 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 84aaf89..db14c4d 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -188,6 +188,11 @@ function GroupAnalyticsPage() { const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) 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) @@ -783,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) } @@ -864,13 +881,25 @@ function GroupAnalyticsPage() { ) if (result.success && (result.successCount ?? 0) > 0) { setShowMemberExportModal(false) - alert(`导出成功:${member.displayName || member.username}`) + 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) } @@ -1048,8 +1077,8 @@ function GroupAnalyticsPage() {
handleFunctionSelect('memberMessages')}> - 成员消息查看 - 按成员筛选并分页查看群聊消息 + 成员消息筛选与导出 + 按成员查看群聊消息,并支持导出当前成员记录
handleFunctionSelect('ranking')}> @@ -1074,7 +1103,7 @@ function GroupAnalyticsPage() { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' - case 'memberMessages': return '成员消息查看' + case 'memberMessages': return '成员消息筛选与导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' @@ -1513,6 +1542,29 @@ function GroupAnalyticsPage() { ) } + const renderExportResultDialog = () => { + if (!exportResultDialog) return null + + return ( +
setExportResultDialog(null)}> +
e.stopPropagation()}> + +
+

{exportResultDialog.title}

+

{exportResultDialog.message}

+
+
+ +
+
+
+ ) + } + return (
@@ -1525,6 +1577,7 @@ function GroupAnalyticsPage() {
{renderMemberModal()} {renderMemberExportModal()} + {renderExportResultDialog()}
) }