From e9cc996ebb10dec63d9aa6b8c6601bcc0e97adac Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Wed, 19 Aug 2026 17:43:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20UI=20=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E5=B1=82=E4=B8=8E=E6=A0=B8=E5=BF=83=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/components/ChatWindow.tsx | 119 +- .../src/components/FirstUseWelcome.tsx | 93 +- src/renderer/src/components/ImageBubble.tsx | 17 +- src/renderer/src/components/SettingsPanel.tsx | 496 ------- .../src/components/chat/ChatHeader.tsx | 47 +- .../src/components/chat/ChatImageViewer.tsx | 160 +++ .../src/components/chat/ExportMenu.tsx | 68 +- .../chat/PersonalWechatSendDialog.tsx | 53 +- .../export/ExportConfigurationPanel.tsx | 441 ++++++ .../components/export/ExportContactPanel.tsx | 167 ++- .../components/export/ExportPreviewPanel.tsx | 202 +-- .../components/export/ExportTaskCenter.tsx | 50 +- .../src/components/export/ExportWorkspace.tsx | 383 +----- .../reports/ReportHistorySidebar.tsx | 90 +- .../src/components/reports/ReportToolbar.tsx | 154 +-- .../reports/WechatShareCardDialog.tsx | 161 +-- .../components/search/AISearchComposer.tsx | 161 +++ .../search/AISearchEvidencePanel.tsx | 115 ++ .../components/search/AISearchWorkspace.tsx | 361 ++--- .../search/ExternalProviderConsentDialog.tsx | 73 + .../hooks/useExternalProviderConsent.ts | 3 + .../src/components/search/searchMappers.ts | 27 + .../src/components/search/searchState.ts | 58 +- .../src/components/search/searchUtils.ts | 64 +- .../services/aiSearchProviderConsent.ts | 50 + src/renderer/src/components/ui/button.tsx | 2 +- src/renderer/src/components/ui/progress.tsx | 42 +- src/renderer/src/components/ui/tabs.tsx | 2 +- .../components/SkillPreviewDialog.tsx | 89 +- .../settings/pages/TextToSpeechPage.tsx | 837 ++++++------ src/renderer/src/styles/api.scss | 67 - src/renderer/src/styles/chat.scss | 198 --- src/renderer/src/styles/conversation.scss | 4 - src/renderer/src/styles/export.scss | 1183 +---------------- .../src/styles/reports-templates.scss | 335 ----- src/renderer/src/styles/search.scss | 484 +------ .../src/styles/settings-text-to-speech.scss | 157 --- src/renderer/src/styles/settings.scss | 2 +- src/renderer/src/styles/shell-overlays.scss | 531 -------- src/renderer/src/styles/theme.scss | 4 - tests/component/ai-search-composer.test.tsx | 78 ++ .../ai-search-evidence-panel.test.tsx | 70 + .../ai-search-provider-consent-hook.test.tsx | 22 + .../ai-search-workspace-regression.test.tsx | 116 +- tests/component/chat-image-viewer.test.tsx | 73 + tests/component/chat-menus.test.tsx | 60 + tests/component/export-preview-panel.test.tsx | 4 +- tests/component/export-workspace.test.tsx | 20 + .../external-provider-consent-dialog.test.tsx | 84 ++ tests/component/first-use-welcome.test.tsx | 70 + tests/component/image-bubble.test.tsx | 7 + .../personal-wechat-send-dialog.test.tsx | 62 + tests/component/report-features.test.tsx | 146 +- tests/component/skill-preview-dialog.test.tsx | 76 ++ tests/component/text-to-speech-page.test.tsx | 45 + .../wechat-share-card-dialog.test.tsx | 116 ++ tests/e2e/app.spec.ts | 225 +++- tests/e2e/support/electron-main.cjs | 82 +- tests/e2e/support/electron.ts | 19 +- tests/e2e/visual.spec.ts | 211 ++- tests/unit/ai-search-provider-consent.test.ts | 102 ++ ...ai-search-workspace-pure-functions.test.ts | 219 ++- 62 files changed, 4279 insertions(+), 5178 deletions(-) delete mode 100644 src/renderer/src/components/SettingsPanel.tsx create mode 100644 src/renderer/src/components/chat/ChatImageViewer.tsx create mode 100644 src/renderer/src/components/export/ExportConfigurationPanel.tsx create mode 100644 src/renderer/src/components/search/AISearchComposer.tsx create mode 100644 src/renderer/src/components/search/AISearchEvidencePanel.tsx create mode 100644 src/renderer/src/components/search/ExternalProviderConsentDialog.tsx create mode 100644 src/renderer/src/components/search/services/aiSearchProviderConsent.ts create mode 100644 tests/component/ai-search-composer.test.tsx create mode 100644 tests/component/ai-search-evidence-panel.test.tsx create mode 100644 tests/component/chat-image-viewer.test.tsx create mode 100644 tests/component/chat-menus.test.tsx create mode 100644 tests/component/external-provider-consent-dialog.test.tsx create mode 100644 tests/component/first-use-welcome.test.tsx create mode 100644 tests/component/skill-preview-dialog.test.tsx create mode 100644 tests/component/wechat-share-card-dialog.test.tsx create mode 100644 tests/unit/ai-search-provider-consent.test.ts diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index d99278c..e8e4b97 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { Message, Contact } from '../../../shared/types' import { ChatHeader } from './chat/ChatHeader' +import { ChatImageViewer } from './chat/ChatImageViewer' import { ChatStatusBar } from './chat/ChatStatusBar' import { DataTrustBar } from './chat/DataTrustBar' import { EmptyConversationState } from './chat/EmptyConversationState' @@ -46,13 +47,6 @@ const ChatWindow: React.FC = ({ const messageListRef = useRef(null) const messagesEndRef = useRef(null) const [previewImage, setPreviewImage] = useState(null) - const [imageScale, setImageScale] = useState(0.75) - const [imageRotation, setImageRotation] = useState(0) - const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 }) - const imageViewerStageRef = useRef(null) - const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>( - null - ) const [showAvatar, setShowAvatar] = useState(true) const [isAtLatest, setIsAtLatest] = useState(true) const [isReloadingAvatars, setIsReloadingAvatars] = useState(false) @@ -101,53 +95,10 @@ const ChatWindow: React.FC = ({ const openImagePreview = (imageUrl: string): void => { setPreviewImage(imageUrl) - setImageScale(1) - setImageRotation(0) - setImageOffset({ x: 0, y: 0 }) } const closeImagePreview = (): void => { setPreviewImage(null) - imageDragRef.current = null - } - - const zoomImage = (delta: number): void => { - setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2))))) - } - - const resetImageTransform = (): void => { - setImageScale(1) - setImageRotation(0) - setImageOffset({ x: 0, y: 0 }) - } - - const handleViewerWheel = (event: React.WheelEvent): void => { - event.preventDefault() - event.stopPropagation() - zoomImage(event.deltaY > 0 ? -0.1 : 0.1) - } - - const handleViewerMouseDown = (event: React.MouseEvent): void => { - event.preventDefault() - imageDragRef.current = { - x: event.clientX, - y: event.clientY, - offsetX: imageOffset.x, - offsetY: imageOffset.y - } - } - - const handleViewerMouseMove = (event: React.MouseEvent): void => { - if (!imageDragRef.current) return - const drag = imageDragRef.current - setImageOffset({ - x: drag.offsetX + event.clientX - drag.x, - y: drag.offsetY + event.clientY - drag.y - }) - } - - const handleViewerMouseUp = (): void => { - imageDragRef.current = null } const handleReloadAvatars = async (): Promise => { @@ -160,24 +111,6 @@ const ChatWindow: React.FC = ({ } } - useEffect(() => { - if (!previewImage) return - - const previousOverflow = document.body.style.overflow - document.body.style.overflow = 'hidden' - - const stage = imageViewerStageRef.current - const preventBackgroundWheel = (event: WheelEvent): void => { - event.preventDefault() - } - stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false }) - - return () => { - document.body.style.overflow = previousOverflow - stage?.removeEventListener('wheel', preventBackgroundWheel) - } - }, [previewImage]) - const filteredMessages = React.useMemo(() => { return messages.filter((msg) => { const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '') @@ -241,55 +174,7 @@ const ChatWindow: React.FC = ({ /> )} - {previewImage && ( -
-
e.stopPropagation()}> -
-
- 图片查看 - - {Math.round(imageScale * 100)}% - - - - - -
- -
-
- 图片预览 -
-
-
- )} + {previewImage && } ) } diff --git a/src/renderer/src/components/FirstUseWelcome.tsx b/src/renderer/src/components/FirstUseWelcome.tsx index f55a5f8..51ab59a 100644 --- a/src/renderer/src/components/FirstUseWelcome.tsx +++ b/src/renderer/src/components/FirstUseWelcome.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useRef } from 'react' +import { Button, Dialog, DialogContent, DialogDescription, DialogTitle } from './ui' interface FirstUseWelcomeProps { onDismiss: () => void @@ -16,63 +17,81 @@ export function FirstUseWelcome({ onOpenReport, onOpenAISettings }: FirstUseWelcomeProps): React.ReactElement { + const restoreFocusRef = useRef(null) + + const dismiss = (): void => { + const restoreFocus = restoreFocusRef.current + restoreFocusRef.current = null + onDismiss() + queueMicrotask(() => restoreFocus?.focus()) + } + return ( -
-
!open && dismiss()}> + { + restoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null + }} > - - -

微信已连接

-

开始探索你的微信

-

+

微信已连接

+ 开始探索你的微信 + 最关键的一步已经完成。现在,让 AI 帮你看看最近的聊天都发生了什么。 -

+
- + -
- - + +
-
+
还没有配置 AI? - - + + 查看完整使用教程
-
-
+ + ) } diff --git a/src/renderer/src/components/ImageBubble.tsx b/src/renderer/src/components/ImageBubble.tsx index 9bd2420..660925a 100644 --- a/src/renderer/src/components/ImageBubble.tsx +++ b/src/renderer/src/components/ImageBubble.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import type { JSX, MouseEvent } from 'react' +import type { JSX, KeyboardEvent, MouseEvent } from 'react' import { getCachedLoadedImage, requestImage } from './image-loader' interface ImageBubbleProps { @@ -156,6 +156,12 @@ export function ImageBubble({ if (imageUrl) onImageClick?.(imageUrl) } + const handlePreviewKeyDown = (event: KeyboardEvent): void => { + if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + void handleClick() + } + if (loading) { return (
@@ -184,7 +190,14 @@ export function ImageBubble({ } return ( -
+
图片 void - onDbKeyChange: (key: string) => void - onAiModelConfigChange: (config: AiModelConfig) => void - onSaveAiModelConfig: () => void - onDbRootChanged: () => void -} - -const AI_MODEL_OPTIONS = [ - { value: 'deepseek-chat', label: 'DeepSeek Chat' }, - { value: 'gpt-4o', label: 'GPT-4o' }, - { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, - { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }, - { value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' }, - { value: 'moonshot-v1-8k', label: 'Moonshot V1' } -] - -export const SettingsPanel: React.FC = ({ - open, - selfInfo, - dbReady, - dbKey, - aiModelConfig, - onClose, - onDbKeyChange, - onAiModelConfigChange, - onSaveAiModelConfig, - onDbRootChanged -}) => { - const dbRootPlaceholder = isWindows - ? 'C:\\Users\\你\\Documents\\WeChat Files' - : '~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files' - const [settings, setSettings] = useState(null) - const [settingsPath, setSettingsPath] = useState('') - const [apiState, setApiState] = useState(null) - const [testStatus, setTestStatus] = useState<{ - kind: 'idle' | 'ok' | 'fail' - message: string - wxid?: string - accountRoot?: string - }>({ kind: 'idle', message: '' }) - const [reopenStatus, setReopenStatus] = useState('') - const [imageKeyStatus, setImageKeyStatus] = useState<{ - kind: 'idle' | 'ok' | 'fail' - message: string - }>({ kind: 'idle', message: '' }) - const [busy, setBusy] = useState(false) - - useEffect(() => { - if (!open) return - void refresh() - }, [open]) - - useEffect(() => { - if (!open) return - return window.api.onImageKeyStatus(({ message }) => { - setImageKeyStatus({ kind: 'idle', message }) - }) - }, [open]) - - async function refresh(): Promise { - const [{ settings, settingsPath }, api] = await Promise.all([ - window.api.getSettings(), - window.api.apiStatus() - ]) - setSettings(settings) - setSettingsPath(settingsPath) - setApiState(api) - } - - if (!open) return null - - async function handleSave(patch: Partial): Promise { - if (!settings) return - setBusy(true) - const next = await window.api.setSettings(patch) - setSettings(next.settings) - setBusy(false) - } - - async function handleTest(): Promise { - setTestStatus({ kind: 'idle', message: '测试中...' }) - setBusy(true) - try { - const result = await window.api.testConnection(dbKey, settings?.dbRoot) - if (result.success) { - setTestStatus({ - kind: 'ok', - message: '连接成功', - wxid: result.wxid, - accountRoot: result.accountRoot - }) - if (result.accountRoot && settings && result.accountRoot !== settings.dbRoot) { - const next = await window.api.setSettings({ dbRoot: result.accountRoot }) - setSettings(next.settings) - } - } else { - setTestStatus({ kind: 'fail', message: result.error || '连接失败' }) - } - } catch (error) { - setTestStatus({ - kind: 'fail', - message: error instanceof Error ? error.message : String(error) - }) - } finally { - setBusy(false) - } - } - - async function handleReopen(): Promise { - if (!settings) return - setBusy(true) - setReopenStatus('重新初始化中...') - try { - const result = await window.api.reopenWithRoot(settings.dbRoot) - if (result.success) { - setReopenStatus(`已重新打开:${result.info?.wxid || '未知'}`) - onDbRootChanged() - } else { - setReopenStatus(result.error || '重新打开失败') - } - } catch (error) { - setReopenStatus(error instanceof Error ? error.message : String(error)) - } finally { - setBusy(false) - } - } - - async function handleApiToggle(enabled: boolean): Promise { - setBusy(true) - await handleSave({ apiEnabled: enabled }) - const state = await window.api.apiToggle(enabled) - setApiState(state) - setBusy(false) - } - - async function handleApiRestart(): Promise { - if (!settings) return - setBusy(true) - await window.api.apiStop() - const state = await window.api.apiStart(settings.apiHost, settings.apiPort) - setApiState(state) - setBusy(false) - } - - async function handleAutoGetImageKey(): Promise { - if (!settings) return - setBusy(true) - setImageKeyStatus({ kind: 'idle', message: '正在扫描微信内存获取图片密钥...' }) - try { - const result = await window.api.autoGetImageKey() - if (!result.success || !result.aesKey) { - setImageKeyStatus({ kind: 'fail', message: result.error || '图片密钥获取失败' }) - return - } - const imageXorKey = - result.imageXorKey || - (typeof result.xorKey === 'number' - ? `0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}` - : settings.imageXorKey) - const imageAesKey = result.imageAesKey || result.aesKey - setSettings(result.settings || { ...settings, imageXorKey, imageAesKey }) - setImageKeyStatus({ - kind: 'ok', - message: result.verified ? '图片密钥已获取并校验通过' : '图片密钥已获取,未完成模板校验' - }) - } catch (error) { - setImageKeyStatus({ - kind: 'fail', - message: error instanceof Error ? error.message : String(error) - }) - } finally { - setBusy(false) - } - } - - return ( -
-
e.stopPropagation()}> -
-

设置

- -
- -
- {/* 自我信息卡片 */} -
-
账号信息
- {dbReady && selfInfo ? ( -
-
- {selfInfo.avatar ? ( - {selfInfo.nickname} - ) : ( - (selfInfo.nickname || selfInfo.wxid || '?').charAt(0) - )} -
-
-
{selfInfo.nickname}
-
{selfInfo.wxid}
-
{selfInfo.accountRoot}
-
-
- ) : ( -
尚未连接数据库
- )} -
- - {/* 测试连接 */} -
-
连接测试
-
- - {testStatus.kind !== 'idle' && ( - - {testStatus.kind === 'ok' ? '✓' : '✗'} {testStatus.message} - {testStatus.wxid ? ` · ${testStatus.wxid}` : ''} - {testStatus.accountRoot ? ` · ${testStatus.accountRoot}` : ''} - - )} -
-
- 使用当前密钥 + 下方配置的根目录尝试打开数据库,只校验不持久化。 -
-
- - {/* 解密密钥 */} -
-
解密密钥
-
- onDbKeyChange(e.target.value)} - placeholder="64 位 hex 密钥,如 0x..." - spellCheck={false} - /> -
-
- 密钥通过系统 safeStorage 加密保存在本机,不会上传任何服务器。 -
-
- - {/* 图片解密密钥 */} -
-
图片解密密钥
-
- - setSettings(settings ? { ...settings, imageKeyRoot: e.target.value } : null) - } - onBlur={(e) => handleSave({ imageKeyRoot: e.target.value })} - placeholder={dbRootPlaceholder} - spellCheck={false} - /> -
-
- - setSettings(settings ? { ...settings, imageXorKey: e.target.value } : null) - } - onBlur={(e) => handleSave({ imageXorKey: e.target.value })} - placeholder="XOR Key,如 0x40" - spellCheck={false} - /> - - setSettings(settings ? { ...settings, imageAesKey: e.target.value } : null) - } - onBlur={(e) => handleSave({ imageAesKey: e.target.value })} - placeholder="AES Key,16 位字符" - spellCheck={false} - /> - -
- {imageKeyStatus.message && ( -
- {imageKeyStatus.kind === 'ok' ? '✓ ' : imageKeyStatus.kind === 'fail' ? '✗ ' : ''} - {imageKeyStatus.message} -
- )} -
- 目录默认使用数据库根目录,用于查找图片模板文件。Windows - 会直接扫描微信内存,请先在微信中打开 2-3 张图片大图。 -
-
- - {/* 数据库根目录 */} -
-
数据库根目录
-
- - setSettings(settings ? { ...settings, dbRoot: e.target.value } : null) - } - onBlur={(e) => handleSave({ dbRoot: e.target.value })} - placeholder={dbRootPlaceholder} - spellCheck={false} - /> -
-
- - {reopenStatus && {reopenStatus}} -
-
- 可填写微信数据总目录或具体账号目录。Windows 通常是 Documents\WeChat Files,macOS - 通常是 xwechat_files;程序会自动选择包含 db_storage/session.db 的账号目录。 -
-
- -
-
AI 模型配置
-
- - -
-
- - onAiModelConfigChange({ ...aiModelConfig, baseURL: event.target.value }) - } - placeholder="https://api.deepseek.com" - spellCheck={false} - /> -
-
- - onAiModelConfigChange({ ...aiModelConfig, apiKey: event.target.value }) - } - placeholder="API Key" - spellCheck={false} - /> -
-
- 所选内容会发送至你配置的模型服务进行处理。配置沿用原有本地 localStorage 保存方式。 -
-
- - {/* API 服务 */} -
-
本地 HTTP API
-
- - {apiState && ( - - {apiState.running ? '运行中' : '已停止'} - {apiState.error ? ` · ${apiState.error}` : ''} - - )} -
-
- - setSettings(settings ? { ...settings, apiHost: e.target.value } : null) - } - onBlur={(e) => handleSave({ apiHost: e.target.value })} - placeholder="host" - spellCheck={false} - /> - - setSettings( - settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null - ) - } - onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })} - placeholder="port" - /> - -
-
- API 默认仅监听本机,并通过 Bearer Token 保护数据接口。Token 请在 API Center - 中显示或复制。关闭后 Claude / Codex 等客户端无法读取聊天数据。 -
- 配置文档:docs/skill/tracememo-reader/SKILL.md -
-
- - {/* 配置文件位置 */} -
-
配置文件
-
- {settingsPath} -
-
-
-
-
- ) -} diff --git a/src/renderer/src/components/chat/ChatHeader.tsx b/src/renderer/src/components/chat/ChatHeader.tsx index 2ed246d..8a0d5f8 100644 --- a/src/renderer/src/components/chat/ChatHeader.tsx +++ b/src/renderer/src/components/chat/ChatHeader.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useState } from 'react' import { Contact } from '../../../../shared/types' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui' import { ConversationContentSearch } from './ConversationContentSearch' import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons' import { supportsPersonalWechatSend } from '../../utils/runtime-environment' @@ -32,21 +33,10 @@ export function ChatHeader({ onOpenAiSettings }: ChatHeaderProps): React.ReactElement { const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter)) - const [moreOpen, setMoreOpen] = useState(false) - const moreRef = useRef(null) const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话' const typeLabel = isGroupChat ? '群聊' : '联系人' const visibleCount = contentFilter ? filteredCount : loadedCount - useEffect(() => { - if (!moreOpen) return - const handlePointerDown = (event: PointerEvent): void => { - if (!moreRef.current?.contains(event.target as Node)) setMoreOpen(false) - } - window.addEventListener('pointerdown', handlePointerDown) - return () => window.removeEventListener('pointerdown', handlePointerDown) - }, [moreOpen]) - const handleCloseSearch = (): void => { onContentFilterChange('') setSearchOpen(false) @@ -91,29 +81,16 @@ export function ChatHeader({ -
- - {moreOpen && ( -
- -
- )} -
+ + + + + + onRefreshData?.()}>刷新数据 + + void +} + +export function ChatImageViewer({ imageUrl, onClose }: ChatImageViewerProps): React.ReactElement { + const [scale, setScale] = useState(1) + const [rotation, setRotation] = useState(0) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const dragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null) + const contentRef = useRef(null) + const restoreFocusRef = useRef(null) + const closingRef = useRef(false) + + const closeViewer = (): void => { + if (closingRef.current) return + closingRef.current = true + const restoreFocus = restoreFocusRef.current + restoreFocusRef.current = null + onClose() + queueMicrotask(() => restoreFocus?.focus()) + } + + const zoom = (delta: number): void => { + setScale((current) => Math.min(8, Math.max(0.1, Number((current + delta).toFixed(2))))) + } + + const reset = (): void => { + setScale(1) + setRotation(0) + setOffset({ x: 0, y: 0 }) + } + + const handleMouseDown = (event: React.MouseEvent): void => { + event.preventDefault() + dragRef.current = { + x: event.clientX, + y: event.clientY, + offsetX: offset.x, + offsetY: offset.y + } + } + + const handleMouseMove = (event: React.MouseEvent): void => { + if (!dragRef.current) return + const drag = dragRef.current + setOffset({ + x: drag.offsetX + event.clientX - drag.x, + y: drag.offsetY + event.clientY - drag.y + }) + } + + const stopDragging = (): void => { + dragRef.current = null + } + + return ( + !open && closeViewer()}> + { + restoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null + event.preventDefault() + contentRef.current?.focus() + }} + > + + + 图片查看 + + + 可缩放、旋转和拖动查看当前聊天图片。 + +
+ zoom(-0.1)} + > + + + + {Math.round(scale * 100)}% + + zoom(0.1)} + > + + + + + setRotation((current) => current - 90)} + > + + + setRotation((current) => current + 90)} + > + + + + + +
+
+
{ + event.preventDefault() + zoom(event.deltaY > 0 ? -0.1 : 0.1) + }} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={stopDragging} + onMouseLeave={stopDragging} + > + 图片预览 +
+
+
+ ) +} diff --git a/src/renderer/src/components/chat/ExportMenu.tsx b/src/renderer/src/components/chat/ExportMenu.tsx index 91e214f..4b7f7fb 100644 --- a/src/renderer/src/components/chat/ExportMenu.tsx +++ b/src/renderer/src/components/chat/ExportMenu.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react' +import React from 'react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui' import { ArrowDownIcon, ExportIcon } from './icons' export type ExportRange = number | 'all' @@ -18,47 +19,30 @@ const EXPORT_OPTIONS: { label: string; value: ExportRange }[] = [ ] export function ExportMenu({ disabled, onExport }: ExportMenuProps): React.ReactElement { - const [open, setOpen] = useState(false) - const menuRef = useRef(null) - - useEffect(() => { - if (!open) return - const handlePointerDown = (event: PointerEvent): void => { - if (!menuRef.current?.contains(event.target as Node)) setOpen(false) - } - window.addEventListener('pointerdown', handlePointerDown) - return () => window.removeEventListener('pointerdown', handlePointerDown) - }, [open]) - return ( -
- - {open && ( -
- {EXPORT_OPTIONS.map((option, index) => ( - - ))} -
- )} -
+ + + + + + {EXPORT_OPTIONS.map((option, index) => ( + onExport(option.value)} + > + {option.label} + + ))} + + ) } diff --git a/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx b/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx index 7d7a3cc..5ca12c4 100644 --- a/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx +++ b/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Contact } from '../../../../shared/types' import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat' import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui' type SendMode = 'image' | 'voice' type VoiceSource = 'generated' | 'file' @@ -82,6 +83,8 @@ export function PersonalWechatSendDialog({ const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) const generatedVoiceRef = useRef(null) const generatedAudioRef = useRef(null) + const restoreFocusRef = useRef(null) + const closingRef = useRef(false) const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话' const targetId = contact.m_nsUsrName const selectedTypeReady = mode === 'voice' ? status?.canSendVoice : status?.canSendImage @@ -122,9 +125,13 @@ export function PersonalWechatSendDialog({ }, []) const handleClose = useCallback((): void => { - if (isBusy) return + if (isBusy || closingRef.current) return + closingRef.current = true + const restoreFocus = restoreFocusRef.current + restoreFocusRef.current = null clearGeneratedVoice() onClose() + queueMicrotask(() => restoreFocus?.focus()) }, [clearGeneratedVoice, isBusy, onClose]) const handleOpenTextToSpeechSettings = (showSupportedVersions = false): void => { @@ -212,14 +219,6 @@ export function PersonalWechatSendDialog({ return () => audio?.pause() }, [generatedVoice]) - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape' && !isBusy) handleClose() - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [handleClose, isBusy]) - const handleRebind = async (): Promise => { if (isBusy) return setIsRebinding(true) @@ -392,23 +391,27 @@ export function PersonalWechatSendDialog({ ] return ( -
-
event.stopPropagation()} + !open && handleClose()}> + { + restoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null + }} + onEscapeKeyDown={(event) => isBusy && event.preventDefault()} + onPointerDownOutside={(event) => isBusy && event.preventDefault()} > -
+
- 实验性功能 -

个人微信测试发送

+ 实验性功能 + + 个人微信测试发送 +
- -
+ + 向当前微信联系人或群聊测试发送图片和语音。 + +
i @@ -676,7 +679,7 @@ export function PersonalWechatSendDialog({ )} -
-
+ + ) } diff --git a/src/renderer/src/components/export/ExportConfigurationPanel.tsx b/src/renderer/src/components/export/ExportConfigurationPanel.tsx new file mode 100644 index 0000000..a779006 --- /dev/null +++ b/src/renderer/src/components/export/ExportConfigurationPanel.tsx @@ -0,0 +1,441 @@ +import React from 'react' +import type { ExportContactType, ExportNameMode } from '../../../../shared/export' +import type { VoiceModelStatus } from '../../../../shared/voice-recognition' +import { Button, Checkbox, Input, RadioGroup, RadioGroupItem } from '../ui' +import type { Contact, ExportFormat, ExportRange, ExportStatus } from './exportTypes' +import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils' + +interface NameOption { + value: ExportNameMode + label: string +} + +interface ExportConfigurationPanelProps { + taskCenter: React.ReactNode + selectedContacts: Contact[] + exportAll: boolean + allContactTypes: ExportContactType[] + selectedLabel: string + selectionMode: boolean + exportContactCount: number + format: ExportFormat + range: ExportRange + startDate: string + endDate: string + selectedKinds: ReadonlySet + nameOptions: NameOption[] + nameMode: ExportNameMode + includeMedia: boolean + includeVoiceTranscripts: boolean + includeAvatars: boolean + preferOriginal: boolean + fallbackThumbnail: boolean + keepMissing: boolean + voiceModelStatus: VoiceModelStatus | null + zip: boolean + fileName: string + defaultOutputName: string + selectedTargetPath: string + status: ExportStatus + canStart: boolean + onToggleSelectionMode: () => void + onFormatChange: (format: ExportFormat) => void + onZipChange: (zip: boolean) => void + onRangeChange: (range: ExportRange) => void + onStartDateChange: (value: string) => void + onEndDateChange: (value: string) => void + onToggleKind: (kind: string) => void + onNameModeChange: (mode: ExportNameMode) => void + onIncludeMediaChange: (checked: boolean) => void + onIncludeVoiceTranscriptsChange: (checked: boolean) => void + onIncludeAvatarsChange: (checked: boolean) => void + onPreferOriginalChange: (checked: boolean) => void + onFallbackThumbnailChange: (checked: boolean) => void + onKeepMissingChange: (checked: boolean) => void + onFileNameChange: (value: string) => void + onSelectOutputDirectory: () => void + onReset: () => void + onStart: () => void +} + +const sectionClassName = 'mb-6' +const sectionTitleClassName = 'mb-3 text-xs font-bold tracking-normal text-foreground' +const helperClassName = 'mb-0 mt-2 text-[11px] leading-[17px] text-muted-foreground' +const choicePanelClassName = 'rounded-lg bg-muted p-3' + +export function ExportConfigurationPanel({ + taskCenter, + selectedContacts, + exportAll, + allContactTypes, + selectedLabel, + selectionMode, + exportContactCount, + format, + range, + startDate, + endDate, + selectedKinds, + nameOptions, + nameMode, + includeMedia, + includeVoiceTranscripts, + includeAvatars, + preferOriginal, + fallbackThumbnail, + keepMissing, + voiceModelStatus, + zip, + fileName, + defaultOutputName, + selectedTargetPath, + status, + canStart, + onToggleSelectionMode, + onFormatChange, + onZipChange, + onRangeChange, + onStartDateChange, + onEndDateChange, + onToggleKind, + onNameModeChange, + onIncludeMediaChange, + onIncludeVoiceTranscriptsChange, + onIncludeAvatarsChange, + onPreferOriginalChange, + onFallbackThumbnailChange, + onKeepMissingChange, + onFileNameChange, + onSelectOutputDirectory, + onReset, + onStart +}: ExportConfigurationPanelProps): React.ReactElement { + const mediaOptionsEnabled = includeMedia && format === 'html' + const voiceTranscriptsEnabled = + mediaOptionsEnabled && selectedKinds.has('voice') && voiceModelStatus?.state === 'ready' + + return ( +
+
+ {taskCenter} +
+ + {exportAll + ? allContactTypes.map((type, index) => ( + + {type === 'group' ? '群' : '联'} + + )) + : selectedContacts.slice(0, 3).map((contact, index) => ( + + {contact.avatar ? ( + + ) : ( + displayName(contact).slice(0, 1) + )} + + ))} + + +

+ 导出设置 +

+

+ {selectedLabel} +

+
+ +
+ +
+

导出格式

+
+ {formatOrder.map((value) => { + const active = format === value + return ( + + ) + })} +
+

+ {exportAll + ? '全部导出固定使用全部时间;每个群聊或联系人都会在自己的目录中生成所选格式的独立档案。' + : selectedContacts.length > 1 + ? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。' + : 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'} +

+ {format === 'html' && ( + <> + onZipChange(value === 'zip')} + aria-label="HTML 打包方式" + > + + + +

+ 使用相同名称再次导出时,会把新消息合并进已有档案,不会删除之前导出的消息。 +

+ + )} +
+ +
+
+

时间范围

+ + {status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'} + +
+
+ {(exportAll + ? [['all', '全部时间']] + : [ + ['all', '全部时间'], + ['today', '今天'], + ['threeDays', '最近 3 天'], + ['sevenDays', '最近 7 天'], + ['custom', '自定义时间'] + ] + ).map(([value, label]) => ( + + ))} +
+ {!exportAll && range === 'custom' && ( +
+ + +
+ )} +
+ +
+

消息内容

+
+ {messageKinds.map(([value, label]) => ( + + ))} +
+
+ +
+

消息显示名称

+ onNameModeChange(value as ExportNameMode)} + aria-label="消息显示名称" + > + {nameOptions.map((option) => ( + + ))} + +
+ +
+

资源处理

+ +
+ {( + [ + ['prefer-original', '优先导出原图', preferOriginal, onPreferOriginalChange], + [ + 'fallback-thumbnail', + '原图缺失时使用缩略图', + fallbackThumbnail, + onFallbackThumbnailChange + ], + ['keep-missing', '媒体缺失时保留占位说明', keepMissing, onKeepMissingChange] + ] as const + ).map(([id, label, checked, onChange]) => ( + + ))} + +
+

+ 资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。 +

+
+ {[ + '图片解密:已就绪', + '视频资源:可用', + '语音资源:可用', + `语音转文字:${voiceModelStatus?.state === 'ready' ? '已就绪' : '请先在设置中准备模型'}`, + '表情资源:按需解析', + '文件附件:按需复制' + ].map((label) => ( + + {label} + + ))} +
+

媒体资源会延长导出时间,缺失资源不会中断任务。

+ +
+ +
+

保存设置

+ +
+ 保存位置 + + {selectedTargetPath} + + +
+ {format === 'html' && ( +

可以分多次选择不同时间范围,逐步补齐同一个聊天档案。

+ )} +
+
+ +
+ + + {status === 'running' ? '正在后台导出' : status === 'completed' ? '导出完成' : '准备就绪'} + + + 路径:{selectedTargetPath} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/export/ExportContactPanel.tsx b/src/renderer/src/components/export/ExportContactPanel.tsx index 9aa3034..09b8229 100644 --- a/src/renderer/src/components/export/ExportContactPanel.tsx +++ b/src/renderer/src/components/export/ExportContactPanel.tsx @@ -1,5 +1,6 @@ import React from 'react' import type { ExportContactType } from '../../../../shared/export' +import { Button, Checkbox, Input, Tabs, TabsList, TabsTrigger } from '../ui' import type { Contact, SelfInfo } from './exportTypes' import { displayName } from './exportUtils' @@ -55,53 +56,72 @@ export function ExportContactPanel({ (allContactTypes.includes('user') ? userCount : 0) return ( -