feat: 更新版本号至 2.2.2,新增文件操作功能

- 在 IPC 中新增文件删除和复制功能,支持文件管理
- 更新 README.md,反映版本号变更
- 优化缓存清理逻辑,确保数据库连接安全关闭
- 改进 HTML 导出生成器,支持更现代化的样式和功能
- 增强数据管理页面的用户体验,添加下载进度提示
This commit is contained in:
ILoveBingLu
2026-02-12 05:59:13 +08:00
parent e2aa9e09ac
commit 922d6bfdfe
18 changed files with 2637 additions and 1024 deletions
+68 -1
View File
@@ -14,7 +14,11 @@
.content {
flex: 1;
overflow: auto;
padding: 24px;
padding: 24px 24px 0 24px; // 移除底部 padding
&.no-overflow {
overflow: hidden; // 数据管理页面禁用外层滚动
}
}
// 更新提示悬浮卡片
@@ -264,3 +268,66 @@
}
}
}
// 下载进度胶囊
.download-progress-capsule {
position: fixed;
top: 40px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(8px);
border-radius: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
z-index: 2000;
color: white;
font-size: 13px;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.1);
animation: capsuleSlideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1);
.spin {
animation: spin 1s linear infinite;
color: var(--primary);
}
span {
white-space: nowrap;
}
.progress-bar-bg {
width: 100px;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 2px;
transition: width 0.2s linear;
}
}
@keyframes capsuleSlideDown {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+27 -4
View File
@@ -31,7 +31,7 @@ import * as configService from './services/config'
import { initTldList } from './utils/linkify'
import LockScreen from './pages/LockScreen'
import { useAuthStore } from './stores/authStore'
import { X, Shield } from 'lucide-react'
import { X, Shield, Loader2 } from 'lucide-react'
import './App.scss'
function App() {
@@ -52,6 +52,7 @@ function App() {
// 更新提示状态
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
const [downloadProgress, setDownloadProgress] = useState<number | null>(null)
// 加载主题配置
useEffect(() => {
@@ -153,6 +154,16 @@ function App() {
}
}, [])
// 监听下载进度
useEffect(() => {
const removeDownloadListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
setDownloadProgress(progress)
})
return () => {
removeDownloadListener?.()
}
}, [])
const dismissUpdate = () => {
setUpdateInfo(null)
}
@@ -436,8 +447,11 @@ function App() {
<div className="update-toast-title"></div>
<div className="update-toast-version">v{updateInfo.version} </div>
</div>
<button className="update-toast-btn" onClick={() => { navigate('/settings?tab=about'); dismissUpdate(); }}>
<button className="update-toast-btn" onClick={() => {
window.electronAPI.app.downloadAndInstall()
dismissUpdate()
}}>
</button>
<button className="update-toast-close" onClick={dismissUpdate}>
<X size={14} />
@@ -447,7 +461,7 @@ function App() {
<div className="main-layout">
<Sidebar />
<main className="content">
<main className={`content ${location.pathname === '/data-management' ? 'no-overflow' : ''}`}>
<RouteGuard>
<Routes>
<Route path="/" element={<WelcomePage />} />
@@ -463,6 +477,15 @@ function App() {
</main>
</div>
<DecryptProgressOverlay />
{downloadProgress !== null && (
<div className="download-progress-capsule">
<Loader2 className="spin" size={14} />
<span>... {downloadProgress.toFixed(0)}%</span>
<div className="progress-bar-bg">
<div className="progress-bar-fill" style={{ width: `${downloadProgress}%` }} />
</div>
</div>
)}
{isLocked && <LockScreen />}
</div>
)
+46 -27
View File
@@ -15,15 +15,15 @@
.whats-new-modal {
width: 600px;
max-width: 90%;
background: rgba(255, 255, 255, 0.95);
background: var(--bg-secondary);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
animation: scaleUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
border: 1px solid rgba(255, 255, 255, 0.5);
border: 1px solid var(--border-color);
.modal-header {
padding: 40px 32px 24px;
@@ -31,8 +31,8 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #FAF8F5 0%, #FFFFFF 100%);
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
border-bottom: 1px solid var(--border-color);
.version-tag {
display: inline-flex;
@@ -52,19 +52,15 @@
h2 {
font-size: 26px;
line-height: 1.2;
color: #1a1a1a;
color: var(--text-primary);
margin: 0 0 8px;
letter-spacing: -0.5px;
background: linear-gradient(135deg, #2c2c2c 0%, #5a5a5a 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-align: center;
}
p {
margin: 0;
color: #888;
color: var(--text-secondary);
font-size: 14px;
text-align: center;
font-weight: 400;
@@ -89,11 +85,11 @@
width: 40px;
height: 40px;
border-radius: 12px;
background: #F9F7F5;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: #8B7355;
color: var(--primary);
flex-shrink: 0;
}
@@ -101,13 +97,13 @@
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
color: var(--text-primary);
margin: 0 0 4px;
}
p {
font-size: 14px;
color: #666;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
@@ -118,30 +114,53 @@
.modal-footer {
padding: 16px 32px 32px;
display: flex;
justify-content: center;
justify-content: flex-end;
align-items: center;
gap: 12px;
background: var(--bg-secondary);
.start-btn {
background: #2c2c2c;
color: white;
border: none;
padding: 12px 48px;
border-radius: 16px;
font-size: 16px;
button {
padding: 10px 20px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: none;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
}
}
.telegram-btn {
background: #2AABEE;
color: white;
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.2);
&:hover {
background: #229ED9;
box-shadow: 0 6px 16px rgba(42, 171, 238, 0.3);
}
}
.start-btn {
background: var(--text-primary);
color: var(--bg-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
&:hover {
opacity: 0.9;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
}
}
// 装饰元素
@@ -152,7 +171,7 @@
left: -50px;
width: 150px;
height: 150px;
background: radial-gradient(circle, rgba(139, 115, 85, 0.05) 0%, transparent 70%);
background: radial-gradient(circle, var(--primary-alpha-10) 0%, transparent 70%);
pointer-events: none;
}
}
+22 -24
View File
@@ -1,4 +1,4 @@
import { Zap, Layout, Monitor, MessageSquareQuote, RefreshCw, Mic, Rocket, Sparkles } from 'lucide-react'
import { Package, Image, Mic, Filter, Send } from 'lucide-react'
import './WhatsNewModal.scss'
interface WhatsNewModalProps {
@@ -8,38 +8,32 @@ interface WhatsNewModalProps {
function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
const updates = [
// {
// icon: <Rocket size={20} />,
// title: '性能优化',
// desc: '修复消息内容会出现重复的问题。'
// },
{
icon: <MessageSquareQuote size={20} />,
title: '优化1',
desc: '优化图片加载逻辑。'
icon: <Package size={20} />,
title: '媒体导出',
desc: '导出聊天记录时可同时导出图片、视频、表情包和语音消息。'
},
{
icon: <MessageSquareQuote size={20} />,
title: '优化2',
desc: '优化批量语音转文字功能。'
icon: <Image size={20} />,
title: '图片自动解密',
desc: '导出时自动解密未缓存的图片,无需提前在密语聊天窗口浏览。'
},
// {
// icon: <Sparkles size={20} />,
// title: 'AI摘要',
// desc: '支持AI在单人会话以及群聊会话中进行AI摘要总结。(默认只能选择天数)'
// },
// {
// icon: <RefreshCw size={20} />,
// title: '体验升级',
// desc: '修复了一些已知的问题。'
// }//,
{
icon: <Mic size={20} />,
title: '新功能',
desc: '数据管理界面可查看所有解密后的图片。'
title: '语音导出',
desc: '支持将语音消息解码为 WAV 格式导出,含转写文字。'
},
{
icon: <Filter size={20} />,
title: '分类导出',
desc: '导出时可按群聊或个人聊天筛选,支持日期范围过滤。'
}
]
const handleTelegram = () => {
window.electronAPI?.shell?.openExternal?.('https://t.me/+p7YzmRMBm-gzNzJl')
}
return (
<div className="whats-new-overlay">
<div className="whats-new-modal">
@@ -66,6 +60,10 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
</div>
<div className="modal-footer">
<button className="telegram-btn" onClick={handleTelegram}>
<Send size={16} />
Telegram
</button>
<button className="start-btn" onClick={onClose}>
</button>
+221 -7
View File
@@ -45,6 +45,53 @@
display: flex;
flex-direction: column;
gap: 24px;
height: calc(100vh - 140px); // 设置固定高度
overflow-y: auto; // 启用滚动
overflow-x: hidden;
// 滚动条样式
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
&:hover {
background: var(--text-tertiary);
}
}
}
.media-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
h2 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.section-desc {
font-size: 13px;
color: var(--text-tertiary);
margin: 0;
}
.section-actions {
display: flex;
gap: 10px;
}
}
.page-section {
@@ -127,6 +174,15 @@
}
}
.btn-danger {
background: #ef4444;
color: white;
&:hover:not(:disabled) {
background: #dc2626;
}
}
.database-list {
display: flex;
flex-direction: column;
@@ -318,15 +374,17 @@
}
}
// 媒体网格样式
// 媒体网格样式 - 直接显示在主区域
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); // 自动填充,充分利用空间
gap: 12px;
max-height: 600px;
overflow-y: auto;
padding: 4px;
max-height: calc(100vh - 200px); // 设置最大高度
overflow-y: auto; // 启用垂直滚动
overflow-x: hidden;
// 滚动条样式
&::-webkit-scrollbar {
width: 8px;
}
@@ -346,7 +404,7 @@
}
&.emoji-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); // 表情包也自适应
}
}
@@ -364,6 +422,10 @@
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: var(--primary);
.media-actions {
opacity: 1;
}
}
&.pending {
@@ -381,6 +443,7 @@
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-placeholder {
@@ -402,6 +465,49 @@
}
}
.media-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
.action-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(8px);
&.download-btn {
background: rgba(59, 130, 246, 0.9);
color: white;
&:hover {
background: rgba(37, 99, 235, 1);
transform: scale(1.1);
}
}
&.delete-btn {
background: rgba(239, 68, 68, 0.9);
color: white;
&:hover {
background: rgba(220, 38, 38, 1);
transform: scale(1.1);
}
}
}
}
.media-info {
position: absolute;
bottom: 0;
@@ -435,8 +541,6 @@
}
.emoji-item {
aspect-ratio: 1;
img {
padding: 12px;
object-fit: contain;
@@ -452,6 +556,26 @@
}
}
.loading-more {
grid-column: 1 / -1;
text-align: center;
padding: 16px;
font-size: 13px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
border-radius: 8px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.more-hint {
grid-column: 1 / -1;
text-align: center;
@@ -494,6 +618,7 @@
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
.progress-card {
background: var(--bg-primary);
@@ -542,6 +667,95 @@
}
}
.delete-confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease;
.delete-confirm-card {
background: var(--bg-primary);
border-radius: 16px;
padding: 28px 32px;
min-width: 400px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
animation: slideUp 0.2s ease;
h3 {
margin: 0 0 16px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
}
.confirm-message {
margin: 0 0 12px;
font-size: 15px;
color: var(--text-primary);
text-align: center;
}
.confirm-detail {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
text-align: center;
padding: 8px 12px;
background: var(--bg-tertiary);
border-radius: 8px;
word-break: break-all;
}
.confirm-warning {
margin: 0 0 24px;
font-size: 12px;
color: #ef4444;
text-align: center;
font-weight: 500;
}
.confirm-actions {
display: flex;
gap: 12px;
justify-content: center;
.btn {
min-width: 100px;
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 图片列表样式
.current-dir {
+381 -135
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile } from 'lucide-react'
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile, Download, Trash2 } from 'lucide-react'
import './DataManagementPage.scss'
interface DatabaseFile {
@@ -22,6 +22,11 @@ interface ImageFileInfo {
version: number
}
interface DeleteConfirmData {
image: ImageFileInfo
show: boolean
}
type TabType = 'database' | 'images' | 'emojis'
function DataManagementPage() {
@@ -29,11 +34,21 @@ function DataManagementPage() {
const [databases, setDatabases] = useState<DatabaseFile[]>([])
const [images, setImages] = useState<ImageFileInfo[]>([])
const [emojis, setEmojis] = useState<ImageFileInfo[]>([])
const [imageCount, setImageCount] = useState({ total: 0, decrypted: 0 })
const [emojiCount, setEmojiCount] = useState({ total: 0, decrypted: 0 })
const [isLoading, setIsLoading] = useState(false)
const [isDecrypting, setIsDecrypting] = useState(false)
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const [progress, setProgress] = useState<any>(null)
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmData>({ image: null as any, show: false })
const location = useLocation()
// 懒加载相关状态
const [displayedImageCount, setDisplayedImageCount] = useState(20)
const [displayedEmojiCount, setDisplayedEmojiCount] = useState(20)
const imageGridRef = useRef<HTMLDivElement>(null)
const emojiGridRef = useRef<HTMLDivElement>(null)
const loadMoreThreshold = 300 // 距离底部多少像素时加载更多
const loadDatabases = useCallback(async () => {
setIsLoading(true)
@@ -66,35 +81,36 @@ function DataManagementPage() {
return
}
// 扫描第一个目录的图片
const firstDir = dirsResult.directories[0]
console.log('[DataManagement] 扫描目录:', firstDir.path)
const result = await window.electronAPI.dataManagement.scanImages(firstDir.path)
console.log('[DataManagement] 扫描结果:', result)
if (result.success && result.images) {
console.log('[DataManagement] 找到图片数量:', result.images.length)
// 分离图片和表情包
const imageList: ImageFileInfo[] = []
const emojiList: ImageFileInfo[] = []
result.images.forEach(img => {
console.log('[DataManagement] 处理图片:', img.fileName, '路径:', img.filePath)
// 根据路径判断是否是表情包
if (img.filePath.includes('CustomEmotions') || img.filePath.includes('emoji')) {
emojiList.push(img)
} else {
imageList.push(img)
}
})
console.log('[DataManagement] 图片分类完成 - 普通图片:', imageList.length, '表情包:', emojiList.length)
setImages(imageList)
setEmojis(emojiList)
} else {
showMessage(result.error || '扫描图片失败', false)
// 找到 images 和 Emojis 目录
const imagesDir = dirsResult.directories.find(d => d.path.includes('images'))
const emojisDir = dirsResult.directories.find(d => d.path.includes('Emojis'))
// 扫描普通图片
if (imagesDir) {
console.log('[DataManagement] 扫描图片目录:', imagesDir.path)
const result = await window.electronAPI.dataManagement.scanImages(imagesDir.path)
if (result.success && result.images) {
console.log('[DataManagement] 找到普通图片:', result.images.length, '个')
setImages(result.images)
setImageCount({
total: result.images.length,
decrypted: result.images.filter(img => img.isDecrypted).length
})
}
}
// 扫描表情包
if (emojisDir) {
console.log('[DataManagement] 扫描表情包目录:', emojisDir.path)
const result = await window.electronAPI.dataManagement.scanImages(emojisDir.path)
if (result.success && result.images) {
console.log('[DataManagement] 找到表情包:', result.images.length, '个')
setEmojis(result.images)
setEmojiCount({
total: result.images.length,
decrypted: result.images.filter(emoji => emoji.isDecrypted).length
})
}
}
} catch (e) {
console.error('[DataManagement] 扫描图片异常:', e)
@@ -104,6 +120,46 @@ function DataManagementPage() {
}
}, [])
// 页面加载时预加载图片数量(不加载详细数据)
useEffect(() => {
const loadImageCounts = async () => {
try {
const dirsResult = await window.electronAPI.dataManagement.getImageDirectories()
if (dirsResult.success && dirsResult.directories && dirsResult.directories.length > 0) {
// 找到 images 和 Emojis 目录
const imagesDir = dirsResult.directories.find(d => d.path.includes('images'))
const emojisDir = dirsResult.directories.find(d => d.path.includes('Emojis'))
// 扫描普通图片数量
if (imagesDir) {
const result = await window.electronAPI.dataManagement.scanImages(imagesDir.path)
if (result.success && result.images) {
setImageCount({
total: result.images.length,
decrypted: result.images.filter(img => img.isDecrypted).length
})
}
}
// 扫描表情包数量
if (emojisDir) {
const result = await window.electronAPI.dataManagement.scanImages(emojisDir.path)
if (result.success && result.images) {
setEmojiCount({
total: result.images.length,
decrypted: result.images.filter(emoji => emoji.isDecrypted).length
})
}
}
}
} catch (e) {
console.error('[DataManagement] 预加载图片数量失败:', e)
}
}
loadImageCounts()
}, [])
useEffect(() => {
if (activeTab === 'database') {
loadDatabases()
@@ -273,6 +329,9 @@ function DataManagementPage() {
loadDatabases()
} else if (activeTab === 'images' || activeTab === 'emojis') {
loadImages()
// 重置懒加载计数
setDisplayedImageCount(20)
setDisplayedEmojiCount(20)
}
}
@@ -290,12 +349,142 @@ function DataManagementPage() {
}
}
const handleDownloadImage = async (e: React.MouseEvent, image: ImageFileInfo) => {
e.stopPropagation() // 阻止触发点击打开图片
if (!image.isDecrypted || !image.decryptedPath) {
showMessage('图片未解密,无法下载', false)
return
}
try {
// 直接使用浏览器的下载功能
const link = document.createElement('a')
link.href = image.decryptedPath.startsWith('file://')
? image.decryptedPath
: `file:///${image.decryptedPath.replace(/\\/g, '/')}`
link.download = image.fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
showMessage('下载成功', true)
} catch (e) {
showMessage(`下载失败: ${e}`, false)
}
}
const handleDeleteImage = async (e: React.MouseEvent, image: ImageFileInfo) => {
e.stopPropagation() // 阻止触发点击打开图片
if (!image.isDecrypted || !image.decryptedPath) {
showMessage('图片未解密,无法删除', false)
return
}
// 显示自定义确认对话框
setDeleteConfirm({ image, show: true })
}
const confirmDelete = async () => {
const image = deleteConfirm.image
setDeleteConfirm({ image: null as any, show: false })
try {
// 删除解密后的文件
const result = await window.electronAPI.file.delete(image.decryptedPath!)
if (result.success) {
showMessage('删除成功', true)
// 刷新列表
await loadImages()
} else {
showMessage(`删除失败: ${result.error}`, false)
}
} catch (e) {
showMessage(`删除失败: ${e}`, false)
}
}
const cancelDelete = () => {
setDeleteConfirm({ image: null as any, show: false })
}
// 懒加载:监听滚动事件
useEffect(() => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLDivElement
const scrollTop = target.scrollTop
const scrollHeight = target.scrollHeight
const clientHeight = target.clientHeight
// 距离底部小于阈值时加载更多
if (scrollHeight - scrollTop - clientHeight < loadMoreThreshold) {
if (activeTab === 'images' && displayedImageCount < images.length) {
setDisplayedImageCount(prev => Math.min(prev + 20, images.length))
} else if (activeTab === 'emojis' && displayedEmojiCount < emojis.length) {
setDisplayedEmojiCount(prev => Math.min(prev + 20, emojis.length))
}
}
}
const imageGrid = imageGridRef.current
const emojiGrid = emojiGridRef.current
if (activeTab === 'images' && imageGrid) {
imageGrid.addEventListener('scroll', handleScroll)
return () => imageGrid.removeEventListener('scroll', handleScroll)
} else if (activeTab === 'emojis' && emojiGrid) {
emojiGrid.addEventListener('scroll', handleScroll)
return () => emojiGrid.removeEventListener('scroll', handleScroll)
}
}, [activeTab, displayedImageCount, displayedEmojiCount, images.length, emojis.length])
// 检查是否需要加载更多(如果没有滚动条)
useEffect(() => {
const checkAndLoadMore = () => {
const grid = activeTab === 'images' ? imageGridRef.current : emojiGridRef.current
if (!grid) return
const hasScroll = grid.scrollHeight > grid.clientHeight
const hasMore = activeTab === 'images'
? displayedImageCount < images.length
: displayedEmojiCount < emojis.length
// 如果没有滚动条且还有更多内容,继续加载
if (!hasScroll && hasMore) {
if (activeTab === 'images') {
setDisplayedImageCount(prev => Math.min(prev + 20, images.length))
} else {
setDisplayedEmojiCount(prev => Math.min(prev + 20, emojis.length))
}
}
}
// 延迟检查,等待 DOM 渲染完成
const timer = setTimeout(checkAndLoadMore, 100)
return () => clearTimeout(timer)
}, [activeTab, displayedImageCount, displayedEmojiCount, images.length, emojis.length])
// 切换标签时重置懒加载计数
useEffect(() => {
setDisplayedImageCount(20)
setDisplayedEmojiCount(20)
}, [activeTab])
const pendingCount = databases.filter(db => !db.isDecrypted).length
const decryptedCount = databases.filter(db => db.isDecrypted).length
const needsUpdateCount = databases.filter(db => db.needsUpdate).length
const decryptedImagesCount = images.filter(img => img.isDecrypted).length
const decryptedEmojisCount = emojis.filter(emoji => emoji.isDecrypted).length
// 使用预加载的计数,如果已加载详细数据则使用详细数据的计数
const displayImageCount = images.length > 0 ? images.length : imageCount.total
const displayDecryptedImagesCount = images.length > 0
? images.filter(img => img.isDecrypted).length
: imageCount.decrypted
const displayEmojiCount = emojis.length > 0 ? emojis.length : emojiCount.total
const displayDecryptedEmojisCount = emojis.length > 0
? emojis.filter(emoji => emoji.isDecrypted).length
: emojiCount.decrypted
return (
@@ -326,6 +515,25 @@ function DataManagementPage() {
</div>
)}
{deleteConfirm.show && (
<div className="delete-confirm-overlay" onClick={cancelDelete}>
<div className="delete-confirm-card" onClick={(e) => e.stopPropagation()}>
<h3></h3>
<p className="confirm-message"></p>
<p className="confirm-detail">: {deleteConfirm.image?.fileName}</p>
<p className="confirm-warning"></p>
<div className="confirm-actions">
<button className="btn btn-secondary" onClick={cancelDelete}>
</button>
<button className="btn btn-danger" onClick={confirmDelete}>
</button>
</div>
</div>
</div>
)}
<div className="page-header">
<h1></h1>
<div className="header-tabs">
@@ -341,20 +549,20 @@ function DataManagementPage() {
onClick={() => setActiveTab('images')}
>
<ImageIcon size={16} />
({decryptedImagesCount}/{images.length})
({displayDecryptedImagesCount}/{displayImageCount})
</button>
<button
className={`tab-btn ${activeTab === 'emojis' ? 'active' : ''}`}
onClick={() => setActiveTab('emojis')}
>
<Smile size={16} />
({decryptedEmojisCount}/{emojis.length})
({displayDecryptedEmojisCount}/{displayEmojiCount})
</button>
</div>
</div>
<div className="page-scroll">
{activeTab === 'database' && (
{activeTab === 'database' && (
<div className="page-scroll">
<section className="page-section">
<div className="section-header">
<div>
@@ -418,133 +626,171 @@ function DataManagementPage() {
)}
</div>
</section>
)}
</div>
)}
{activeTab === 'images' && (
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc">
{isLoading ? '正在扫描...' : `${images.length} 张图片,${decryptedImagesCount} 张已解密`}
</p>
</div>
<div className="section-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
</div>
{activeTab === 'images' && (
<>
<div className="media-header">
<div>
<h2></h2>
<p className="section-desc">
{isLoading ? '正在扫描...' : `${displayImageCount} 张图片,${displayDecryptedImagesCount} 张已解密`}
</p>
</div>
<div className="section-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
</div>
</div>
<div className="media-grid">
{images.slice(0, 100).map((image, index) => (
<div
key={index}
className={`media-item ${image.isDecrypted ? 'decrypted' : 'pending'}`}
onClick={() => handleImageClick(image)}
>
{image.isDecrypted && image.decryptedPath ? (
<div className="media-grid" ref={imageGridRef}>
{images.slice(0, displayedImageCount).map((image, index) => (
<div
key={index}
className={`media-item ${image.isDecrypted ? 'decrypted' : 'pending'}`}
onClick={() => handleImageClick(image)}
>
{image.isDecrypted && image.decryptedPath ? (
<>
<img
src={image.decryptedPath.startsWith('data:') ? image.decryptedPath : `file:///${image.decryptedPath.replace(/\\/g, '/')}`}
alt={image.fileName}
loading="lazy"
onError={(e) => {
console.error('[DataManagement] 图片加载失败:', image.decryptedPath)
e.currentTarget.style.display = 'none'
}}
/>
) : (
<div className="media-placeholder">
<ImageIcon size={32} />
<span></span>
<div className="media-actions">
<button
className="action-btn download-btn"
onClick={(e) => handleDownloadImage(e, image)}
title="下载"
>
<Download size={16} />
</button>
<button
className="action-btn delete-btn"
onClick={(e) => handleDeleteImage(e, image)}
title="删除"
>
<Trash2 size={16} />
</button>
</div>
)}
<div className="media-info">
<span className="media-name">{image.fileName}</span>
<span className="media-size">{formatFileSize(image.fileSize)}</span>
</>
) : (
<div className="media-placeholder">
<ImageIcon size={32} />
<span></span>
</div>
)}
<div className="media-info">
<span className="media-name">{image.fileName}</span>
<span className="media-size">{formatFileSize(image.fileSize)}</span>
</div>
))}
{!isLoading && images.length === 0 && (
<div className="empty-state">
<ImageIcon size={48} strokeWidth={1} />
<p></p>
<p className="hint"></p>
</div>
)}
{images.length > 100 && (
<div className="more-hint">
100 {images.length}
</div>
)}
</div>
</section>
)}
{activeTab === 'emojis' && (
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc">
{isLoading ? '正在扫描...' : `${emojis.length} 个表情包,${decryptedEmojisCount} 个已解密`}
</p>
</div>
<div className="section-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
</div>
</div>
))}
<div className="media-grid emoji-grid">
{emojis.slice(0, 100).map((emoji, index) => (
<div
key={index}
className={`media-item emoji-item ${emoji.isDecrypted ? 'decrypted' : 'pending'}`}
onClick={() => handleImageClick(emoji)}
>
{emoji.isDecrypted && emoji.decryptedPath ? (
{!isLoading && images.length === 0 && (
<div className="empty-state">
<ImageIcon size={48} strokeWidth={1} />
<p></p>
<p className="hint"></p>
</div>
)}
{displayedImageCount < images.length && (
<div className="loading-more">
... ({displayedImageCount}/{images.length})
</div>
)}
</div>
</>
)}
{activeTab === 'emojis' && (
<>
<div className="media-header">
<div>
<h2></h2>
<p className="section-desc">
{isLoading ? '正在扫描...' : `${displayEmojiCount} 个表情包,${displayDecryptedEmojisCount} 个已解密`}
</p>
</div>
<div className="section-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
</div>
</div>
<div className="media-grid emoji-grid" ref={emojiGridRef}>
{emojis.slice(0, displayedEmojiCount).map((emoji, index) => (
<div
key={index}
className={`media-item emoji-item ${emoji.isDecrypted ? 'decrypted' : 'pending'}`}
onClick={() => handleImageClick(emoji)}
>
{emoji.isDecrypted && emoji.decryptedPath ? (
<>
<img
src={emoji.decryptedPath.startsWith('data:') ? emoji.decryptedPath : `file:///${emoji.decryptedPath.replace(/\\/g, '/')}`}
alt={emoji.fileName}
loading="lazy"
onError={(e) => {
console.error('[DataManagement] 表情包加载失败:', emoji.decryptedPath)
e.currentTarget.style.display = 'none'
}}
/>
) : (
<div className="media-placeholder">
<Smile size={32} />
<span></span>
<div className="media-actions">
<button
className="action-btn download-btn"
onClick={(e) => handleDownloadImage(e, emoji)}
title="下载"
>
<Download size={16} />
</button>
<button
className="action-btn delete-btn"
onClick={(e) => handleDeleteImage(e, emoji)}
title="删除"
>
<Trash2 size={16} />
</button>
</div>
)}
<div className="media-info">
<span className="media-name">{emoji.fileName}</span>
</>
) : (
<div className="media-placeholder">
<Smile size={32} />
<span></span>
</div>
)}
<div className="media-info">
<span className="media-name">{emoji.fileName}</span>
</div>
))}
</div>
))}
{!isLoading && emojis.length === 0 && (
<div className="empty-state">
<Smile size={48} strokeWidth={1} />
<p></p>
<p className="hint"></p>
</div>
)}
{!isLoading && emojis.length === 0 && (
<div className="empty-state">
<Smile size={48} strokeWidth={1} />
<p></p>
<p className="hint"></p>
</div>
)}
{emojis.length > 100 && (
<div className="more-hint">
100 {emojis.length}
</div>
)}
</div>
</section>
)}
</div>
{displayedEmojiCount < emojis.length && (
<div className="loading-more">
... ({displayedEmojiCount}/{emojis.length})
</div>
)}
</div>
</>
)}
</>
)
}
+98 -2
View File
@@ -111,12 +111,59 @@
}
}
// 会话类型筛选按钮组
.session-type-filter {
display: flex;
align-items: center;
gap: 4px;
padding: 0 20px;
margin-bottom: 8px;
.type-filter-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 14px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
border-color: rgba(var(--primary-rgb), 0.3);
font-weight: 600;
}
svg {
flex-shrink: 0;
}
}
}
.select-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px 12px;
.select-actions-left {
display: flex;
align-items: center;
gap: 2px;
}
.select-all-btn {
background: none;
border: none;
@@ -131,12 +178,37 @@
}
}
.select-type-btn {
display: flex;
align-items: center;
gap: 3px;
background: none;
border: none;
padding: 5px 10px;
font-size: 12px;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
&:hover {
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
svg {
flex-shrink: 0;
}
}
.selected-count {
font-size: 13px;
color: var(--text-secondary);
padding: 4px 12px;
background: var(--bg-secondary);
border-radius: 12px;
flex-shrink: 0;
}
}
@@ -519,7 +591,7 @@
.export-options {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 12px;
}
@@ -856,15 +928,39 @@
margin: 0 0 8px;
}
.progress-phase {
font-size: 13px;
color: var(--primary);
margin: 0 0 4px;
font-weight: 500;
}
.progress-text {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
margin: 0 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-detail {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 8px;
}
.progress-export-options {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 16px;
padding: 6px 12px;
background: var(--bg-secondary);
border-radius: 6px;
display: inline-flex;
gap: 0;
}
.progress-bar {
height: 6px;
background: var(--bg-secondary);
+165 -29
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { Search, Download, FolderOpen, RefreshCw, Check, FileJson, FileText, Table, Loader2, X, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink, MessageSquare, Users, User } from 'lucide-react'
import { Search, Download, FolderOpen, RefreshCw, Check, FileJson, FileText, Table, Loader2, X, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink, MessageSquare, Users, User, Filter, Image, Video, CircleUserRound, Smile, Mic } from 'lucide-react'
import DateRangePicker from '../components/DateRangePicker'
import { useTitleBarStore } from '../stores/titleBarStore'
import * as configService from '../services/config'
@@ -29,6 +29,10 @@ interface ExportOptions {
startDate: string
endDate: string
exportAvatars: boolean
exportImages: boolean
exportVideos: boolean
exportEmojis: boolean
exportVoices: boolean
}
interface ContactExportOptions {
@@ -49,6 +53,9 @@ interface ExportResult {
error?: string
}
// 会话类型筛选
type SessionTypeFilter = 'all' | 'group' | 'private'
function ExportPage() {
const [activeTab, setActiveTab] = useState<ExportTab>('chat')
const setTitleBarContent = useTitleBarStore(state => state.setRightContent)
@@ -59,12 +66,13 @@ function ExportPage() {
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [sessionTypeFilter, setSessionTypeFilter] = useState<SessionTypeFilter>('all')
const [exportFolder, setExportFolder] = useState<string>('')
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState({
current: 0,
total: 0,
currentName: '',
const [exportProgress, setExportProgress] = useState({
current: 0,
total: 0,
currentName: '',
phase: '',
detail: ''
})
@@ -74,7 +82,11 @@ function ExportPage() {
format: 'chatlab',
startDate: '',
endDate: '',
exportAvatars: true
exportAvatars: true,
exportImages: false,
exportVideos: false,
exportEmojis: false,
exportVoices: false
})
// 通讯录导出状态
@@ -104,12 +116,12 @@ function ExportPage() {
let endDate = ''
if (defaultDateRange > 0) {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
const todayStr = `${year}-${month}-${day}`
if (defaultDateRange === 1) {
// 最近1天 = 今天
startDate = todayStr
@@ -118,11 +130,11 @@ function ExportPage() {
// 其他天数:从 N 天前到今天
const start = new Date(today)
start.setDate(today.getDate() - defaultDateRange + 1)
const startYear = start.getFullYear()
const startMonth = String(start.getMonth() + 1).padStart(2, '0')
const startDay = String(start.getDate()).padStart(2, '0')
startDate = `${startYear}-${startMonth}-${startDay}`
endDate = todayStr
}
@@ -148,11 +160,18 @@ function ExportPage() {
// 监听导出进度
useEffect(() => {
const removeListener = window.electronAPI.export.onProgress((data) => {
// 将 phase 英文映射为中文描述
const phaseMap: Record<string, string> = {
'preparing': '正在准备...',
'exporting': '正在导出消息...',
'writing': '正在写入文件...',
'complete': '导出完成'
}
setExportProgress({
current: data.current || 0,
total: data.total || 0,
currentName: data.currentSession || '',
phase: data.phase || '',
phase: (data.phase ? phaseMap[data.phase] : undefined) || data.phase || '',
detail: data.detail || ''
})
})
@@ -258,18 +277,28 @@ function ExportPage() {
return () => setTitleBarContent(null)
}, [activeTab, setTitleBarContent])
// 聊天会话搜索过滤
// 聊天会话搜索与类型过滤
useEffect(() => {
if (!searchKeyword.trim()) {
setFilteredSessions(sessions)
return
let filtered = sessions
// 类型过滤
if (sessionTypeFilter === 'group') {
filtered = filtered.filter(s => s.username.includes('@chatroom'))
} else if (sessionTypeFilter === 'private') {
filtered = filtered.filter(s => !s.username.includes('@chatroom'))
}
const lower = searchKeyword.toLowerCase()
setFilteredSessions(sessions.filter(s =>
s.displayName?.toLowerCase().includes(lower) ||
s.username.toLowerCase().includes(lower)
))
}, [searchKeyword, sessions])
// 关键词过滤
if (searchKeyword.trim()) {
const lower = searchKeyword.toLowerCase()
filtered = filtered.filter(s =>
s.displayName?.toLowerCase().includes(lower) ||
s.username.toLowerCase().includes(lower)
)
}
setFilteredSessions(filtered)
}, [searchKeyword, sessions, sessionTypeFilter])
// 通讯录搜索过滤
useEffect(() => {
@@ -307,13 +336,29 @@ function ExportPage() {
}
const toggleSelectAll = () => {
if (selectedSessions.size === filteredSessions.length) {
if (selectedSessions.size === filteredSessions.length && filteredSessions.length > 0) {
setSelectedSessions(new Set())
} else {
setSelectedSessions(new Set(filteredSessions.map(s => s.username)))
}
}
// 快捷选择:仅选群聊
const selectOnlyGroups = () => {
const groupUsernames = filteredSessions
.filter(s => s.username.includes('@chatroom'))
.map(s => s.username)
setSelectedSessions(new Set(groupUsernames))
}
// 快捷选择:仅选私聊
const selectOnlyPrivate = () => {
const privateUsernames = filteredSessions
.filter(s => !s.username.includes('@chatroom'))
.map(s => s.username)
setSelectedSessions(new Set(privateUsernames))
}
const toggleContact = (username: string) => {
const newSet = new Set(selectedContacts)
if (newSet.has(username)) {
@@ -374,10 +419,14 @@ function ExportPage() {
const exportOptions = {
format: options.format,
dateRange: (options.startDate && options.endDate) ? {
start: Math.floor(new Date(options.startDate).getTime() / 1000),
start: Math.floor(new Date(options.startDate + 'T00:00:00').getTime() / 1000),
end: Math.floor(new Date(options.endDate + 'T23:59:59').getTime() / 1000)
} : null,
exportAvatars: options.exportAvatars
exportAvatars: options.exportAvatars,
exportImages: options.exportImages,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoices: options.exportVoices
}
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'html') {
@@ -486,10 +535,43 @@ function ExportPage() {
)}
</div>
<div className="select-actions">
<button className="select-all-btn" onClick={toggleSelectAll}>
{selectedSessions.size === filteredSessions.length && filteredSessions.length > 0 ? '取消全选' : '全选'}
<div className="session-type-filter">
<button
className={`type-filter-btn ${sessionTypeFilter === 'all' ? 'active' : ''}`}
onClick={() => setSessionTypeFilter('all')}
>
</button>
<button
className={`type-filter-btn ${sessionTypeFilter === 'group' ? 'active' : ''}`}
onClick={() => setSessionTypeFilter('group')}
>
<Users size={13} />
</button>
<button
className={`type-filter-btn ${sessionTypeFilter === 'private' ? 'active' : ''}`}
onClick={() => setSessionTypeFilter('private')}
>
<User size={13} />
</button>
</div>
<div className="select-actions">
<div className="select-actions-left">
<button className="select-all-btn" onClick={toggleSelectAll}>
{selectedSessions.size === filteredSessions.length && filteredSessions.length > 0 ? '取消全选' : '全选'}
</button>
<button className="select-type-btn" onClick={selectOnlyGroups} title="仅选中列表中的群聊">
<Users size={12} />
</button>
<button className="select-type-btn" onClick={selectOnlyPrivate} title="仅选中列表中的私聊">
<User size={12} />
</button>
</div>
<span className="selected-count"> {selectedSessions.size} </span>
</div>
@@ -578,8 +660,49 @@ function ExportPage() {
onChange={e => setOptions(prev => ({ ...prev, exportAvatars: e.target.checked }))}
/>
<div className="custom-checkbox"></div>
<CircleUserRound size={16} style={{ color: 'var(--text-tertiary)' }} />
<span></span>
</label>
<label className="checkbox-item">
<input
type="checkbox"
checked={options.exportImages}
onChange={e => setOptions(prev => ({ ...prev, exportImages: e.target.checked }))}
/>
<div className="custom-checkbox"></div>
<Image size={16} style={{ color: 'var(--text-tertiary)' }} />
<span></span>
</label>
<label className="checkbox-item">
<input
type="checkbox"
checked={options.exportVideos}
onChange={e => setOptions(prev => ({ ...prev, exportVideos: e.target.checked }))}
/>
<div className="custom-checkbox"></div>
<Video size={16} style={{ color: 'var(--text-tertiary)' }} />
<span></span>
</label>
<label className="checkbox-item">
<input
type="checkbox"
checked={options.exportEmojis}
onChange={e => setOptions(prev => ({ ...prev, exportEmojis: e.target.checked }))}
/>
<div className="custom-checkbox"></div>
<Smile size={16} style={{ color: 'var(--text-tertiary)' }} />
<span></span>
</label>
<label className="checkbox-item">
<input
type="checkbox"
checked={options.exportVoices}
onChange={e => setOptions(prev => ({ ...prev, exportVoices: e.target.checked }))}
/>
<div className="custom-checkbox"></div>
<Mic size={16} style={{ color: 'var(--text-tertiary)' }} />
<span></span>
</label>
</div>
</div>
@@ -824,8 +947,21 @@ function ExportPage() {
</div>
<h3></h3>
{exportProgress.phase && <p className="progress-phase">{exportProgress.phase}</p>}
<p className="progress-text">{exportProgress.currentName || '准备中...'}</p>
{exportProgress.currentName && (
<p className="progress-text">: {exportProgress.currentName}</p>
)}
{exportProgress.detail && <p className="progress-detail">{exportProgress.detail}</p>}
{!exportProgress.currentName && !exportProgress.detail && (
<p className="progress-text">...</p>
)}
<div className="progress-export-options">
<span>: {options.format.toUpperCase()}</span>
{options.exportImages && <span> · </span>}
{options.exportVideos && <span> · </span>}
{options.exportEmojis && <span> · </span>}
{options.exportVoices && <span> · </span>}
{options.exportAvatars && <span> · </span>}
</div>
{exportProgress.total > 0 && (
<>
<div className="progress-bar">
@@ -834,7 +970,7 @@ function ExportPage() {
style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }}
/>
</div>
<p className="progress-count">{exportProgress.current} / {exportProgress.total}</p>
<p className="progress-count">{exportProgress.current} / {exportProgress.total} </p>
</>
)}
</div>
+4
View File
@@ -44,6 +44,10 @@ export interface ElectronAPI {
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
saveFile: (options?: Electron.SaveDialogOptions) => Promise<Electron.SaveDialogReturnValue>
}
file: {
delete: (filePath: string) => Promise<{ success: boolean; error?: string }>
copy: (sourcePath: string, destPath: string) => Promise<{ success: boolean; error?: string }>
}
shell: {
openPath: (path: string) => Promise<string>
openExternal: (url: string) => Promise<void>