Merge pull request #143 from hicccc77/dev

Dev
This commit is contained in:
cc
2026-01-30 23:49:43 +08:00
committed by GitHub
23 changed files with 510 additions and 816 deletions

View File

@@ -20,6 +20,7 @@ import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService' import { videoService } from './services/videoService'
import { snsService } from './services/snsService' import { snsService } from './services/snsService'
import { contactExportService } from './services/contactExportService' import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService'
// 配置自动更新 // 配置自动更新
@@ -798,6 +799,17 @@ function registerIpcHandlers() {
return true return true
}) })
// Windows Hello
ipcMain.handle('auth:hello', async (event, message?: string) => {
// 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致
// 如果主窗口不存在(极其罕见),则回退到调用者窗口
const targetWin = (mainWindow && !mainWindow.isDestroyed())
? mainWindow
: (BrowserWindow.fromWebContents(event.sender) || undefined)
return windowsHelloService.verify(message, targetWin)
})
// 导出相关 // 导出相关
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
const onProgress = (progress: ExportProgress) => { const onProgress = (progress: ExportProgress) => {

24
electron/nodert.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare module '@nodert-win10-rs4/windows.security.credentials.ui' {
export enum UserConsentVerificationResult {
Verified = 0,
DeviceNotPresent = 1,
NotConfiguredForUser = 2,
DisabledByPolicy = 3,
DeviceBusy = 4,
RetriesExhausted = 5,
Canceled = 6
}
export enum UserConsentVerifierAvailability {
Available = 0,
DeviceNotPresent = 1,
NotConfiguredForUser = 2,
DisabledByPolicy = 3,
DeviceBusy = 4
}
export class UserConsentVerifier {
static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void;
static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void;
}
}

View File

@@ -9,6 +9,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
clear: () => ipcRenderer.invoke('config:clear') clear: () => ipcRenderer.invoke('config:clear')
}, },
// 认证
auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
},
// 对话框 // 对话框
dialog: { dialog: {

View File

@@ -73,6 +73,7 @@ export interface ExportOptions {
exportAvatars?: boolean exportAvatars?: boolean
exportImages?: boolean exportImages?: boolean
exportVoices?: boolean exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
@@ -186,6 +187,20 @@ class ExportService {
return info return info
} }
private async preloadContacts(
usernames: Iterable<string>,
cache: Map<string, { success: boolean; contact?: any; error?: string }>,
limit = 8
): Promise<void> {
const unique = Array.from(new Set(Array.from(usernames).filter(Boolean)))
if (unique.length === 0) return
await parallelLimit(unique, limit, async (username) => {
if (cache.has(username)) return
const result = await wcdbService.getContact(username)
cache.set(username, result)
})
}
/** /**
* 解析 ext_buffer 二进制数据,提取群成员的群昵称 * 解析 ext_buffer 二进制数据,提取群成员的群昵称
* ext_buffer 包含类似 protobuf 编码的数据,格式示例: * ext_buffer 包含类似 protobuf 编码的数据,格式示例:
@@ -859,10 +874,10 @@ class ExportService {
options: { options: {
exportImages?: boolean exportImages?: boolean
exportVoices?: boolean exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
includeVoiceWithTranscript?: boolean includeVoiceWithTranscript?: boolean
exportVideos?: boolean
} }
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
const localType = msg.localType const localType = msg.localType
@@ -877,8 +892,7 @@ class ExportService {
// 语音消息 // 语音消息
if (localType === 34) { if (localType === 34) {
const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText if (options.exportVoices) {
if (shouldKeepVoiceFile && options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
} }
if (options.exportVoiceAsText) { if (options.exportVoiceAsText) {
@@ -1233,7 +1247,7 @@ class ExportService {
mediaRelativePrefix: string mediaRelativePrefix: string
} { } {
const exportMediaEnabled = options.exportMedia === true && const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis) Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const outputDir = path.dirname(outputPath) const outputDir = path.dirname(outputPath)
const outputBaseName = path.basename(outputPath, path.extname(outputPath)) const outputBaseName = path.basename(outputPath, path.extname(outputPath))
const useSharedMediaLayout = options.sessionLayout === 'shared' const useSharedMediaLayout = options.sessionLayout === 'shared'
@@ -1681,10 +1695,6 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows const allMessages = collected.rows
@@ -1693,6 +1703,14 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.ensureVoiceModel(onProgress)
}
if (isGroup) { if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
} }
@@ -1707,7 +1725,8 @@ class ExportService {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || // 图片 return (t === 3 && options.exportImages) || // 图片
(t === 47 && options.exportEmojis) || // 表情 (t === 47 && options.exportEmojis) || // 表情
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字) (t === 43 && options.exportVideos) || // 视频
(t === 34 && options.exportVoices) // 语音文件
}) })
: [] : []
@@ -1729,6 +1748,7 @@ class ExportService {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText exportVoiceAsText: options.exportVoiceAsText
}) })
@@ -1738,10 +1758,6 @@ class ExportService {
} }
// ========== 阶段2并行语音转文字 ========== // ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>() const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) { if (voiceMessages.length > 0) {
@@ -1895,10 +1911,6 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
@@ -1906,6 +1918,21 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
for (const msg of collected.rows) {
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.preloadContacts(senderUsernames, contactCache)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ========== // ========== 阶段1并行导出媒体文件 ==========
@@ -1914,7 +1941,8 @@ class ExportService {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) (t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices)
}) })
: [] : []
@@ -1935,6 +1963,7 @@ class ExportService {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText exportVoiceAsText: options.exportVoiceAsText
}) })
@@ -1944,10 +1973,6 @@ class ExportService {
} }
// ========== 阶段2并行语音转文字 ========== // ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>() const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) { if (voiceMessages.length > 0) {
@@ -1988,10 +2013,10 @@ class ExportService {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey) const mediaItem = mediaCache.get(mediaKey)
if (mediaItem) { if (msg.localType === 34 && options.exportVoiceAsText) {
content = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
content = mediaItem.relativePath
} else { } else {
content = this.parseMessageContent(msg.content, msg.localType) content = this.parseMessageContent(msg.content, msg.localType)
} }
@@ -2156,10 +2181,6 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
@@ -2167,6 +2188,21 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
for (const msg of collected.rows) {
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.preloadContacts(senderUsernames, contactCache)
onProgress?.({ onProgress?.({
current: 30, current: 30,
total: 100, total: 100,
@@ -2297,7 +2333,8 @@ class ExportService {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) (t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices)
}) })
: [] : []
@@ -2318,6 +2355,7 @@ class ExportService {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText exportVoiceAsText: options.exportVoiceAsText
}) })
@@ -2327,10 +2365,6 @@ class ExportService {
} }
// ========== 并行预处理:语音转文字 ========== // ========== 并行预处理:语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>() const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) { if (voiceMessages.length > 0) {
@@ -2416,13 +2450,21 @@ class ExportService {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey) const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|| this.formatPlainExportContent( const contentValue = shouldUseTranscript
? this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
options, options,
voiceTranscriptMap.get(msg.localId) voiceTranscriptMap.get(msg.localId)
) )
: (mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
))
// 调试日志 // 调试日志
if (msg.localType === 3 || msg.localType === 47) { if (msg.localType === 3 || msg.localType === 47) {
@@ -2549,6 +2591,16 @@ class ExportService {
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({ onProgress?.({
current: 0, current: 0,
total: 100, total: 100,
@@ -2556,10 +2608,6 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
@@ -2567,6 +2615,21 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
for (const msg of collected.rows) {
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.preloadContacts(senderUsernames, contactCache)
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
@@ -2575,7 +2638,8 @@ class ExportService {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) (t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices)
}) })
: [] : []
@@ -2596,6 +2660,7 @@ class ExportService {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText exportVoiceAsText: options.exportVoiceAsText
}) })
@@ -2604,9 +2669,6 @@ class ExportService {
}) })
} }
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>() const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) { if (voiceMessages.length > 0) {
@@ -2637,13 +2699,21 @@ class ExportService {
const msg = sortedMessages[i] const msg = sortedMessages[i]
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey) const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|| this.formatPlainExportContent( const contentValue = shouldUseTranscript
? this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
options, options,
voiceTranscriptMap.get(msg.localId) voiceTranscriptMap.get(msg.localId)
) )
: (mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
))
let senderRole: string let senderRole: string
let senderWxid: string let senderWxid: string
@@ -2763,7 +2833,7 @@ class ExportService {
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices) || (t === 34 && options.exportVoices) ||
t === 43 (t === 43 && options.exportVideos)
}) })
: [] : []
@@ -2787,7 +2857,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVoiceWithTranscript: true, includeVoiceWithTranscript: true,
exportVideos: true exportVideos: options.exportVideos
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
} }
@@ -3094,7 +3164,7 @@ class ExportService {
} }
const exportMediaEnabled = options.exportMedia === true && const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis) Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const sessionLayout = exportMediaEnabled const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session') ? (options.sessionLayout ?? 'per-session')
: 'shared' : 'shared'

View File

@@ -57,6 +57,7 @@ export class WcdbCore {
private wcdbGetDbStatus: any = null private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null private wcdbGetVoiceData: any = null
private wcdbGetSnsTimeline: any = null private wcdbGetSnsTimeline: any = null
private wcdbVerifyUser: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map() private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null private logTimer: NodeJS.Timeout | null = null
@@ -259,24 +260,24 @@ export class WcdbCore {
let protectionOk = false let protectionOk = false
for (const resPath of resourcePaths) { for (const resPath of resourcePaths) {
try { try {
console.log(`[WCDB] 尝试 InitProtection: ${resPath}`) // console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
protectionOk = this.wcdbInitProtection(resPath) protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) { if (protectionOk) {
console.log(`[WCDB] InitProtection 成功: ${resPath}`) // console.log(`[WCDB] InitProtection 成功: ${resPath}`)
break break
} }
} catch (e) { } catch (e) {
console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) // console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
} }
} }
if (!protectionOk) { if (!protectionOk) {
console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') // console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
this.writeLog('InitProtection 失败,继续运行') // this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行 // 不返回 false允许继续运行
} }
} catch (e) { } catch (e) {
console.warn('InitProtection symbol not found:', e) // console.warn('InitProtection symbol not found:', e)
} }
// 定义类型 // 定义类型
@@ -430,6 +431,13 @@ export class WcdbCore {
this.wcdbGetSnsTimeline = null this.wcdbGetSnsTimeline = null
} }
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
try {
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
} catch {
this.wcdbVerifyUser = null
}
// 初始化 // 初始化
const initResult = this.wcdbInit() const initResult = this.wcdbInit()
if (initResult !== 0) { if (initResult !== 0) {
@@ -1434,6 +1442,39 @@ export class WcdbCore {
} }
} }
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB 初始化失败' }
}
if (!this.wcdbVerifyUser) {
return { success: false, error: 'Binding not found: VerifyUser' }
}
return new Promise((resolve) => {
try {
// Allocate buffer for result JSON
const maxLen = 1024
const outBuf = Buffer.alloc(maxLen)
// Call native function
const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0)
this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen)
// Parse result
const jsonStr = this.koffi.decode(outBuf, 'char', -1)
const result = JSON.parse(jsonStr)
resolve(result)
} catch (e) {
resolve({ success: false, error: String(e) })
}
})
}
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' } if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }

View File

@@ -369,6 +369,13 @@ export class WcdbService {
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime }) return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
} }
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('verifyUser', { message, hwnd })
}
} }
export const wcdbService = new WcdbService() export const wcdbService = new WcdbService()

View File

@@ -0,0 +1,32 @@
import { wcdbService } from './wcdbService'
import { BrowserWindow } from 'electron'
export class WindowsHelloService {
private verificationPromise: Promise<{ success: boolean; error?: string }> | null = null
/**
* 验证 Windows Hello
* @param message 提示信息
*/
async verify(message: string = '请验证您的身份以解锁 WeFlow', targetWindow?: BrowserWindow): Promise<{ success: boolean; error?: string }> {
// Prevent concurrent verification requests
if (this.verificationPromise) {
return this.verificationPromise
}
// 获取窗口句柄: 优先使用传入的窗口,否则尝试获取焦点窗口,最后兜底主窗口
const window = targetWindow || BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
const hwndBuffer = window?.getNativeWindowHandle()
// Convert buffer to int string for transport
const hwndStr = hwndBuffer ? BigInt('0x' + hwndBuffer.toString('hex')).toString() : undefined
this.verificationPromise = wcdbService.verifyUser(message, hwndStr)
.finally(() => {
this.verificationPromise = null
})
return this.verificationPromise
}
}
export const windowsHelloService = new WindowsHelloService()

View File

@@ -119,6 +119,9 @@ if (parentPort) {
case 'getSnsTimeline': case 'getSnsTimeline':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break break
case 'verifyUser':
result = await core.verifyUser(payload.message, payload.hwnd)
break
default: default:
result = { success: false, error: `Unknown method: ${type}` } result = { success: false, error: `Unknown method: ${type}` }
} }

21
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.4.2",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@nodert-win10-rs4/windows.security.credentials.ui": "^0.4.4",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
@@ -1948,6 +1949,16 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@nodert-win10-rs4/windows.security.credentials.ui": {
"version": "0.4.4",
"resolved": "https://registry.npmmirror.com/@nodert-win10-rs4/windows.security.credentials.ui/-/windows.security.credentials.ui-0.4.4.tgz",
"integrity": "sha512-P+EsJw5MCQXTxp7mwXfNDvIzIYsB6ple+HNg01QjPWg/PJfAodPuxL6XM7l0sPtYHsDYnfnvoefZMdZRa2Z1ig==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"nan": "latest"
}
},
"node_modules/@npmcli/agent": { "node_modules/@npmcli/agent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/@npmcli/agent/-/agent-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/@npmcli/agent/-/agent-3.0.0.tgz",
@@ -7380,6 +7391,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmmirror.com/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",

View File

@@ -1,13 +1,13 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.4.2",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
"//": "二改不应改变此处的作者与应用信息", "//": "二改不应改变此处的作者与应用信息",
"scripts": { "scripts": {
"postinstall": "echo 'No native modules to rebuild'", "postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'", "rebuild": "electron-rebuild",
"dev": "vite", "dev": "vite",
"build": "tsc && vite build && electron-builder", "build": "tsc && vite build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
@@ -15,6 +15,7 @@
"electron:build": "npm run build" "electron:build": "npm run build"
}, },
"dependencies": { "dependencies": {
"@nodert-win10-rs4/windows.security.credentials.ui": "^0.4.4",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",

Binary file not shown.

View File

@@ -12,7 +12,6 @@ import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow' import AnnualReportWindow from './pages/AnnualReportWindow'
import AgreementPage from './pages/AgreementPage' import AgreementPage from './pages/AgreementPage'
import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage' import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow' import VideoWindow from './pages/VideoWindow'
@@ -43,7 +42,9 @@ function App() {
setDownloadProgress, setDownloadProgress,
showUpdateDialog, showUpdateDialog,
setShowUpdateDialog, setShowUpdateDialog,
setUpdateError setUpdateError,
isLocked,
setLocked
} = useAppStore() } = useAppStore()
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
@@ -54,8 +55,10 @@ function App() {
const [themeHydrated, setThemeHydrated] = useState(false) const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态 // 锁定状态
const [isLocked, setIsLocked] = useState(false) // const [isLocked, setIsLocked] = useState(false) // Moved to store
const [lockAvatar, setLockAvatar] = useState<string | undefined>(undefined) const [lockAvatar, setLockAvatar] = useState<string | undefined>(
localStorage.getItem('app_lock_avatar') || undefined
)
const [lockUseHello, setLockUseHello] = useState(false) const [lockUseHello, setLockUseHello] = useState(false)
// 协议同意状态 // 协议同意状态
@@ -174,7 +177,7 @@ function App() {
setShowUpdateDialog(true) setShowUpdateDialog(true)
} }
}) })
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => { const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => {
setDownloadProgress(progress) setDownloadProgress(progress)
}) })
return () => { return () => {
@@ -271,12 +274,13 @@ function App() {
if (enabled) { if (enabled) {
setLockUseHello(useHello) setLockUseHello(useHello)
setIsLocked(true) setLocked(true)
// 尝试获取头像 // 尝试获取头像
try { try {
const result = await window.electronAPI.chat.getMyAvatarUrl() const result = await window.electronAPI.chat.getMyAvatarUrl()
if (result && result.success && result.avatarUrl) { if (result && result.success && result.avatarUrl) {
setLockAvatar(result.avatarUrl) setLockAvatar(result.avatarUrl)
localStorage.setItem('app_lock_avatar', result.avatarUrl)
} }
} catch (e) { } catch (e) {
console.error('获取锁屏头像失败', e) console.error('获取锁屏头像失败', e)
@@ -310,7 +314,7 @@ function App() {
<div className="app-container"> <div className="app-container">
{isLocked && ( {isLocked && (
<LockScreen <LockScreen
onUnlock={() => setIsLocked(false)} onUnlock={() => setLocked(false)}
avatar={lockAvatar} avatar={lockAvatar}
useHello={lockUseHello} useHello={lockUseHello}
/> />
@@ -394,7 +398,7 @@ function App() {
<Route path="/group-analytics" element={<GroupAnalyticsPage />} /> <Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/annual-report" element={<AnnualReportPage />} /> <Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} /> <Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/data-management" element={<DataManagementPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} /> <Route path="/export" element={<ExportPage />} />
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import * as configService from '../services/config' import * as configService from '../services/config'
import { ArrowRight, Fingerprint, Lock, ShieldCheck } from 'lucide-react' import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import './LockScreen.scss' import './LockScreen.scss'
interface LockScreenProps { interface LockScreenProps {
@@ -63,18 +63,6 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
setShowHello(true) setShowHello(true)
// 立即执行验证 (0延迟) // 立即执行验证 (0延迟)
verifyHello() verifyHello()
// 后台再次确认可用性,如果其实不可用,再隐藏?
// 或者信任用户的配置。为了速度,我们优先信任配置。
if (window.PublicKeyCredential) {
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then(available => {
if (!available) {
// 如果系统报告不支持,但配置开了,我们可能需要提示?
// 暂时保持开启状态,反正 verifyHello 会报错
}
})
}
} }
} catch (e) { } catch (e) {
console.error('Quick start hello failed', e) console.error('Quick start hello failed', e)
@@ -84,51 +72,23 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
const verifyHello = async () => { const verifyHello = async () => {
if (isVerifying || isUnlocked) return if (isVerifying || isUnlocked) return
// 取消之前的请求(如果有)
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const abortController = new AbortController()
abortControllerRef.current = abortController
setIsVerifying(true) setIsVerifying(true)
setError('') setError('')
try { try {
const challenge = new Uint8Array(32) const result = await window.electronAPI.auth.hello()
window.crypto.getRandomValues(challenge)
const rpId = 'localhost' if (result.success) {
const credential = await navigator.credentials.get({
publicKey: {
challenge,
rpId,
userVerification: 'required',
},
signal: abortController.signal
})
if (credential) {
handleUnlock() handleUnlock()
} else {
console.error('Hello verification failed:', result.error)
setError(result.error || '验证失败')
} }
} catch (e: any) { } catch (e: any) {
if (e.name === 'AbortError') { console.error('Hello verification error:', e)
console.log('Hello verification aborted') setError(`验证失败: ${e.message || String(e)}`)
return
}
if (e.name === 'NotAllowedError') {
console.log('User cancelled Hello verification')
} else {
console.error('Hello verification error:', e)
// 仅在非手动取消时显示错误
if (e.name !== 'AbortError') {
setError(`验证失败: ${e.message || e.name}`)
}
}
} finally { } finally {
if (!abortController.signal.aborted) { setIsVerifying(false)
setIsVerifying(false)
}
} }
} }
@@ -136,11 +96,8 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
e?.preventDefault() e?.preventDefault()
if (!password || isUnlocked) return if (!password || isUnlocked) return
// 如果正在进行 Hello 验证,取消 // 如果正在进行 Hello 验证,它会自动失败或被取代UI上不用特意取消
if (abortControllerRef.current) { // 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
abortControllerRef.current.abort()
abortControllerRef.current = null
}
// 不再检查 isVerifying因为我们允许打断 Hello // 不再检查 isVerifying因为我们允许打断 Hello
setIsVerifying(true) setIsVerifying(true)

View File

@@ -6,8 +6,7 @@ interface RouteGuardProps {
children: React.ReactNode children: React.ReactNode
} }
// 不需要数据库连接的页面 const PUBLIC_ROUTES = ['/', '/home', '/settings']
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management']
function RouteGuard({ children }: RouteGuardProps) { function RouteGuard({ children }: RouteGuardProps) {
const navigate = useNavigate() const navigate = useNavigate()

View File

@@ -76,7 +76,7 @@
} }
.sidebar-footer { .sidebar-footer {
padding: 0 8px; padding: 0 12px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
padding-top: 12px; padding-top: 12px;
margin-top: 8px; margin-top: 8px;

View File

@@ -1,11 +1,19 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle } from 'lucide-react' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle, Lock } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import './Sidebar.scss' import './Sidebar.scss'
function Sidebar() { function Sidebar() {
const location = useLocation() const location = useLocation()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false)
const setLocked = useAppStore(state => state.setLocked)
useEffect(() => {
configService.getAuthEnabled().then(setAuthEnabled)
}, [])
const isActive = (path: string) => { const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`) return location.pathname === path || location.pathname.startsWith(`${path}/`)
@@ -94,18 +102,21 @@ function Sidebar() {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
{/* 数据管理 */}
<NavLink
to="/data-management"
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
title={collapsed ? '数据管理' : undefined}
>
<span className="nav-icon"><Database size={20} /></span>
<span className="nav-label"></span>
</NavLink>
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
{authEnabled && (
<button
className="nav-item"
onClick={() => setLocked(true)}
title={collapsed ? '锁定' : undefined}
>
<span className="nav-icon"><Lock size={20} /></span>
<span className="nav-label"></span>
</button>
)}
<NavLink <NavLink
to="/settings" to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`} className={`nav-item ${isActive('/settings') ? 'active' : ''}`}

View File

@@ -5,7 +5,6 @@ import ReactECharts from 'echarts-for-react'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import './AnalyticsPage.scss' import './AnalyticsPage.scss'
import './DataManagementPage.scss'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
function AnalyticsPage() { function AnalyticsPage() {

View File

@@ -1,569 +0,0 @@
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
h1 {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.header-tabs {
display: flex;
gap: 8px;
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
border-radius: 9999px;
transition: all 0.2s;
&:hover {
background: var(--border-color);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: white;
}
}
}
}
.page-scroll {
display: flex;
flex-direction: column;
gap: 24px;
}
.page-section {
background: var(--bg-secondary);
border-radius: 16px;
padding: 20px 24px;
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-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
.section-actions {
display: flex;
gap: 10px;
}
}
}
.btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 9999px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spin {
animation: spin 1s linear infinite;
}
}
.btn-primary {
background: var(--primary);
color: white;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover:not(:disabled) {
background: var(--border-color);
}
}
.btn-warning {
background: #f59e0b;
color: white;
&:hover:not(:disabled) {
background: #d97706;
}
}
.database-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.database-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-primary);
border-radius: 12px;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
}
.status-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&.decrypted {
background: var(--primary);
color: white;
}
&.needs-update {
background: #f59e0b;
color: white;
}
&.pending {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
}
.db-info {
flex: 1;
min-width: 0;
.db-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.db-meta {
display: flex;
gap: 6px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.db-status {
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
&.decrypted {
background: rgba(34, 197, 94, 0.15);
color: #16a34a;
}
&.needs-update {
background: rgba(245, 158, 11, 0.15);
color: #b45309;
}
&.pending {
background: rgba(234, 179, 8, 0.15);
color: #b45309;
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--text-tertiary);
svg {
margin-bottom: 16px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 14px;
&.hint {
margin-top: 6px;
font-size: 13px;
opacity: 0.7;
}
}
}
.unavailable-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 20px;
color: var(--text-tertiary);
svg {
margin-bottom: 20px;
opacity: 0.4;
}
p {
margin: 0;
font-size: 15px;
color: var(--text-secondary);
&.hint {
margin-top: 8px;
font-size: 13px;
color: var(--text-tertiary);
}
}
}
.message-toast {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
padding: 10px 24px;
border-radius: 9999px;
font-size: 14px;
z-index: 100;
animation: slideDown 0.3s ease;
&.success {
background: var(--primary);
color: white;
}
&.error {
background: var(--danger);
color: white;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.decrypt-progress-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;
.progress-card {
background: var(--bg-primary);
border-radius: 16px;
padding: 32px 40px;
min-width: 400px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.progress-file {
margin: 0 0 20px;
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 9999px;
overflow: hidden;
margin-bottom: 12px;
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 9999px;
transition: width 0.2s ease;
}
}
.progress-text {
margin: 0;
font-size: 13px;
color: var(--text-tertiary);
}
}
}
// 图片列表样式
.current-dir {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
.dir-label {
color: var(--text-tertiary);
flex-shrink: 0;
}
.dir-path {
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.image-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
max-height: 500px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
&:hover {
background: var(--text-tertiary);
}
}
}
.image-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-primary);
border-radius: 10px;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
}
&.clickable {
cursor: pointer;
&:hover {
background: var(--bg-tertiary);
.decrypt-hint {
opacity: 1;
}
}
}
.status-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
&.decrypted {
background: var(--primary);
color: white;
}
&.pending {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
.spin {
animation: spin 1s linear infinite;
}
}
.img-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.img-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.img-meta {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.version-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
&.v3 {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
&.v4 {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
}
}
.img-size {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
}
}
.decrypt-hint {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: var(--text-tertiary);
opacity: 0;
transition: opacity 0.2s;
}
}
.more-hint {
grid-column: 1 / -1;
text-align: center;
padding: 16px;
font-size: 13px;
color: var(--text-tertiary);
}
// 账号选择器
.account-selector {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
.account-btn {
padding: 6px 14px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 13px;
border-radius: 9999px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
}
}

View File

@@ -1,67 +0,0 @@
import { useEffect, useState } from 'react'
import * as configService from '../services/config'
import './DataManagementPage.scss'
function DataManagementPage() {
const [dbPath, setDbPath] = useState<string | null>(null)
const [wxid, setWxid] = useState<string | null>(null)
useEffect(() => {
const loadConfig = async () => {
const [path, id] = await Promise.all([
configService.getDbPath(),
configService.getMyWxid()
])
setDbPath(path)
setWxid(id)
}
loadConfig()
const handleChange = () => {
loadConfig()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [])
return (
<>
<div className="page-header">
<h1></h1>
</div>
<div className="page-scroll">
<section className="page-section">
<div className="section-header">
<div>
<h2>WCDB </h2>
<p className="section-desc">
WCDB DLL
</p>
</div>
</div>
<div className="database-list">
<div className="database-item decrypted">
<div className="db-info">
<div className="db-name">
</div>
<div className="db-path">{dbPath || '未配置'}</div>
</div>
</div>
<div className="database-item decrypted">
<div className="db-info">
<div className="db-name">
ID
</div>
<div className="db-path">{wxid || '未配置'}</div>
</div>
</div>
</div>
</section>
</div>
</>
)
}
export default DataManagementPage

View File

@@ -19,6 +19,7 @@ interface ExportOptions {
exportMedia: boolean exportMedia: boolean
exportImages: boolean exportImages: boolean
exportVoices: boolean exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean exportEmojis: boolean
exportVoiceAsText: boolean exportVoiceAsText: boolean
excelCompactColumns: boolean excelCompactColumns: boolean
@@ -65,6 +66,7 @@ function ExportPage() {
exportMedia: false, exportMedia: false,
exportImages: true, exportImages: true,
exportVoices: true, exportVoices: true,
exportVideos: true,
exportEmojis: true, exportEmojis: true,
exportVoiceAsText: true, exportVoiceAsText: true,
excelCompactColumns: true, excelCompactColumns: true,
@@ -257,6 +259,7 @@ function ExportPage() {
exportMedia: true, exportMedia: true,
exportImages: true, exportImages: true,
exportVoices: true, exportVoices: true,
exportVideos: true,
exportEmojis: true, exportEmojis: true,
exportVoiceAsText: true exportVoiceAsText: true
} }
@@ -286,6 +289,7 @@ function ExportPage() {
exportMedia: options.exportMedia, exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages, exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices, exportVoices: options.exportMedia && options.exportVoices,
exportVideos: options.exportMedia && options.exportVideos,
exportEmojis: options.exportMedia && options.exportEmojis, exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
@@ -609,7 +613,7 @@ function ExportPage() {
)} )}
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<p className="setting-subtitle">//</p> <p className="setting-subtitle">///</p>
<div className="media-options-card"> <div className="media-options-card">
<div className="media-switch-row"> <div className="media-switch-row">
<div className="media-switch-info"> <div className="media-switch-info">
@@ -661,7 +665,7 @@ function ExportPage() {
<label className="media-checkbox-row"> <label className="media-checkbox-row">
<div className="media-checkbox-info"> <div className="media-checkbox-info">
<span className="media-checkbox-title"></span> <span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span> <span className="media-checkbox-desc"></span>
</div> </div>
<input <input
type="checkbox" type="checkbox"
@@ -672,6 +676,21 @@ function ExportPage() {
<div className="media-option-divider"></div> <div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportVideos}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVideos: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}> <label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info"> <div className="media-checkbox-info">
<span className="media-checkbox-title"></span> <span className="media-checkbox-title"></span>

View File

@@ -62,9 +62,11 @@ function SettingsPage() {
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null) const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null) const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null) const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = 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')
@@ -96,6 +98,7 @@ function SettingsPage() {
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false) const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
const [isClearingImageCache, setIsClearingImageCache] = useState(false) const [isClearingImageCache, setIsClearingImageCache] = useState(false)
const [isClearingAllCache, setIsClearingAllCache] = useState(false) const [isClearingAllCache, setIsClearingAllCache] = useState(false)
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
// 安全设置 state // 安全设置 state
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
@@ -125,6 +128,9 @@ function SettingsPage() {
useEffect(() => { useEffect(() => {
loadConfig() loadConfig()
loadAppVersion() loadAppVersion()
return () => {
Object.values(saveTimersRef.current).forEach((timer) => clearTimeout(timer))
}
}, []) }, [])
// 点击外部关闭下拉框 // 点击外部关闭下拉框
@@ -140,10 +146,13 @@ function SettingsPage() {
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false) setShowExportExcelColumnsSelect(false)
} }
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
setShowExportConcurrencySelect(false)
}
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect]) }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -334,6 +343,12 @@ function SettingsPage() {
imageAesKey: imageAesKey || '' imageAesKey: imageAesKey || ''
}) })
const buildKeysFromInputs = (overrides?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string }): WxidKeys => ({
decryptKey: overrides?.decryptKey ?? decryptKey ?? '',
imageXorKey: parseImageXorKey(overrides?.imageXorKey ?? imageXorKey),
imageAesKey: overrides?.imageAesKey ?? imageAesKey ?? ''
})
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({ const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
decryptKey: wxidConfig?.decryptKey || '', decryptKey: wxidConfig?.decryptKey || '',
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null, imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
@@ -444,9 +459,9 @@ function SettingsPage() {
try { try {
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] }) const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
const path = result.filePaths[0] const selectedPath = result.filePaths[0]
setDbPath(path) setDbPath(selectedPath)
await configService.setDbPath(path) await configService.setDbPath(selectedPath)
showMessage('已选择数据库目录', true) showMessage('已选择数据库目录', true)
} }
} catch (e: any) { } catch (e: any) {
@@ -490,9 +505,9 @@ function SettingsPage() {
try { try {
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] }) const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
const path = result.filePaths[0] const selectedPath = result.filePaths[0]
setCachePath(path) setCachePath(selectedPath)
await configService.setCachePath(path) await configService.setCachePath(selectedPath)
showMessage('已选择缓存目录', true) showMessage('已选择缓存目录', true)
} }
} catch (e: any) { } catch (e: any) {
@@ -579,12 +594,25 @@ function SettingsPage() {
handleAutoGetDbKey() handleAutoGetDbKey()
} }
// Helper to sync current keys to wxid config // Debounce config writes to avoid excessive disk IO
const syncCurrentKeys = async () => { const scheduleConfigSave = (key: string, task: () => Promise<void> | void, delay = 300) => {
const keys = buildKeysFromState() const timers = saveTimersRef.current
if (timers[key]) {
clearTimeout(timers[key])
}
timers[key] = setTimeout(() => {
Promise.resolve(task()).catch((e) => {
console.error('保存配置失败:', e)
})
}, delay)
}
const syncCurrentKeys = async (options?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string; wxid?: string }) => {
const keys = buildKeysFromInputs(options)
await syncKeysToConfig(keys) await syncKeysToConfig(keys)
if (wxid) { const wxidToUse = options?.wxid ?? wxid
await configService.setWxidConfig(wxid, { if (wxidToUse) {
await configService.setWxidConfig(wxidToUse, {
decryptKey: keys.decryptKey, decryptKey: keys.decryptKey,
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0, imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
imageAesKey: keys.imageAesKey imageAesKey: keys.imageAesKey
@@ -808,10 +836,11 @@ function SettingsPage() {
type={showDecryptKey ? 'text' : 'password'} type={showDecryptKey ? 'text' : 'password'}
placeholder="例如: a1b2c3d4e5f6..." placeholder="例如: a1b2c3d4e5f6..."
value={decryptKey} value={decryptKey}
onChange={(e) => setDecryptKey(e.target.value)} onChange={(e) => {
onBlur={async () => { const value = e.target.value
if (decryptKey && decryptKey.length === 64) { setDecryptKey(value)
await syncCurrentKeys() if (value && value.length === 64) {
scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value }))
// showMessage('解密密钥已保存', true) // showMessage('解密密钥已保存', true)
} }
}} }}
@@ -843,11 +872,14 @@ function SettingsPage() {
type="text" type="text"
placeholder="例如: C:\Users\xxx\Documents\xwechat_files" placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
value={dbPath} value={dbPath}
onChange={(e) => setDbPath(e.target.value)} onChange={(e) => {
onBlur={async () => { const value = e.target.value
if (dbPath) { setDbPath(value)
await configService.setDbPath(dbPath) scheduleConfigSave('dbPath', async () => {
} if (value) {
await configService.setDbPath(value)
}
})
}} }}
/> />
<div className="btn-row"> <div className="btn-row">
@@ -866,12 +898,15 @@ function SettingsPage() {
type="text" type="text"
placeholder="例如: wxid_xxxxxx" placeholder="例如: wxid_xxxxxx"
value={wxid} value={wxid}
onChange={(e) => setWxid(e.target.value)} onChange={(e) => {
onBlur={async () => { const value = e.target.value
if (wxid) { setWxid(value)
await configService.setMyWxid(wxid) scheduleConfigSave('wxid', async () => {
await syncCurrentKeys() // Sync keys to the new wxid entry if (value) {
} await configService.setMyWxid(value)
await syncCurrentKeys({ wxid: value }) // Sync keys to the new wxid entry
}
})
}} }}
/> />
</div> </div>
@@ -885,8 +920,14 @@ function SettingsPage() {
type="text" type="text"
placeholder="例如: 0xA4" placeholder="例如: 0xA4"
value={imageXorKey} value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)} onChange={(e) => {
onBlur={syncCurrentKeys} const value = e.target.value
setImageXorKey(value)
const parsed = parseImageXorKey(value)
if (value === '' || parsed !== null) {
scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value }))
}
}}
/> />
</div> </div>
@@ -897,8 +938,11 @@ function SettingsPage() {
type="text" type="text"
placeholder="16 位 AES 密钥" placeholder="16 位 AES 密钥"
value={imageAesKey} value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)} onChange={(e) => {
onBlur={syncCurrentKeys} const value = e.target.value
setImageAesKey(value)
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value }))
}}
/> />
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}> <button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} <Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
@@ -1013,8 +1057,11 @@ function SettingsPage() {
type="text" type="text"
placeholder="留空使用默认目录" placeholder="留空使用默认目录"
value={whisperModelDir} value={whisperModelDir}
onChange={(e) => setWhisperModelDir(e.target.value)} onChange={(e) => {
onBlur={() => configService.setWhisperModelDir(whisperModelDir)} const value = e.target.value
setWhisperModelDir(value)
scheduleConfigSave('whisperModelDir', () => configService.setWhisperModelDir(value))
}}
/> />
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> </button> <button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> </button>
@@ -1068,6 +1115,15 @@ function SettingsPage() {
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] ]
const exportConcurrencyOptions = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 4, label: '4' },
{ value: 5, label: '5' },
{ value: 6, label: '6' }
]
const getOptionLabel = (options: { value: string; label: string }[], value: string) => { const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
return options.find((option) => option.value === value)?.label ?? value return options.find((option) => option.value === value)?.label ?? value
} }
@@ -1077,6 +1133,7 @@ function SettingsPage() {
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange) const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
const exportConcurrencyLabel = String(exportDefaultConcurrency)
return ( return (
<div className="tab-content"> <div className="tab-content">
@@ -1091,6 +1148,7 @@ function SettingsPage() {
setShowExportFormatSelect(!showExportFormatSelect) setShowExportFormatSelect(!showExportFormatSelect)
setShowExportDateRangeSelect(false) setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false) setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}} }}
> >
<span className="select-value">{exportFormatLabel}</span> <span className="select-value">{exportFormatLabel}</span>
@@ -1130,6 +1188,7 @@ function SettingsPage() {
setShowExportDateRangeSelect(!showExportDateRangeSelect) setShowExportDateRangeSelect(!showExportDateRangeSelect)
setShowExportFormatSelect(false) setShowExportFormatSelect(false)
setShowExportExcelColumnsSelect(false) setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}} }}
> >
<span className="select-value">{exportDateRangeLabel}</span> <span className="select-value">{exportDateRangeLabel}</span>
@@ -1214,6 +1273,7 @@ function SettingsPage() {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFormatSelect(false) setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false) setShowExportDateRangeSelect(false)
setShowExportConcurrencySelect(false)
}} }}
> >
<span className="select-value">{exportExcelColumnsLabel}</span> <span className="select-value">{exportExcelColumnsLabel}</span>
@@ -1243,6 +1303,45 @@ function SettingsPage() {
</div> </div>
</div> </div>
<div className="form-group">
<label></label>
<span className="form-hint">1~6</span>
<div className="select-field" ref={exportConcurrencyDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`}
onClick={() => {
setShowExportConcurrencySelect(!showExportConcurrencySelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportConcurrencyLabel}</span>
<ChevronDown size={16} />
</button>
{showExportConcurrencySelect && (
<div className="select-dropdown">
{exportConcurrencyOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultConcurrency === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultConcurrency(option.value)
await configService.setExportDefaultConcurrency(option.value)
showMessage(`已将导出并发数设为 ${option.value}`, true)
setShowExportConcurrencySelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
</div> </div>
) )
} }
@@ -1256,14 +1355,23 @@ function SettingsPage() {
type="text" type="text"
placeholder="留空使用默认目录" placeholder="留空使用默认目录"
value={cachePath} value={cachePath}
onChange={(e) => setCachePath(e.target.value)} onChange={(e) => {
onBlur={async () => { const value = e.target.value
await configService.setCachePath(cachePath) setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}} }}
/> />
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button> <button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button> <button
className="btn btn-secondary"
onClick={async () => {
setCachePath('')
await configService.setCachePath('')
}}
>
<RotateCcw size={16} />
</button>
</div> </div>
</div> </div>

View File

@@ -33,6 +33,10 @@ export interface AppState {
setShowUpdateDialog: (show: boolean) => void setShowUpdateDialog: (show: boolean) => void
setUpdateError: (error: string | null) => void setUpdateError: (error: string | null) => void
// 锁定状态
isLocked: boolean
setLocked: (locked: boolean) => void
reset: () => void reset: () => void
} }
@@ -42,6 +46,7 @@ export const useAppStore = create<AppState>((set) => ({
myWxid: null, myWxid: null,
isLoading: false, isLoading: false,
loadingText: '', loadingText: '',
isLocked: false,
// 更新状态初始化 // 更新状态初始化
updateInfo: null, updateInfo: null,
@@ -62,6 +67,8 @@ export const useAppStore = create<AppState>((set) => ({
loadingText: text ?? '' loadingText: text ?? ''
}), }),
setLocked: (locked) => set({ isLocked: locked }),
setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }), setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }),
setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }), setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }),
setDownloadProgress: (progress) => set({ downloadProgress: progress }), setDownloadProgress: (progress) => set({ downloadProgress: progress }),
@@ -74,6 +81,7 @@ export const useAppStore = create<AppState>((set) => ({
myWxid: null, myWxid: null,
isLoading: false, isLoading: false,
loadingText: '', loadingText: '',
isLocked: false,
updateInfo: null, updateInfo: null,
isDownloading: false, isDownloading: false,
downloadProgress: { percent: 0 }, downloadProgress: { percent: 0 },

13
src/vite-env.d.ts vendored
View File

@@ -1 +1,14 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface Window {
electronAPI: {
// ... other methods ...
auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
}
// For brevity, using 'any' for other parts or properly importing types if available.
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
// or import a shared type definition.
[key: string]: any
}
}