chore: merge upstream main into fork main

This commit is contained in:
Jason
2026-04-10 20:45:04 +08:00
62 changed files with 1693 additions and 901 deletions

View File

@@ -107,44 +107,6 @@ function App() {
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null)
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
try {
// 防止在非客户端环境报错,先检查 API 是否存在
if (!window.electronAPI?.app?.checkWayland) return
// 通过 configService 检查是否已经弹过窗
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
if (!hasWarned) {
const isWayland = await window.electronAPI.app.checkWayland()
if (isWayland) {
setShowWaylandWarning(true)
}
}
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
// 只有在协议同意之后并且已经进入主应用流程才检查
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
checkWaylandStatus()
}
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
const handleDismissWaylandWarning = async () => {
try {
// 记录到本地配置中,下次不再提示
await window.electronAPI.config.set('waylandWarningShown', true)
} catch (e) {
console.error('保存 Wayland 提示状态失败:', e)
}
setShowWaylandWarning(false)
}
useEffect(() => {
if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location
@@ -339,6 +301,21 @@ function App() {
}
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
// 监听通知点击导航事件
useEffect(() => {
if (isNotificationWindow) return
const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => {
if (!sessionId) return
// 导航到聊天页面通过URL参数让ChatPage接收sessionId
navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true })
})
return () => {
removeListener?.()
}
}, [navigate, isNotificationWindow])
// 解锁后显示暂存的更新弹窗
useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
@@ -670,33 +647,6 @@ function App() {
</div>
)}
{/*{showWaylandWarning && (*/}
{/* <div className="agreement-overlay">*/}
{/* <div className="agreement-modal">*/}
{/* <div className="agreement-header">*/}
{/* <Shield size={32} />*/}
{/* <h2>环境兼容性提示 (Wayland)</h2>*/}
{/* </div>*/}
{/* <div className="agreement-content">*/}
{/* <div className="agreement-text">*/}
{/* <p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>*/}
{/* <p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>*/}
{/* <p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>*/}
{/* <br />*/}
{/* <p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>*/}
{/* <p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>*/}
{/* <p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>*/}
{/* </div>*/}
{/* </div>*/}
{/* <div className="agreement-footer">*/}
{/* <div className="agreement-actions">*/}
{/* <button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>*/}
{/* </div>*/}
{/* </div>*/}
{/* </div>*/}
{/* </div>*/}
{/*)}*/}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}

View File

@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
format?: string
avatars?: boolean
dateRange?: ExportDateRangeSelection
fileNamingMode?: configService.ExportFileNamingMode
media?: configService.ExportDefaultMediaConfig
voiceAsText?: boolean
excelCompactColumns?: boolean
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] as const
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
{ value: 'classic', label: '简洁模式', desc: '示例私聊_张三兼容旧版' },
{ value: 'date-range', label: '时间范围模式', desc: '示例私聊_张三_20250101-20250331推荐' }
]
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
@@ -56,12 +62,15 @@ export function ExportDefaultsSettingsForm({
layout = 'stacked'
}: ExportDefaultsSettingsFormProps) {
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true,
videos: true,
@@ -76,10 +85,11 @@ export function ExportDefaultsSettingsForm({
useEffect(() => {
let cancelled = false
void (async () => {
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultFileNamingMode(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
@@ -91,6 +101,7 @@ export function ExportDefaultsSettingsForm({
setExportDefaultFormat(savedFormat || 'excel')
setExportDefaultAvatars(savedAvatars ?? true)
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setExportDefaultMedia(savedMedia ?? {
images: true,
videos: true,
@@ -114,15 +125,19 @@ export function ExportDefaultsSettingsForm({
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
setShowExportFileNamingModeSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportExcelColumnsSelect])
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
const notify = (text: string, success = true) => {
onNotify?.(text, success)
@@ -224,6 +239,7 @@ export function ExportDefaultsSettingsForm({
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(false)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(true)
}}
>
@@ -247,6 +263,50 @@ export function ExportDefaultsSettingsForm({
}}
/>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
onClick={() => {
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
setShowExportExcelColumnsSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>
<span className="select-value">{exportFileNamingModeLabel}</span>
<ChevronDown size={16} />
</button>
{showExportFileNamingModeSelect && (
<div className="select-dropdown">
{exportFileNamingModeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFileNamingMode(option.value)
await configService.setExportDefaultFileNamingMode(option.value)
onDefaultsChanged?.({ fileNamingMode: option.value })
notify('已更新导出文件命名方式', true)
setShowExportFileNamingModeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label>Excel </label>
@@ -259,6 +319,7 @@ export function ExportDefaultsSettingsForm({
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>

View File

@@ -1965,6 +1965,10 @@
color: var(--on-primary);
border-radius: 18px 18px 4px 18px;
}
.bubble-body {
align-items: flex-end;
}
}
// 对方发送的消息 - 左侧白色
@@ -1974,6 +1978,10 @@
color: var(--text-primary);
border-radius: 18px 18px 18px 4px;
}
.bubble-body {
align-items: flex-start;
}
}
&.system {
@@ -2038,6 +2046,12 @@
white-space: pre-wrap;
}
// 让文字气泡按内容收缩,不被群昵称行宽度牵连
.message-bubble:not(.system) .bubble-content {
width: fit-content;
max-width: 100%;
}
// 表情包消息
.message-bubble.emoji {
.bubble-content {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useLocation } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { useShallow } from 'zustand/react/shallow'
@@ -1142,6 +1142,7 @@ function ChatPage(props: ChatPageProps) {
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
const navigate = useNavigate()
const location = useLocation()
const {
isConnected,
@@ -5350,6 +5351,19 @@ function ChatPage(props: ChatPageProps) {
selectSessionById
])
// 监听URL参数中的sessionId用于通知点击导航
useEffect(() => {
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
const params = new URLSearchParams(location.search)
const urlSessionId = params.get('sessionId')
if (!urlSessionId) return
if (!isConnected || isConnecting) return
if (currentSessionId === urlSessionId) return
selectSessionById(urlSessionId)
// 选中后清除URL参数避免影响后续用户手动切换会话
navigate('/chat', { replace: true })
}, [standaloneSessionWindow, location.search, isConnected, isConnecting, currentSessionId, selectSessionById, navigate])
useEffect(() => {
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
if (!isConnected || isConnecting) {

View File

@@ -1621,6 +1621,7 @@ function ExportPage() {
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true,
videos: true,
@@ -2270,7 +2271,7 @@ function ExportPage() {
setIsBaseConfigLoading(true)
let isReady = true
try {
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
@@ -2287,6 +2288,7 @@ function ExportPage() {
configService.getExportWriteLayout(),
configService.getExportSessionNamePrefixEnabled(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultFileNamingMode(),
ensureExportCacheScope()
])
@@ -2318,6 +2320,7 @@ function ExportPage() {
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
setTimeRangeSelection(resolvedDefaultDateRange)
@@ -4397,6 +4400,7 @@ function ExportPage() {
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
fileNamingMode: exportDefaultFileNamingMode,
sessionLayout,
sessionNameWithTypePrefix,
dateRange: options.useAllTime
@@ -7089,6 +7093,9 @@ function ExportPage() {
if (patch.dateRange) {
setExportDefaultDateRangeSelection(patch.dateRange)
}
if (patch.fileNamingMode) {
setExportDefaultFileNamingMode(patch.fileNamingMode)
}
if (patch.media) {
const mediaPatch = patch.media
setExportDefaultMedia(mediaPatch)

View File

@@ -1,6 +1,7 @@
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
import { VirtuosoGrid } from 'react-virtuoso'
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
import './ResourcesPage.scss'
type MediaTab = 'image' | 'video'
@@ -35,10 +36,14 @@ type DialogState = {
onConfirm?: (() => void) | null
}
const PAGE_SIZE = 120
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4
const PAGE_SIZE = 96
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 3
const INITIAL_IMAGE_PRELOAD_END = 48
const INITIAL_IMAGE_RESOLVE_END = 12
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
const { className = '', ...rest } = props
@@ -409,7 +414,13 @@ function ResourcesPage() {
}
try {
await window.electronAPI.chat.connect()
if (reset) {
const connectResult = await window.electronAPI.chat.connect()
if (!connectResult.success) {
setError(connectResult.error || '连接数据库失败')
return
}
}
const requestOffset = reset ? 0 : nextOffset
const streamResult = await window.electronAPI.chat.getMediaStream({
sessionId: selectedContact === 'all' ? undefined : selectedContact,
@@ -524,7 +535,6 @@ function ResourcesPage() {
let cancelled = false
const run = async () => {
try {
await window.electronAPI.chat.connect()
const sessionResult = await window.electronAPI.chat.getSessions()
if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) {
const initialNameMap: Record<string, string> = {}
@@ -674,7 +684,10 @@ function ResourcesPage() {
resolvingImageCacheBatchRef.current = true
void (async () => {
try {
const result = await window.electronAPI.image.resolveCacheBatch(payloads, { disableUpdateCheck: true })
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
disableUpdateCheck: true,
allowCacheIndex: false
})
const rows = Array.isArray(result?.rows) ? result.rows : []
const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
@@ -741,7 +754,10 @@ function ResourcesPage() {
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
}
if (payloads.length === 0) return
void window.electronAPI.image.preload(payloads, { allowDecrypt: false })
void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: false
})
}, [displayItems])
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
@@ -813,14 +829,18 @@ function ResourcesPage() {
if (!pending) return
pendingRangeRef.current = null
if (tab === 'image') {
preloadImageCacheRange(pending.start - 8, pending.end + 32)
resolveImageCacheRange(pending.start - 2, pending.end + 8)
preloadImageCacheRange(pending.start - 4, pending.end + 20)
resolveImageCacheRange(pending.start - 1, pending.end + 6)
return
}
resolvePosterRange(pending.start, pending.end)
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
const scheduleRangeResolve = useCallback((start: number, end: number) => {
const previous = pendingRangeRef.current
if (previous && start >= previous.start && end <= previous.end) {
return
}
pendingRangeRef.current = { start, end }
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
@@ -832,8 +852,8 @@ function ResourcesPage() {
useEffect(() => {
if (displayItems.length === 0) return
if (tab === 'image') {
preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80))
resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20))
preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
return
}
resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
@@ -1057,25 +1077,61 @@ function ResourcesPage() {
setBatchBusy(true)
let success = 0
let failed = 0
const previewPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
const taskId = registerBackgroundTask({
sourcePage: 'other',
title: '资源页图片批量解密',
detail: `正在解密图片0/${imageItems.length}`,
progressText: `0 / ${imageItems.length}`,
cancelable: false
})
try {
let completed = 0
const progressStep = Math.max(1, Math.floor(imageItems.length / TASK_PROGRESS_UPDATE_MAX_STEPS))
let lastProgressBucket = 0
let lastProgressUpdateAt = Date.now()
const updateTaskProgress = (force: boolean = false) => {
const now = Date.now()
const bucket = Math.floor(completed / progressStep)
const crossedBucket = bucket !== lastProgressBucket
const intervalReached = now - lastProgressUpdateAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS
if (!force && !crossedBucket && !intervalReached) return
updateBackgroundTask(taskId, {
detail: `正在解密图片(${completed}/${imageItems.length}`,
progressText: `${completed} / ${imageItems.length}`
})
lastProgressBucket = bucket
lastProgressUpdateAt = now
}
for (const item of imageItems) {
if (!item.imageMd5 && !item.imageDatName) continue
if (!item.imageMd5 && !item.imageDatName) {
failed += 1
completed += 1
updateTaskProgress()
continue
}
const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined,
force: true
})
if (!result?.success) continue
success += 1
if (result.localPath) {
const key = getItemKey(item)
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
if (!result?.success) {
failed += 1
} else {
success += 1
if (result.localPath) {
const key = getItemKey(item)
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
}
}
completed += 1
updateTaskProgress()
}
updateTaskProgress(true)
if (Object.keys(previewPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
@@ -1083,8 +1139,17 @@ function ResourcesPage() {
if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
}
setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`)
showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成')
setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
progressText: `成功 ${success} / 失败 ${failed}`
})
} catch (e) {
finishBackgroundTask(taskId, 'failed', {
detail: `资源页图片批量解密失败:${String(e)}`
})
showAlert(`批量解密失败:${String(e)}`, '批量解密失败')
} finally {
setBatchBusy(false)
}

View File

@@ -238,23 +238,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('')
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('')
const [isWayland, setIsWayland] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
if (window.electronAPI?.app?.checkWayland) {
try {
const wayland = await window.electronAPI.app.checkWayland()
setIsWayland(wayland)
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
}
checkWaylandStatus()
}, [])
// 检查 Hello 可用性
useEffect(() => {
setHelloAvailable(isWindows)
@@ -1474,13 +1457,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{
value: 'quote-top' as const,
label: '引用在上',
description: '更接近当前 WeFlow 风格',
successMessage: '已切换为引用在上样式'
},
{
value: 'quote-bottom' as const,
label: '正文在上',
description: '更接近微信 / 密语风格',
successMessage: '已切换为正文在上样式'
}
].map(option => {
@@ -1530,7 +1511,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="quote-layout-card-footer">
<div className="quote-layout-card-title-group">
<span className="quote-layout-card-title">{option.label}</span>
<span className="quote-layout-card-desc">{option.description}</span>
</div>
</div>
</button>
@@ -1672,7 +1652,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"><EFBFBD><EFBFBD><EFBFBD></span>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle">
@@ -1696,11 +1676,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group">
<label></label>
<span className="form-hint"></span>
{isWayland && (
<span className="form-hint" style={{ color: '#ff4d4f', marginTop: '4px', display: 'block' }}>
Wayland
</span>
)}
<div className="custom-select">
<div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
@@ -3676,7 +3651,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="updates-hero-main">
<span className="updates-chip"></span>
<h2>{appVersion || '...'}</h2>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更<EFBFBD><EFBFBD><EFBFBD>'}</p>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更'}</p>
</div>
<div className="updates-hero-action">
{updateInfo?.hasUpdate ? (

View File

@@ -31,6 +31,7 @@ const steps = [
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
]
type SetupStepId = typeof steps[number]['id']
interface WelcomePageProps {
standalone?: boolean
@@ -438,6 +439,48 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
}
const jumpToStep = (stepId: SetupStepId) => {
const targetIndex = steps.findIndex(step => step.id === stepId)
if (targetIndex >= 0) setStepIndex(targetIndex)
}
const validateDbStepBeforeNext = async (): Promise<string | null> => {
if (!dbPath) return '数据库目录步骤未完成:请先选择数据库目录'
if (dbPathValidationError) return `数据库目录步骤配置有误:${dbPathValidationError}`
try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
if (!Array.isArray(wxids) || wxids.length === 0) {
return '数据库目录步骤配置有误:当前目录下未找到可用账号数据(缺少 db_storage请重新选择微信数据目录'
}
} catch (e) {
return `数据库目录步骤配置有误:目录读取失败,请确认该路径可访问(${String(e)}`
}
return null
}
const findConfigIssueBeforeConnect = async (): Promise<{ stepId: SetupStepId; message: string } | null> => {
const dbIssue = await validateDbStepBeforeNext()
if (dbIssue) return { stepId: 'db', message: dbIssue }
let scannedWxids: Array<{ wxid: string }> = []
try {
scannedWxids = await window.electronAPI.dbPath.scanWxids(dbPath)
} catch {
scannedWxids = []
}
if (!wxid) {
return { stepId: 'key', message: '解密密钥步骤未完成:请先选择微信账号 (wxid)' }
}
if (!scannedWxids.some(item => item.wxid === wxid)) {
return { stepId: 'key', message: `解密密钥步骤配置有误:微信账号「${wxid}」不在当前数据库目录中,请重新选择账号` }
}
if (!decryptKey || decryptKey.length !== 64) {
return { stepId: 'key', message: '解密密钥步骤未完成:请填写 64 位解密密钥' }
}
return null
}
const canGoNext = () => {
if (currentStep.id === 'intro') return true
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
@@ -453,7 +496,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
return false
}
const handleNext = () => {
const handleNext = async () => {
if (currentStep.id === 'db') {
const dbStepIssue = await validateDbStepBeforeNext()
if (dbStepIssue) {
setError(dbStepIssue)
return
}
}
if (!canGoNext()) {
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
@@ -473,9 +524,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
const handleConnect = async () => {
if (!dbPath) { setError('请先选择数据库目录'); return }
if (!wxid) { setError('请填写微信ID'); return }
if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return }
const configIssue = await findConfigIssueBeforeConnect()
if (configIssue) {
setError(configIssue.message)
jumpToStep(configIssue.stepId)
return
}
setIsConnecting(true)
setError('')
@@ -484,7 +538,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
try {
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
if (!result.success) {
setError(result.error || 'WCDB 连接失败')
const errorMessage = result.error || 'WCDB 连接失败'
if (errorMessage.includes('-3001')) {
const fallbackIssue = await findConfigIssueBeforeConnect()
if (fallbackIssue) {
setError(fallbackIssue.message)
jumpToStep(fallbackIssue.stepId)
} else {
setError(`数据库目录步骤配置有误:${errorMessage}`)
jumpToStep('db')
}
} else {
setError(errorMessage)
}
setLoading(false)
return
}

View File

@@ -30,6 +30,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_FILE_NAMING_MODE: 'exportDefaultFileNamingMode',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
@@ -114,6 +115,8 @@ export interface ExportDefaultMediaConfig {
files: boolean
}
export type ExportFileNamingMode = 'classic' | 'date-range'
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
export type QuoteLayout = 'quote-top' | 'quote-bottom'
export type UpdateChannel = 'stable' | 'preview' | 'dev'
@@ -434,6 +437,18 @@ export async function setExportDefaultDateRange(range: ExportDefaultDateRangeCon
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
}
// 获取导出默认文件命名方式
export async function getExportDefaultFileNamingMode(): Promise<ExportFileNamingMode | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE)
if (value === 'classic' || value === 'date-range') return value
return null
}
// 设置导出默认文件命名方式
export async function setExportDefaultFileNamingMode(mode: ExportFileNamingMode): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE, mode)
}
// 获取导出默认媒体设置
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)

View File

@@ -1,4 +1,10 @@
import { create } from 'zustand'
import {
finishBackgroundTask,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
export interface BatchImageDecryptState {
isBatchDecrypting: boolean
@@ -8,8 +14,9 @@ export interface BatchImageDecryptState {
result: { success: number; fail: number }
startTime: number
sessionName: string
taskId: string | null
startDecrypt: (total: number, sessionName: string) => void
startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void
updateProgress: (current: number, total: number) => void
finishDecrypt: (success: number, fail: number) => void
setShowToast: (show: boolean) => void
@@ -17,7 +24,26 @@ export interface BatchImageDecryptState {
reset: () => void
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({
const clampProgress = (current: number, total: number): { current: number; total: number } => {
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
const normalizedCurrent = normalizedTotal > 0
? Math.min(normalizedCurrentRaw, normalizedTotal)
: normalizedCurrentRaw
return { current: normalizedCurrent, total: normalizedTotal }
}
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
const calcProgressStep = (total: number): number => {
if (total <= 0) return 1
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, get) => ({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
@@ -25,40 +51,127 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) =>
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
taskId: null,
startDecrypt: (total, sessionName) => set({
isBatchDecrypting: true,
progress: { current: 0, total },
showToast: true,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: Date.now(),
sessionName
}),
startDecrypt: (total, sessionName, sourcePage = 'chat') => {
const previousTaskId = get().taskId
if (previousTaskId) {
taskProgressUpdateMeta.delete(previousTaskId)
finishBackgroundTask(previousTaskId, 'canceled', {
detail: '已被新的批量解密任务替换',
progressText: '已替换'
})
}
updateProgress: (current, total) => set({
progress: { current, total }
}),
const normalizedProgress = clampProgress(0, total)
const normalizedSessionName = String(sessionName || '').trim()
const title = normalizedSessionName
? `图片批量解密(${normalizedSessionName}`
: '图片批量解密'
const taskId = registerBackgroundTask({
sourcePage,
title,
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total}`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
cancelable: false
})
taskProgressUpdateMeta.set(taskId, {
lastAt: Date.now(),
lastBucket: 0,
step: calcProgressStep(normalizedProgress.total)
})
finishDecrypt: (success, fail) => set({
isBatchDecrypting: false,
showToast: false,
showResultToast: true,
result: { success, fail },
startTime: 0
}),
set({
isBatchDecrypting: true,
progress: normalizedProgress,
showToast: true,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: Date.now(),
sessionName: normalizedSessionName,
taskId
})
},
updateProgress: (current, total) => {
const previousProgress = get().progress
const normalizedProgress = clampProgress(current, total)
const taskId = get().taskId
if (taskId) {
const now = Date.now()
const meta = taskProgressUpdateMeta.get(taskId)
const step = meta?.step || calcProgressStep(normalizedProgress.total)
const bucket = Math.floor(normalizedProgress.current / step)
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
const crossedBucket = !meta || bucket !== meta.lastBucket
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
if (crossedBucket || intervalReached || isFinal) {
updateBackgroundTask(taskId, {
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total}`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
})
taskProgressUpdateMeta.set(taskId, {
lastAt: now,
lastBucket: bucket,
step
})
}
}
if (
previousProgress.current !== normalizedProgress.current ||
previousProgress.total !== normalizedProgress.total
) {
set({
progress: normalizedProgress
})
}
},
finishDecrypt: (success, fail) => {
const taskId = get().taskId
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
const status = normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed'
finishBackgroundTask(taskId, status, {
detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
})
}
set({
isBatchDecrypting: false,
showToast: false,
showResultToast: true,
result: { success: normalizedSuccess, fail: normalizedFail },
startTime: 0,
taskId: null
})
},
setShowToast: (show) => set({ showToast: show }),
setShowResultToast: (show) => set({ showResultToast: show }),
reset: () => set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: ''
})
}))
reset: () => {
const taskId = get().taskId
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
finishBackgroundTask(taskId, 'canceled', {
detail: '批量解密任务已重置',
progressText: '已停止'
})
}
set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
taskId: null
})
}
}))

View File

@@ -69,7 +69,6 @@ export interface ElectronAPI {
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
checkWayland: () => Promise<boolean>
}
notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
@@ -78,6 +77,7 @@ export interface ElectronAPI {
ready: () => void
resize: (width: number, height: number) => void
onShow: (callback: (event: any, data: any) => void) => () => void
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
}
log: {
getPath: () => Promise<string>
@@ -403,10 +403,16 @@ export interface ElectronAPI {
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean }
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => Promise<{
success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
@@ -414,7 +420,7 @@ export interface ElectronAPI {
}>
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean }
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
@@ -999,6 +1005,7 @@ export interface ExportOptions {
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
txtColumns?: string[]
fileNamingMode?: 'classic' | 'date-range'
sessionLayout?: 'shared' | 'per-session'
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'