diff --git a/installer.nsh b/installer.nsh index 78ef827..748eda0 100644 --- a/installer.nsh +++ b/installer.nsh @@ -2,6 +2,7 @@ ManifestDPIAware true !include "WordFunc.nsh" +!include "nsDialogs.nsh" !macro customInit ; 设置 DPI 感知 @@ -16,3 +17,49 @@ ManifestDPIAware true StrCpy $INSTDIR "$INSTDIR\WeFlow" ${EndIf} !macroend + +; 安装完成后检测并安装 VC++ Redistributable +!macro customInstall + ; 检查 VC++ 2015-2022 x64 是否已安装 + ReadRegStr $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" + ${If} $0 != "1" + ; 未安装,显示提示并下载 + MessageBox MB_YESNO|MB_ICONQUESTION "检测到系统缺少 Visual C++ 运行库,这可能导致程序无法正常运行。$\n$\n是否立即下载并安装?(约 24MB)" IDYES downloadVC IDNO skipVC + + downloadVC: + DetailPrint "正在下载 Visual C++ Redistributable..." + SetOutPath "$TEMP" + + ; 从微软官方下载 VC++ Redistributable x64 + inetc::get /TIMEOUT=30000 /CAPTION "下载 Visual C++ 运行库" /BANNER "正在下载,请稍候..." \ + "https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" /END + Pop $0 + + ${If} $0 == "OK" + DetailPrint "下载完成,正在安装..." + ; 使用 ShellExecute 以管理员权限运行 + ExecShell "runas" '"$TEMP\vc_redist.x64.exe"' "/install /quiet /norestart" SW_HIDE + ; 等待安装完成 + Sleep 5000 + ; 检查是否安装成功 + ReadRegStr $1 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" + ${If} $1 == "1" + DetailPrint "Visual C++ Redistributable 安装成功" + MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!" + ${Else} + MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,您可能需要手动安装。" + ${EndIf} + Delete "$TEMP\vc_redist.x64.exe" + ${Else} + MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n您可以稍后手动下载安装 Visual C++ Redistributable。" + ${EndIf} + Goto doneVC + + skipVC: + DetailPrint "用户跳过 Visual C++ Redistributable 安装" + + doneVC: + ${Else} + DetailPrint "Visual C++ Redistributable 已安装" + ${EndIf} +!macroend diff --git a/package.json b/package.json index 3a6fce7..15de675 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "weflow", - "version": "1.1.0", + "version": "1.1.2", "description": "WeFlow", "main": "dist-electron/main.js", "author": "cc", diff --git a/src/components/ImagePreview.scss b/src/components/ImagePreview.scss new file mode 100644 index 0000000..5e44b11 --- /dev/null +++ b/src/components/ImagePreview.scss @@ -0,0 +1,46 @@ +.image-preview-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + user-select: none; +} + +.preview-image { + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + transition: transform 0.15s ease-out; + + &.dragging { + transition: none; + } +} + +.image-preview-close { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + width: 48px; + height: 48px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.3); + background: rgba(0, 0, 0, 0.7); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + backdrop-filter: blur(10px); + + &:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.5); + transform: translateX(-50%) scale(1.1); + } +} diff --git a/src/components/ImagePreview.tsx b/src/components/ImagePreview.tsx new file mode 100644 index 0000000..28e06c9 --- /dev/null +++ b/src/components/ImagePreview.tsx @@ -0,0 +1,101 @@ +import React, { useState, useRef, useCallback, useEffect } from 'react' +import { X } from 'lucide-react' +import { createPortal } from 'react-dom' +import './ImagePreview.scss' + +interface ImagePreviewProps { + src: string + onClose: () => void +} + +export const ImagePreview: React.FC = ({ src, onClose }) => { + const [scale, setScale] = useState(1) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = useState(false) + const dragStart = useRef({ x: 0, y: 0 }) + const positionStart = useRef({ x: 0, y: 0 }) + const containerRef = useRef(null) + + // 滚轮缩放 + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault() + const delta = e.deltaY > 0 ? 0.9 : 1.1 + setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5)) + }, []) + + // 开始拖动 + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (scale <= 1) return + e.preventDefault() + setIsDragging(true) + dragStart.current = { x: e.clientX, y: e.clientY } + positionStart.current = { ...position } + }, [scale, position]) + + // 拖动中 + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isDragging) return + const dx = e.clientX - dragStart.current.x + const dy = e.clientY - dragStart.current.y + setPosition({ + x: positionStart.current.x + dx, + y: positionStart.current.y + dy + }) + }, [isDragging]) + + // 结束拖动 + const handleMouseUp = useCallback(() => { + setIsDragging(false) + }, []) + + // 双击重置 + const handleDoubleClick = useCallback(() => { + setScale(1) + setPosition({ x: 0, y: 0 }) + }, []) + + // 点击背景关闭 + const handleOverlayClick = useCallback((e: React.MouseEvent) => { + if (e.target === containerRef.current) { + onClose() + } + }, [onClose]) + + // ESC 关闭 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + return createPortal( +
+ 图片预览 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' + }} + onWheel={handleWheel} + onMouseDown={handleMouseDown} + onDoubleClick={handleDoubleClick} + draggable={false} + /> + +
, + document.body + ) +} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 2c7d462..02d0235 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1291,36 +1291,6 @@ color: var(--text-quaternary); } -.image-preview-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.75); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - - img { - max-width: 88vw; - max-height: 88vh; - border-radius: 12px; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); - } -} - -.image-preview-close { - position: absolute; - top: 20px; - right: 20px; - width: 36px; - height: 36px; - border-radius: 50%; - border: none; - background: rgba(0, 0, 0, 0.6); - color: #fff; - cursor: pointer; -} - // 语音消息 .voice-message { display: flex; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 6376593..3de02ac 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -4,6 +4,7 @@ import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' +import { ImagePreview } from '../components/ImagePreview' import './ChatPage.scss' interface ChatPageProps { @@ -1682,14 +1683,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: )} - {showImagePreview && createPortal( -
setShowImagePreview(false)}> - 图片预览 e.stopPropagation()} /> - -
, - document.body + {showImagePreview && ( + setShowImagePreview(false)} /> )} ) diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 7f2f676..55547c6 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -767,3 +767,168 @@ } } } + + +// wxid 输入框下拉 +.wxid-input-wrapper { + position: relative; + display: flex; + align-items: center; + + input { + flex: 1; + padding-right: 36px; + } +} + +.wxid-dropdown-btn { + position: absolute; + right: 8px; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + color: var(--text-primary); + } + + &.open { + transform: rotate(180deg); + } +} + +.wxid-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + max-height: 200px; + overflow-y: auto; +} + +.wxid-option { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: var(--primary-light); + color: var(--primary); + } + + .wxid-value { + font-weight: 500; + font-size: 13px; + } + + .wxid-time { + font-size: 11px; + color: var(--text-tertiary); + } +} + +// 多账号选择对话框 +.wxid-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.wxid-dialog { + background: var(--bg-primary); + border-radius: 16px; + width: 400px; + max-width: 90vw; + max-height: 80vh; + overflow: hidden; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); +} + +.wxid-dialog-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border-primary); + + h3 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + } +} + +.wxid-dialog-list { + padding: 8px; + max-height: 300px; + overflow-y: auto; +} + +.wxid-dialog-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + border-radius: 10px; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: var(--primary-light); + + .wxid-id { + color: var(--primary); + } + } + + .wxid-id { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .wxid-date { + font-size: 12px; + color: var(--text-tertiary); + } +} + +.wxid-dialog-footer { + padding: 16px 24px; + border-top: 1px solid var(--border-primary); + display: flex; + justify-content: flex-end; +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 989522f..8a717ce 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useAppStore } from '../stores/appStore' import { useThemeStore, themes } from '../stores/themeStore' import { dialog } from '../services/ipc' @@ -6,7 +6,7 @@ import * as configService from '../services/config' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Save, Plug, Check, Sun, Moon, - Palette, Database, Download, HardDrive, Info, RefreshCw + Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown } from 'lucide-react' import './SettingsPage.scss' @@ -19,6 +19,11 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'about', label: '关于', icon: Info } ] +interface WxidOption { + wxid: string + modifiedTime: number +} + function SettingsPage() { const { setDbConnected, setLoading, reset } = useAppStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() @@ -29,6 +34,9 @@ function SettingsPage() { const [imageAesKey, setImageAesKey] = useState('') const [dbPath, setDbPath] = useState('') const [wxid, setWxid] = useState('') + const [wxidOptions, setWxidOptions] = useState([]) + const [showWxidSelect, setShowWxidSelect] = useState(false) + const wxidDropdownRef = useRef(null) const [cachePath, setCachePath] = useState('') const [logEnabled, setLogEnabled] = useState(false) @@ -53,6 +61,17 @@ function SettingsPage() { loadAppVersion() }, []) + // 点击外部关闭下拉框 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) { + setShowWxidSelect(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showWxidSelect]) + useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { setDbKeyStatus(payload.message) @@ -156,12 +175,14 @@ function SettingsPage() { showMessage(`自动检测成功:${result.path}`, true) const wxids = await window.electronAPI.dbPath.scanWxids(result.path) + setWxidOptions(wxids) if (wxids.length === 1) { setWxid(wxids[0].wxid) await configService.setMyWxid(wxids[0].wxid) showMessage(`已检测到账号:${wxids[0].wxid}`, true) } else if (wxids.length > 1) { - showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true) + // 多账号时弹出选择对话框 + setShowWxidSelect(true) } } else { showMessage(result.error || '未能自动检测到数据库目录', false) @@ -192,12 +213,14 @@ function SettingsPage() { } try { const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) + setWxidOptions(wxids) if (wxids.length === 1) { setWxid(wxids[0].wxid) await configService.setMyWxid(wxids[0].wxid) if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true) } else if (wxids.length > 1) { - if (!silent) showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true) + // 多账号时弹出选择对话框 + setShowWxidSelect(true) } else { if (!silent) showMessage('未检测到账号目录,请检查路径', false) } @@ -206,6 +229,13 @@ function SettingsPage() { } } + const handleSelectWxid = async (selectedWxid: string) => { + setWxid(selectedWxid) + await configService.setMyWxid(selectedWxid) + setShowWxidSelect(false) + showMessage(`已选择账号:${selectedWxid}`, true) + } + const handleSelectCachePath = async () => { try { const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] }) @@ -466,7 +496,38 @@ function SettingsPage() {
微信账号标识 - setWxid(e.target.value)} /> +
+ setWxid(e.target.value)} + /> + + {showWxidSelect && wxidOptions.length > 0 && ( +
+ {wxidOptions.map((opt) => ( +
handleSelectWxid(opt.wxid)} + > + {opt.wxid} + + {new Date(opt.modifiedTime).toLocaleDateString()} + +
+ ))} +
+ )} +
@@ -604,6 +665,33 @@ function SettingsPage() {
{message &&
{message.text}
} + {/* 多账号选择对话框 */} + {showWxidSelect && wxidOptions.length > 1 && ( +
setShowWxidSelect(false)}> +
e.stopPropagation()}> +
+

检测到多个微信账号

+

请选择要使用的账号

+
+
+ {wxidOptions.map((opt) => ( +
handleSelectWxid(opt.wxid)} + > + {opt.wxid} + 最后修改: {new Date(opt.modifiedTime).toLocaleString()} +
+ ))} +
+
+ +
+
+
+ )} +

设置