Merge pull request #72 from hicccc77/dev

Dev
This commit is contained in:
xuncha
2026-01-21 21:01:38 +08:00
committed by GitHub
14 changed files with 682 additions and 141 deletions

View File

@@ -13,7 +13,7 @@ import { imagePreloadService } from './services/imagePreloadService'
import { analyticsService } from './services/analyticsService' import { analyticsService } from './services/analyticsService'
import { groupAnalyticsService } from './services/groupAnalyticsService' import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService' import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { KeyService } from './services/keyService' import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService' import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService' import { videoService } from './services/videoService'
@@ -646,8 +646,13 @@ function registerIpcHandlers() {
}) })
// 导出相关 // 导出相关
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
return exportService.exportSessions(sessionIds, outputDir, options) const onProgress = (progress: ExportProgress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', progress)
}
}
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
}) })
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {

View File

@@ -191,7 +191,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
exportSessions: (sessionIds: string[], outputDir: string, options: any) => exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) => exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options) ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
}, },
whisper: { whisper: {

View File

@@ -72,6 +72,7 @@ export interface ExportOptions {
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
sessionLayout?: 'shared' | 'per-session'
} }
interface MediaExportItem { interface MediaExportItem {
@@ -408,14 +409,15 @@ class ExportService {
private async exportMediaForMessage( private async exportMediaForMessage(
msg: any, msg: any,
sessionId: string, sessionId: string,
mediaDir: string, mediaRootDir: string,
mediaRelativePrefix: string,
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
const localType = msg.localType const localType = msg.localType
// 图片消息 // 图片消息
if (localType === 3 && options.exportImages) { if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaDir) const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) { if (result) {
} }
return result return result
@@ -429,13 +431,13 @@ class ExportService {
} }
// 否则导出语音文件 // 否则导出语音文件
if (options.exportVoices) { if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaDir) return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
} }
} }
// 动画表情 // 动画表情
if (localType === 47 && options.exportEmojis) { if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaDir) const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) { if (result) {
} }
return result return result
@@ -447,9 +449,14 @@ class ExportService {
/** /**
* 导出图片文件 * 导出图片文件
*/ */
private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportImage(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const imagesDir = path.join(mediaDir, 'media', 'images') const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
if (!fs.existsSync(imagesDir)) { if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true }) fs.mkdirSync(imagesDir, { recursive: true })
} }
@@ -494,7 +501,7 @@ class ExportService {
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
return { return {
relativePath: `media/images/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image' kind: 'image'
} }
} else if (sourcePath.startsWith('file://')) { } else if (sourcePath.startsWith('file://')) {
@@ -512,7 +519,7 @@ class ExportService {
} }
return { return {
relativePath: `media/images/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image' kind: 'image'
} }
} }
@@ -526,9 +533,14 @@ class ExportService {
/** /**
* 导出语音文件 * 导出语音文件
*/ */
private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportVoice(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const voicesDir = path.join(mediaDir, 'media', 'voices') const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
if (!fs.existsSync(voicesDir)) { if (!fs.existsSync(voicesDir)) {
fs.mkdirSync(voicesDir, { recursive: true }) fs.mkdirSync(voicesDir, { recursive: true })
} }
@@ -540,7 +552,7 @@ class ExportService {
// 如果已存在则跳过 // 如果已存在则跳过
if (fs.existsSync(destPath)) { if (fs.existsSync(destPath)) {
return { return {
relativePath: `media/voices/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice' kind: 'voice'
} }
} }
@@ -556,7 +568,7 @@ class ExportService {
fs.writeFileSync(destPath, wavBuffer) fs.writeFileSync(destPath, wavBuffer)
return { return {
relativePath: `media/voices/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice' kind: 'voice'
} }
} catch (e) { } catch (e) {
@@ -582,9 +594,14 @@ class ExportService {
/** /**
* 导出表情文件 * 导出表情文件
*/ */
private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportEmoji(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const emojisDir = path.join(mediaDir, 'media', 'emojis') const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
if (!fs.existsSync(emojisDir)) { if (!fs.existsSync(emojisDir)) {
fs.mkdirSync(emojisDir, { recursive: true }) fs.mkdirSync(emojisDir, { recursive: true })
} }
@@ -613,7 +630,7 @@ class ExportService {
// 如果已存在则跳过 // 如果已存在则跳过
if (fs.existsSync(destPath)) { if (fs.existsSync(destPath)) {
return { return {
relativePath: `media/emojis/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji' kind: 'emoji'
} }
} }
@@ -621,13 +638,13 @@ class ExportService {
// 下载表情 // 下载表情
if (emojiUrl) { if (emojiUrl) {
const downloaded = await this.downloadFile(emojiUrl, destPath) const downloaded = await this.downloadFile(emojiUrl, destPath)
if (downloaded) { if (downloaded) {
return { return {
relativePath: `media/emojis/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji' kind: 'emoji'
} }
} else { } else {
} }
} }
return null return null
@@ -704,6 +721,22 @@ class ExportService {
return '.jpg' return '.jpg'
} }
private getMediaLayout(outputPath: string, options: ExportOptions): {
exportMediaEnabled: boolean
mediaRootDir: string
mediaRelativePrefix: string
} {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
const outputDir = path.dirname(outputPath)
const outputBaseName = path.basename(outputPath, path.extname(outputPath))
const useSharedMediaLayout = options.sessionLayout === 'shared'
const mediaRelativePrefix = useSharedMediaLayout
? path.posix.join('media', outputBaseName)
: 'media'
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
}
/** /**
* 下载文件 * 下载文件
*/ */
@@ -1128,29 +1161,43 @@ class ExportService {
phase: 'exporting' phase: 'exporting'
}) })
const chatLabMessages: ChatLabMessage[] = [] const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
for (const msg of allMessages) { const mediaCache = new Map<string, MediaExportItem | null>()
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { const chatLabMessages: ChatLabMessage[] = []
platformId: msg.senderUsername, for (const msg of allMessages) {
accountName: msg.senderUsername, const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
groupNickname: undefined platformId: msg.senderUsername,
} accountName: msg.senderUsername,
groupNickname: undefined
}
let content = this.parseMessageContent(msg.content, msg.localType) let content = this.parseMessageContent(msg.content, msg.localType)
// 如果是语音消息且开启了转文字 if (exportMediaEnabled) {
if (msg.localType === 34 && options.exportVoiceAsText) { const mediaKey = `${msg.localType}_${msg.localId}`
content = await this.transcribeVoice(sessionId, String(msg.localId)) if (!mediaCache.has(mediaKey)) {
} const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
}
if (msg.localType === 34 && options.exportVoiceAsText) {
// 如果是语音消息且开启了转文字
content = await this.transcribeVoice(sessionId, String(msg.localId))
}
chatLabMessages.push({ chatLabMessages.push({
sender: msg.senderUsername, sender: msg.senderUsername,
accountName: memberInfo.accountName, accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname, groupNickname: memberInfo.groupNickname,
timestamp: msg.createTime, timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content), type: this.convertMessageType(msg.localType, msg.content),
content: content content: content
}) })
} }
const avatarMap = options.exportAvatars const avatarMap = options.exportAvatars
? await this.exportAvatars( ? await this.exportAvatars(
@@ -1243,22 +1290,41 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages: any[] = [] const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const allMessages: any[] = []
for (const msg of collected.rows) { for (const msg of collected.rows) {
const senderInfo = await this.getContactInfo(msg.senderUsername) const senderInfo = await this.getContactInfo(msg.senderUsername)
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '') const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
const source = sourceMatch ? sourceMatch[0] : '' const source = sourceMatch ? sourceMatch[0] : ''
let content = this.parseMessageContent(msg.content, msg.localType) let content = this.parseMessageContent(msg.content, msg.localType)
if (msg.localType === 34 && options.exportVoiceAsText) { let mediaItem: MediaExportItem | null = null
content = await this.transcribeVoice(sessionId, String(msg.localId)) if (exportMediaEnabled) {
} const mediaKey = `${msg.localType}_${msg.localId}`
if (mediaCache.has(mediaKey)) {
mediaItem = mediaCache.get(mediaKey) || null
} else {
mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
}
if (mediaItem) {
content = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
content = await this.transcribeVoice(sessionId, String(msg.localId))
}
allMessages.push({ allMessages.push({
localId: allMessages.length + 1, localId: allMessages.length + 1,
createTime: msg.createTime, createTime: msg.createTime,
formattedTime: this.formatTimestamp(msg.createTime), formattedTime: this.formatTimestamp(msg.createTime),
type: this.getMessageTypeName(msg.localType), type: this.getMessageTypeName(msg.localType),
localType: msg.localType, localType: msg.localType,
@@ -1482,8 +1548,7 @@ class ExportService {
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
// 媒体导出设置 // 媒体导出设置
const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出
// 媒体导出缓存 // 媒体导出缓存
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
@@ -1498,7 +1563,7 @@ class ExportService {
if (mediaCache.has(mediaKey)) { if (mediaCache.has(mediaKey)) {
mediaItem = mediaCache.get(mediaKey) || null mediaItem = mediaCache.get(mediaKey) || null
} else { } else {
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, { mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
@@ -1656,9 +1721,15 @@ class ExportService {
fs.mkdirSync(outputDir, { recursive: true }) fs.mkdirSync(outputDir, { recursive: true })
} }
for (let i = 0; i < sessionIds.length; i++) { const exportMediaEnabled = options.exportMedia === true &&
const sessionId = sessionIds[i] Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
const sessionInfo = await this.getContactInfo(sessionId) const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session')
: 'shared'
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({ onProgress?.({
current: i + 1, current: i + 1,
@@ -1667,13 +1738,13 @@ class ExportService {
phase: 'exporting' phase: 'exporting'
}) })
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
const useSessionFolder = sessionLayout === 'per-session'
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
// 为每个会话创建单独的文件夹 if (useSessionFolder && !fs.existsSync(sessionDir)) {
const sessionDir = path.join(outputDir, safeName) fs.mkdirSync(sessionDir, { recursive: true })
if (!fs.existsSync(sessionDir)) { }
fs.mkdirSync(sessionDir, { recursive: true })
}
let ext = '.json' let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl' if (options.format === 'chatlab-jsonl') ext = '.jsonl'

View File

@@ -33,6 +33,7 @@ export class KeyService {
private ReadProcessMemory: any = null private ReadProcessMemory: any = null
private MEMORY_BASIC_INFORMATION: any = null private MEMORY_BASIC_INFORMATION: any = null
private TerminateProcess: any = null private TerminateProcess: any = null
private QueryFullProcessImageNameW: any = null
// User32 // User32
private EnumWindows: any = null private EnumWindows: any = null
@@ -194,6 +195,7 @@ export class KeyService {
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32']) this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32']) this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64']) this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))]) this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
@@ -310,7 +312,46 @@ export class KeyService {
} }
} }
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
try {
const sizeBuf = Buffer.alloc(4)
sizeBuf.writeUInt32LE(1024, 0)
const pathBuf = Buffer.alloc(1024 * 2)
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
if (ret) {
const len = sizeBuf.readUInt32LE(0)
return pathBuf.toString('ucs2', 0, len * 2)
}
return null
} catch (e) {
console.error('获取进程路径失败:', e)
return null
} finally {
this.CloseHandle(hProcess)
}
}
private async findWeChatInstallPath(): Promise<string | null> { private async findWeChatInstallPath(): Promise<string | null> {
// 0. 优先尝试获取正在运行的微信进程路径
try {
const pid = await this.findWeChatPid()
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) {
console.log('发现正在运行的微信进程,使用路径:', runPath)
return runPath
}
}
} catch (e) {
console.error('尝试获取运行中微信路径失败:', e)
}
// 1. Registry - Uninstall Keys // 1. Registry - Uninstall Keys
const uninstallKeys = [ const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',

View File

@@ -1,6 +1,12 @@
import { join, dirname, basename } from 'path' import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
export function getLastDllInitError(): string | null {
return lastDllInitError
}
export class WcdbCore { export class WcdbCore {
private resourcesPath: string | null = null private resourcesPath: string | null = null
private userDataPath: string | null = null private userDataPath: string | null = null
@@ -208,6 +214,31 @@ export class WcdbCore {
return false return false
} }
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
const dllDir = dirname(dllPath)
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
try {
this.koffi.load(wcdbCorePath)
this.writeLog('预加载 WCDB.dll 成功')
} catch (e) {
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
}
}
const sdl2Path = join(dllDir, 'SDL2.dll')
if (existsSync(sdl2Path)) {
try {
this.koffi.load(sdl2Path)
this.writeLog('预加载 SDL2.dll 成功')
} catch (e) {
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
}
}
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
// 定义类型 // 定义类型
@@ -362,9 +393,20 @@ export class WcdbCore {
} }
this.initialized = true this.initialized = true
lastDllInitError = null
return true return true
} catch (e) { } catch (e) {
console.error('WCDB 初始化异常:', e) const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg
// 检查是否是常见的 VC++ 运行时缺失错误
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
errorMsg.includes('The specified module could not be found')) {
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
}
return false return false
} }
} }
@@ -391,7 +433,9 @@ export class WcdbCore {
if (!this.initialized) { if (!this.initialized) {
const initOk = await this.initialize() const initOk = await this.initialize()
if (!initOk) { if (!initOk) {
return { success: false, error: 'WCDB 初始化失败' } // 返回更详细的错误信息,帮助用户诊断问题
const detailedError = lastDllInitError || 'WCDB 初始化失败'
return { success: false, error: detailedError }
} }
} }

View File

@@ -58,12 +58,24 @@ export class WcdbService {
}) })
this.worker.on('error', (err) => { this.worker.on('error', (err) => {
// Worker error // Worker 发生错误,需要 reject 所有 pending promises
console.error('WCDB Worker 错误:', err)
const errorMsg = err instanceof Error ? err.message : String(err)
for (const [id, p] of this.pending) {
p.reject(new Error(`Worker 错误: ${errorMsg}`))
}
this.pending.clear()
}) })
this.worker.on('exit', (code) => { this.worker.on('exit', (code) => {
// Worker 退出,需要 reject 所有 pending promises
if (code !== 0) { if (code !== 0) {
// Worker exited with error console.error('WCDB Worker 异常退出,退出码:', code)
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
for (const [id, p] of this.pending) {
p.reject(new Error(errorMsg))
}
this.pending.clear()
} }
this.worker = null this.worker = null
}) })

6
package-lock.json generated
View File

@@ -8537,12 +8537,6 @@
"sherpa-onnx-win-x64": "^1.12.23" "sherpa-onnx-win-x64": "^1.12.23"
} }
}, },
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
"optional": true
},
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": {
"optional": true
},
"node_modules/sherpa-onnx-win-ia32": { "node_modules/sherpa-onnx-win-ia32": {
"version": "1.12.23", "version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz", "resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",

View File

@@ -202,10 +202,22 @@ function App() {
} }
} else { } else {
console.log('自动连接失败:', result.error) console.log('自动连接失败:', result.error)
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置
const errorMsg = result.error || ''
if (errorMsg.includes('Visual C++') ||
errorMsg.includes('DLL') ||
errorMsg.includes('Worker') ||
errorMsg.includes('126') ||
errorMsg.includes('模块')) {
console.warn('检测到可能的运行时依赖问题:', errorMsg)
// 不清除配置,让用户安装 VC++ 后重试
}
} }
} }
} catch (e) { } catch (e) {
console.error('自动连接出错:', e) console.error('自动连接出错:', e)
// 捕获异常但不清除配置,防止循环重新引导
} }
} }

View File

@@ -602,6 +602,87 @@
} }
} }
.export-layout-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
text-align: center;
width: min(520px, 90vw);
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.layout-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
.layout-options {
display: grid;
gap: 12px;
}
.layout-option-btn {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
&.primary {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
}
.layout-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.layout-desc {
font-size: 12px;
color: var(--text-tertiary);
}
}
.layout-actions {
margin-top: 18px;
display: flex;
justify-content: center;
}
.layout-cancel-btn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
}
}
.export-result-modal { .export-result-modal {
background: var(--card-bg); background: var(--card-bg);
padding: 32px 40px; padding: 32px 40px;
@@ -1056,4 +1137,4 @@
input:checked + .slider::before { input:checked + .slider::before {
transform: translateX(20px); transform: translateX(20px);
} }
} }

View File

@@ -31,6 +31,8 @@ interface ExportResult {
error?: string error?: string
} }
type SessionLayout = 'shared' | 'per-session'
function ExportPage() { function ExportPage() {
const [sessions, setSessions] = useState<ChatSession[]>([]) const [sessions, setSessions] = useState<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]) const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
@@ -44,6 +46,7 @@ function ExportPage() {
const [showDatePicker, setShowDatePicker] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date()) const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'excel', format: 'excel',
@@ -154,6 +157,19 @@ function ExportPage() {
loadExportDefaults() loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults]) }, [loadSessions, loadExportPath, loadExportDefaults])
useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
setExportProgress({
current: payload.current,
total: payload.total,
currentName: payload.currentSession
})
})
return () => {
removeListener?.()
}
}, [])
useEffect(() => { useEffect(() => {
if (!searchKeyword.trim()) { if (!searchKeyword.trim()) {
setFilteredSessions(sessions) setFilteredSessions(sessions)
@@ -199,7 +215,7 @@ function ExportPage() {
} }
} }
const startExport = async () => { const runExport = async (sessionLayout: SessionLayout) => {
if (selectedSessions.size === 0 || !exportFolder) return if (selectedSessions.size === 0 || !exportFolder) return
setIsExporting(true) setIsExporting(true)
@@ -215,11 +231,12 @@ function ExportPage() {
exportImages: options.exportMedia && options.exportImages, exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices, exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis, exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(options.dateRange.start.getTime() / 1000),
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息 // ?????????????????????????????????23:59:59,??????????????????????????????
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null } : null
} }
@@ -232,16 +249,28 @@ function ExportPage() {
) )
setExportResult(result) setExportResult(result)
} else { } else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` }) setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` })
} }
} catch (e) { } catch (e) {
console.error('导出失败:', e) console.error('????????????:', e)
setExportResult({ success: false, error: String(e) }) setExportResult({ success: false, error: String(e) })
} finally { } finally {
setIsExporting(false) setIsExporting(false)
} }
} }
const startExport = () => {
if (selectedSessions.size === 0 || !exportFolder) return
if (options.exportMedia && selectedSessions.size > 1) {
setShowMediaLayoutPrompt(true)
return
}
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
runExport(layout)
}
const getDaysInMonth = (date: Date) => { const getDaysInMonth = (date: Date) => {
const year = date.getFullYear() const year = date.getFullYear()
const month = date.getMonth() const month = date.getMonth()
@@ -600,6 +629,43 @@ function ExportPage() {
</div> </div>
</div> </div>
{/* 媒体导出布局选择弹窗 */}
{showMediaLayoutPrompt && (
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<p className="layout-subtitle"></p>
<div className="layout-options">
<button
className="layout-option-btn primary"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('shared')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"> media </span>
</button>
<button
className="layout-option-btn"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('per-session')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"></span>
</button>
</div>
<div className="layout-actions">
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */} {/* 导出进度弹窗 */}
{isExporting && ( {isExporting && (
<div className="export-overlay"> <div className="export-overlay">

View File

@@ -221,6 +221,100 @@
} }
} }
.select-field {
position: relative;
margin-bottom: 10px;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 320px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.input-with-toggle { .input-with-toggle {
position: relative; position: relative;
display: flex; display: flex;
@@ -1096,13 +1190,15 @@
left: 0; left: 0;
right: 0; right: 0;
margin-top: 4px; margin-top: 4px;
background: var(--bg-secondary); background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100; z-index: 100;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
} }
.wxid-option { .wxid-option {
@@ -1216,4 +1312,4 @@
border-top: 1px solid var(--border-primary); border-top: 1px solid var(--border-primary);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }

View File

@@ -41,6 +41,12 @@ function SettingsPage() {
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([]) const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false) const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef<HTMLDivElement>(null) const wxidDropdownRef = useRef<HTMLDivElement>(null)
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false) const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base') const [whisperModelName, setWhisperModelName] = useState('base')
@@ -85,13 +91,23 @@ function SettingsPage() {
// 点击外部关闭下拉框 // 点击外部关闭下拉框
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) { const target = e.target as Node
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
setShowWxidSelect(false) setShowWxidSelect(false)
} }
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
setShowExportFormatSelect(false)
}
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
setShowExportDateRangeSelect(false)
}
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect]) }, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -863,48 +879,114 @@ function SettingsPage() {
</div> </div>
) )
const renderExportTab = () => ( const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
]
const exportDateRangeOptions = [
{ value: 'today', label: '今天' },
{ value: '7d', label: '最近7天' },
{ value: '30d', label: '最近30天' },
{ value: '90d', label: '最近90天' },
{ value: 'all', label: '全部时间' }
]
const exportExcelColumnOptions = [
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
]
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
return options.find((option) => option.value === value)?.label ?? value
}
const renderExportTab = () => {
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
return (
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>
<select <div className="select-field" ref={exportFormatDropdownRef}>
value={exportDefaultFormat} <button
onChange={async (e) => { type="button"
const value = e.target.value className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
setExportDefaultFormat(value) onClick={() => {
await configService.setExportDefaultFormat(value) setShowExportFormatSelect(!showExportFormatSelect)
showMessage('已更新导出格式默认值', true) setShowExportDateRangeSelect(false)
}} setShowExportExcelColumnsSelect(false)
> }}
<option value="excel">Excel</option> >
<option value="chatlab">ChatLab</option> <span className="select-value">{exportFormatLabel}</span>
<option value="chatlab-jsonl">ChatLab JSONL</option> <ChevronDown size={16} />
<option value="json">JSON</option> </button>
<option value="html">HTML</option> {showExportFormatSelect && (
<option value="txt">TXT</option> <div className="select-dropdown">
<option value="sql">PostgreSQL</option> {exportFormatOptions.map((option) => (
</select> <button
key={option.value}
type="button"
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFormat(option.value)
await configService.setExportDefaultFormat(option.value)
showMessage('已更新导出格式默认值', true)
setShowExportFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>
<select <div className="select-field" ref={exportDateRangeDropdownRef}>
value={exportDefaultDateRange} <button
onChange={async (e) => { type="button"
const value = e.target.value className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
setExportDefaultDateRange(value) onClick={() => {
await configService.setExportDefaultDateRange(value) setShowExportDateRangeSelect(!showExportDateRangeSelect)
showMessage('已更新默认导出时间范围', true) setShowExportFormatSelect(false)
}} setShowExportExcelColumnsSelect(false)
> }}
<option value="today"></option> >
<option value="7d">7</option> <span className="select-value">{exportDateRangeLabel}</span>
<option value="30d">30</option> <ChevronDown size={16} />
<option value="90d">90</option> </button>
<option value="all"></option> {showExportDateRangeSelect && (
</select> <div className="select-dropdown">
{exportDateRangeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultDateRange(option.value)
await configService.setExportDefaultDateRange(option.value)
showMessage('已更新默认导出时间范围', true)
setShowExportDateRangeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -956,21 +1038,45 @@ function SettingsPage() {
<div className="form-group"> <div className="form-group">
<label>Excel </label> <label>Excel </label>
<span className="form-hint"> Excel </span> <span className="form-hint"> Excel </span>
<select <div className="select-field" ref={exportExcelColumnsDropdownRef}>
value={exportDefaultExcelCompactColumns ? 'compact' : 'full'} <button
onChange={async (e) => { type="button"
const compact = e.target.value === 'compact' className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
setExportDefaultExcelCompactColumns(compact) onClick={() => {
await configService.setExportDefaultExcelCompactColumns(compact) setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
showMessage(compact ? '已启用精简列' : '已启用完整列', true) setShowExportFormatSelect(false)
}} setShowExportDateRangeSelect(false)
> }}
<option value="compact"></option> >
<option value="full">/ID/</option> <span className="select-value">{exportExcelColumnsLabel}</span>
</select> <ChevronDown size={16} />
</button>
{showExportExcelColumnsSelect && (
<div className="select-dropdown">
{exportExcelColumnOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
onClick={async () => {
const compact = option.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div> </div>
</div> </div>
) )
}
const renderCacheTab = () => ( const renderCacheTab = () => (
<div className="tab-content"> <div className="tab-content">
<p className="section-desc"></p> <p className="section-desc"></p>

View File

@@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<FolderOpen size={16} /> <FolderOpen size={16} />
</button> </button>
</div> </div>
<div className="field-hint"> xwechat_files </div> <div className="field-hint">--</div>
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div> <div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div>
</div> </div>
)} )}
@@ -507,7 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>} {dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
<div className="field-hint"></div> <div className="field-hint"><span style={{color: 'red'}}>hook安装成功</span></div>
</div> </div>
)} )}
@@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button> </button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>} {imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
{isFetchingImageKey && <div className="field-hint status-text">...</div>} {isFetchingImageKey && <div className="field-hint status-text">...</div>}
</div> </div>
)} )}

View File

@@ -314,6 +314,7 @@ export interface ElectronAPI {
success: boolean success: boolean
error?: string error?: string
}> }>
onProgress: (callback: (payload: ExportProgress) => void) => () => void
} }
whisper: { whisper: {
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
@@ -332,6 +333,14 @@ export interface ExportOptions {
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
sessionLayout?: 'shared' | 'per-session'
}
export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
} }
export interface WxidInfo { export interface WxidInfo {