Compare commits

...

42 Commits

Author SHA1 Message Date
xuncha
26d4751e80 Merge pull request #157 from hicccc77/dev
Dev
2026-01-31 18:37:19 +08:00
xuncha
b8120a5119 Merge pull request #156 from xunchahaha/dev
111
2026-01-31 18:36:50 +08:00
xuncha
68a13cefc3 111 2026-01-31 18:36:28 +08:00
xuncha
cd4b8f3702 Merge pull request #155 from hicccc77/main
同步一下
2026-01-31 18:16:11 +08:00
xuncha
c5956ba203 Merge pull request #154 from xunchahaha/main
xiufu
2026-01-31 18:15:10 +08:00
xuncha
f456357e01 Merge pull request #153 from xunchahaha/dev
Dev
2026-01-31 18:14:26 +08:00
xuncha
4ef821f45f 更新版本号 2026-01-31 18:12:57 +08:00
xuncha
912c78e9e9 Merge branch 'main' of https://github.com/xunchahaha/WeFlow 2026-01-31 18:11:58 +08:00
xuncha
bfcd154a25 wxid可以自己选择 2026-01-31 18:11:55 +08:00
xuncha
a1c8ba48b0 Merge pull request #1 from xunchahaha/main
11
2026-01-31 17:46:20 +08:00
xuncha
f93369489d Merge branch 'hicccc77:main' into main 2026-01-31 17:45:54 +08:00
xuncha
014f57f152 尝试修复秘钥获取失败 2026-01-31 17:44:52 +08:00
xuncha
3f1eb58af4 Merge pull request #151 from xunchahaha:main
Main
2026-01-31 16:14:26 +08:00
xuncha
97f0077e95 打包你快修好啊 我服了 2026-01-31 16:14:00 +08:00
xuncha
3d9b1b0f8c Merge pull request #150 from xunchahaha:main
Main
2026-01-31 16:06:54 +08:00
xuncha
cf292ca9e2 hh 2026-01-31 16:06:36 +08:00
xuncha
97f14030de Merge pull request #149 from xunchahaha:main
Main
2026-01-31 16:02:01 +08:00
xuncha
2cfe0d8ee8 ee 2026-01-31 16:01:21 +08:00
xuncha
a760f45823 Merge pull request #148 from xunchahaha:main
Main
2026-01-31 15:56:53 +08:00
xuncha
baa949a301 呃呃 2026-01-31 15:56:27 +08:00
xuncha
c29bbab25f Merge pull request #147 from xunchahaha:main
Main
2026-01-31 15:51:21 +08:00
xuncha
29981e1232 打包优化 2026-01-31 15:51:04 +08:00
xuncha
2d043cd929 Merge pull request #146 from hicccc77/dev
Dev
2026-01-31 15:41:37 +08:00
xuncha
d6dca0e5f7 Merge pull request #145 from xunchahaha:dev
Dev
2026-01-31 15:40:39 +08:00
xuncha
d47166e6f9 修复打包错误 2026-01-31 15:39:59 +08:00
xuncha
6e3bb9e361 图片解密策略更加激进 2026-01-31 15:24:21 +08:00
xuncha
b8dbc3caf1 群聊分析ui调整 2026-01-31 15:04:54 +08:00
xuncha
c1145c8f89 导出群成员第二版 2026-01-31 14:58:15 +08:00
xuncha
0cba8e6d89 导出群成员第一版 2026-01-31 14:26:13 +08:00
xuncha
f6f468dff3 Merge pull request #144 from xunchahaha/dev
Dev
2026-01-31 14:01:22 +08:00
xuncha
04fc5f9104 修复切换账号后的异常问题 2026-01-31 14:00:01 +08:00
xuncha
3c9ab6763c 导出方面再优化 媒体并行导出 2026-01-31 13:49:21 +08:00
cc
f360333ab4 Merge pull request #143 from hicccc77/dev
Dev
2026-01-30 23:49:43 +08:00
cc
834aa6eecb Merge branch 'main' into dev 2026-01-30 23:49:33 +08:00
cc
2400cc8b55 Merge pull request #142 from yunxilyf/main
fix:自动保存bug
2026-01-30 23:48:39 +08:00
cc
e4ed7faca9 feat: 一些优化 2026-01-30 23:47:46 +08:00
yunxilyf
8012aa49ee fix:自动保存bug 2026-01-30 23:46:26 +08:00
xuncha
7225358b91 Merge pull request #140 from xunchahaha/dev
Dev
2026-01-30 20:47:01 +08:00
xuncha
39688e8e0c Merge branch 'hicccc77:dev' into dev 2026-01-30 20:46:47 +08:00
xuncha
592ca6128f 导出方面优化 2026-01-30 20:46:02 +08:00
xuncha
7cd27d8905 Merge pull request #139 from xunchahaha/dev
修复自动保存失效
2026-01-30 20:19:42 +08:00
xuncha
bca387c54b 修复自动保存失效 2026-01-30 20:19:23 +08:00
36 changed files with 1365 additions and 919 deletions

View File

@@ -21,7 +21,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22.12
cache: 'npm' cache: 'npm'
- name: Install Dependencies - name: Install Dependencies

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'
// 配置自动更新 // 配置自动更新
@@ -673,6 +674,10 @@ function registerIpcHandlers() {
return dbPathService.scanWxids(rootPath) return dbPathService.scanWxids(rootPath)
}) })
ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => {
return dbPathService.scanWxidCandidates(rootPath)
})
ipcMain.handle('dbpath:getDefault', async () => { ipcMain.handle('dbpath:getDefault', async () => {
return dbPathService.getDefaultPath() return dbPathService.getDefaultPath()
}) })
@@ -798,6 +803,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) => {
@@ -894,6 +910,10 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
}) })
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
})
// 打开协议窗口 // 打开协议窗口
ipcMain.handle('window:openAgreementWindow', async () => { ipcMain.handle('window:openAgreementWindow', async () => {
createAgreementWindow() createAgreementWindow()

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: {
@@ -66,6 +71,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
dbPath: { dbPath: {
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'), autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath), scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
getDefault: () => ipcRenderer.invoke('dbpath:getDefault') getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
}, },
@@ -178,7 +184,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime) getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
}, },
// 年度报告 // 年度报告

View File

@@ -118,6 +118,48 @@ export class DbPathService {
} }
} }
/**
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const wxids: WxidInfo[] = []
try {
if (existsSync(rootPath)) {
const entries = readdirSync(rootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
} catch {
continue
}
if (!stat.isDirectory()) continue
const lower = entry.toLowerCase()
if (lower === 'all_users') continue
if (!entry.includes('_')) continue
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
}
}
if (wxids.length === 0) {
const rootName = basename(rootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
const rootStat = statSync(rootPath)
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
}
}
} catch { }
return wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
}
/** /**
* 扫描 wxid 列表 * 扫描 wxid 列表
*/ */

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
@@ -141,6 +142,12 @@ class ExportService {
this.configService = new ConfigService() this.configService = new ConfigService()
} }
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
const raw = Math.floor(value)
return Math.max(1, Math.min(raw, max))
}
private cleanAccountDirName(dirName: string): string { private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim() const trimmed = dirName.trim()
if (!trimmed) return trimmed if (!trimmed) return trimmed
@@ -186,6 +193,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 +880,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 +898,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 +1253,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 +1701,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 +1709,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 +1731,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) // 语音文件
}) })
: [] : []
@@ -1721,14 +1746,15 @@ class ExportService {
phase: 'exporting-media' phase: 'exporting-media'
}) })
// 并行导出媒体,限制 8 个并发 // 并行导出媒体,并发数跟随导出设置
const MEDIA_CONCURRENCY = 8 const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
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 +1764,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 +1917,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 +1924,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 +1947,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)
}) })
: [] : []
@@ -1928,13 +1962,14 @@ class ExportService {
phase: 'exporting-media' phase: 'exporting-media'
}) })
const MEDIA_CONCURRENCY = 8 const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
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 +1979,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 +2019,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 +2187,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 +2194,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 +2339,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)
}) })
: [] : []
@@ -2311,13 +2354,14 @@ class ExportService {
phase: 'exporting-media' phase: 'exporting-media'
}) })
const MEDIA_CONCURRENCY = 8 const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
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 +2371,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 +2456,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 +2597,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 +2614,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 +2621,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 +2644,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)
}) })
: [] : []
@@ -2589,13 +2659,14 @@ class ExportService {
phase: 'exporting-media' phase: 'exporting-media'
}) })
const MEDIA_CONCURRENCY = 8 const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
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 +2675,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 +2705,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 +2839,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 +2863,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 +3170,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

@@ -1,5 +1,9 @@
import * as fs from 'fs'
import * as path from 'path'
import ExcelJS from 'exceljs'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
export interface GroupChatInfo { export interface GroupChatInfo {
username: string username: string
@@ -41,6 +45,30 @@ class GroupAnalyticsService {
this.configService = new ConfigService() this.configService = new ConfigService()
} }
// 并发控制:限制同时执行的 Promise 数量
private async parallelLimit<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let currentIndex = 0
async function runNext(): Promise<void> {
while (currentIndex < items.length) {
const index = currentIndex++
results[index] = await fn(items[index], index)
}
}
const workers = Array(Math.min(limit, items.length))
.fill(null)
.map(() => runNext())
await Promise.all(workers)
return results
}
private cleanAccountDirName(name: string): string { private cleanAccountDirName(name: string): string {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) return trimmed if (!trimmed) return trimmed
@@ -65,6 +93,139 @@ class GroupAnalyticsService {
return { success: true } return { success: true }
} }
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
/**
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
*/
private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map<string, string> {
const nicknameMap = new Map<string, string>()
try {
const raw = buffer.toString('utf8')
const wxidPattern = /wxid_[a-z0-9_]+/gi
const wxids = raw.match(wxidPattern) || []
for (const wxid of wxids) {
const wxidLower = wxid.toLowerCase()
const wxidIndex = raw.toLowerCase().indexOf(wxidLower)
if (wxidIndex === -1) continue
const afterWxid = raw.slice(wxidIndex + wxid.length)
let nickname = ''
let foundStart = false
for (let i = 0; i < afterWxid.length && i < 100; i++) {
const char = afterWxid[i]
const code = char.charCodeAt(0)
const isPrintable = (
(code >= 0x4E00 && code <= 0x9FFF) ||
(code >= 0x3000 && code <= 0x303F) ||
(code >= 0xFF00 && code <= 0xFFEF) ||
(code >= 0x20 && code <= 0x7E)
)
if (isPrintable && code !== 0x01 && code !== 0x18) {
foundStart = true
nickname += char
} else if (foundStart) {
break
}
}
nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '')
if (nickname && nickname.length < 50) {
nicknameMap.set(wxidLower, nickname)
}
}
} catch (e) {
console.error('Failed to parse ext_buffer:', e)
}
return nicknameMap
}
/**
* 从 contact.db 的 chat_room 表获取群成员的群昵称
*/
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
try {
const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'`
const result = await wcdbService.execQuery('contact', null, sql)
if (!result.success || !result.rows || result.rows.length === 0) {
return new Map<string, string>()
}
let extBuffer = result.rows[0].ext_buffer
if (typeof extBuffer === 'string') {
if (this.looksLikeHex(extBuffer)) {
extBuffer = Buffer.from(extBuffer, 'hex')
} else if (this.looksLikeBase64(extBuffer)) {
extBuffer = Buffer.from(extBuffer, 'base64')
} else {
try {
extBuffer = Buffer.from(extBuffer, 'hex')
} catch {
extBuffer = Buffer.from(extBuffer, 'base64')
}
}
}
if (!extBuffer || !Buffer.isBuffer(extBuffer)) {
return new Map<string, string>()
}
return this.parseGroupNicknamesFromExtBuffer(extBuffer)
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return new Map<string, string>()
}
}
private escapeCsvValue(value: string): string {
if (value == null) return ''
const str = String(value)
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
private normalizeGroupNickname(value: string, wxid: string, fallback: string): string {
const trimmed = (value || '').trim()
if (!trimmed) return fallback
if (/^["'@]+$/.test(trimmed)) return fallback
if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback
return trimmed
}
private sanitizeWorksheetName(name: string): string {
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
const limited = cleaned.slice(0, 31)
return limited || 'Sheet1'
}
private formatDateTime(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
const year = date.getFullYear()
const month = pad(date.getMonth() + 1)
const day = pad(date.getDate())
const hour = pad(date.getHours())
const minute = pad(date.getMinutes())
const second = pad(date.getSeconds())
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()
@@ -80,23 +241,38 @@ class GroupAnalyticsService {
.map((row) => row.username || row.user_name || row.userName || '') .map((row) => row.username || row.user_name || row.userName || '')
.filter((username) => username.includes('@chatroom')) .filter((username) => username.includes('@chatroom'))
const [displayNames, avatarUrls, memberCounts] = await Promise.all([ const [memberCounts, contactInfo] = await Promise.all([
wcdbService.getDisplayNames(groupIds), wcdbService.getGroupMemberCounts(groupIds),
wcdbService.getAvatarUrls(groupIds), chatService.enrichSessionsContactInfo(groupIds)
wcdbService.getGroupMemberCounts(groupIds)
]) ])
let fallbackNames: { success: boolean; map?: Record<string, string> } | null = null
let fallbackAvatars: { success: boolean; map?: Record<string, string> } | null = null
if (!contactInfo.success || !contactInfo.contacts) {
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(groupIds),
wcdbService.getAvatarUrls(groupIds)
])
fallbackNames = displayNames
fallbackAvatars = avatarUrls
}
const groups: GroupChatInfo[] = [] const groups: GroupChatInfo[] = []
for (const groupId of groupIds) { for (const groupId of groupIds) {
const contact = contactInfo.success && contactInfo.contacts ? contactInfo.contacts[groupId] : undefined
const displayName = contact?.displayName ||
(fallbackNames && fallbackNames.success && fallbackNames.map ? (fallbackNames.map[groupId] || '') : '') ||
groupId
const avatarUrl = contact?.avatarUrl ||
(fallbackAvatars && fallbackAvatars.success && fallbackAvatars.map ? fallbackAvatars.map[groupId] : undefined)
groups.push({ groups.push({
username: groupId, username: groupId,
displayName: displayNames.success && displayNames.map displayName,
? (displayNames.map[groupId] || groupId)
: groupId,
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number' memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
? memberCounts.map[groupId] ? memberCounts.map[groupId]
: 0, : 0,
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined avatarUrl
}) })
} }
@@ -248,6 +424,187 @@ class GroupAnalyticsService {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const exportDate = new Date()
const exportTime = this.formatDateTime(exportDate)
const exportVersion = '0.0.2'
const exportGenerator = 'WeFlow'
const exportPlatform = 'wechat'
const groupDisplay = await wcdbService.getDisplayNames([chatroomId])
const groupName = groupDisplay.success && groupDisplay.map
? (groupDisplay.map[chatroomId] || chatroomId)
: chatroomId
const groupContact = await wcdbService.getContact(chatroomId)
const sessionRemark = (groupContact.success && groupContact.contact)
? (groupContact.contact.remark || '')
: ''
const membersResult = await wcdbService.getGroupMembers(chatroomId)
if (!membersResult.success || !membersResult.members) {
return { success: false, error: membersResult.error || '获取群成员失败' }
}
const members = membersResult.members as { username: string; avatarUrl?: string }[]
if (members.length === 0) {
return { success: false, error: '群成员为空' }
}
const usernames = members.map((m) => m.username).filter(Boolean)
const [displayNames, groupNicknames] = await Promise.all([
wcdbService.getDisplayNames(usernames),
this.getGroupNicknamesForRoom(chatroomId)
])
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
const concurrency = 6
await this.parallelLimit(usernames, concurrency, async (username) => {
const result = await wcdbService.getContact(username)
if (result.success && result.contact) {
const contact = result.contact as any
contactMap.set(username, {
remark: contact.remark || '',
nickName: contact.nickName || contact.nick_name || '',
alias: contact.alias || ''
})
} else {
contactMap.set(username, { remark: '', nickName: '', alias: '' })
}
})
const infoTitleRow = ['会话信息']
const infoRow = ['微信ID', chatroomId, '', '昵称', groupName, '备注', sessionRemark || '', '']
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号']
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
for (const member of members) {
const wxid = member.username
const normalizedWxid = this.cleanAccountDirName(wxid || '')
const contact = contactMap.get(wxid)
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
const nickName = contact?.nickName || fallbackName || ''
const remark = contact?.remark || ''
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
const alias = contact?.alias || ''
const groupNickname = this.normalizeGroupNickname(
rawGroupNickname,
normalizedWxid === myWxid ? myWxid : wxid,
''
)
rows.push([nickName, remark, groupNickname, wxid, alias])
}
const ext = path.extname(outputPath).toLowerCase()
if (ext === '.csv') {
const csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
const content = '\ufeff' + csvLines.join('\n')
fs.writeFileSync(outputPath, content, 'utf8')
} else {
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet(this.sanitizeWorksheetName('群成员列表'))
let currentRow = 1
const titleCell = sheet.getCell(currentRow, 1)
titleCell.value = '会话信息'
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
sheet.getRow(currentRow).height = 25
currentRow++
sheet.getCell(currentRow, 1).value = '微信ID'
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
sheet.mergeCells(currentRow, 2, currentRow, 3)
sheet.getCell(currentRow, 2).value = chatroomId
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
sheet.getCell(currentRow, 4).value = '昵称'
sheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 5).value = groupName
sheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
sheet.getCell(currentRow, 6).value = '备注'
sheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
sheet.mergeCells(currentRow, 7, currentRow, 8)
sheet.getCell(currentRow, 7).value = sessionRemark
sheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
sheet.getRow(currentRow).height = 20
currentRow++
sheet.getCell(currentRow, 1).value = '导出工具'
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 2).value = exportGenerator
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 3).value = '导出版本'
sheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 4).value = exportVersion
sheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 5).value = '平台'
sheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 6).value = exportPlatform
sheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 7).value = '导出时间'
sheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 8).value = exportTime
sheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
sheet.getRow(currentRow).height = 20
currentRow++
const headerRow = sheet.getRow(currentRow)
headerRow.height = 22
header.forEach((text, index) => {
const cell = headerRow.getCell(index + 1)
cell.value = text
cell.font = { name: 'Calibri', bold: true, size: 11 }
})
currentRow++
sheet.getColumn(1).width = 28
sheet.getColumn(2).width = 28
sheet.getColumn(3).width = 28
sheet.getColumn(4).width = 36
sheet.getColumn(5).width = 28
sheet.getColumn(6).width = 18
sheet.getColumn(7).width = 24
sheet.getColumn(8).width = 22
for (let i = 4; i < rows.length; i++) {
const [nickName, remark, groupNickname, wxid, alias] = rows[i]
const row = sheet.getRow(currentRow)
row.getCell(1).value = nickName
row.getCell(2).value = remark
row.getCell(3).value = groupNickname
row.getCell(4).value = wxid
row.getCell(5).value = alias
row.alignment = { vertical: 'top', wrapText: true }
currentRow++
}
await workbook.xlsx.writeFile(outputPath)
}
return { success: true, count: members.length }
} catch (e) {
return { success: false, error: String(e) }
}
}
} }
export const groupAnalyticsService = new GroupAnalyticsService() export const groupAnalyticsService = new GroupAnalyticsService()

View File

@@ -43,6 +43,7 @@ export class KeyService {
private GetWindowThreadProcessId: any = null private GetWindowThreadProcessId: any = null
private IsWindowVisible: any = null private IsWindowVisible: any = null
private EnumChildWindows: any = null private EnumChildWindows: any = null
private PostMessageW: any = null
private WNDENUMPROC_PTR: any = null private WNDENUMPROC_PTR: any = null
// Advapi32 // Advapi32
@@ -57,6 +58,7 @@ export class KeyService {
private readonly HKEY_LOCAL_MACHINE = 0x80000002 private readonly HKEY_LOCAL_MACHINE = 0x80000002
private readonly HKEY_CURRENT_USER = 0x80000001 private readonly HKEY_CURRENT_USER = 0x80000001
private readonly ERROR_SUCCESS = 0 private readonly ERROR_SUCCESS = 0
private readonly WM_CLOSE = 0x0010
private getDllPath(): string { private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
@@ -224,6 +226,7 @@ export class KeyService {
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t']) this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t']) this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*']) this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
@@ -437,16 +440,60 @@ export class KeyService {
return fallbackPid ?? null return fallbackPid ?? null
} }
private async killWeChatProcesses() { private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const weixinPid = await this.findPidByImageName('Weixin.exe')
const wechatPid = await this.findPidByImageName('WeChat.exe')
if (!weixinPid && !wechatPid) return true
await new Promise(r => setTimeout(r, 400))
}
return false
}
private async closeWeChatWindows(): Promise<boolean> {
if (!this.ensureUser32()) return false
let requested = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
const className = this.getClassName(hWnd)
const classLower = (className || '').toLowerCase()
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
if (!isWeChatWindow) return true
requested = true
try { try {
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe']) this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe']) } catch { }
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
return requested
}
private async killWeChatProcesses(): Promise<boolean> {
const requested = await this.closeWeChatWindows()
if (requested) {
const gracefulOk = await this.waitForWeChatExit(1500)
if (gracefulOk) return true
}
try {
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
} catch (e) { } catch (e) {
// Ignore if not found // Ignore if not found
} }
await new Promise(r => setTimeout(r, 1000))
return await this.waitForWeChatExit(5000)
} }
// --- Window Detection --- // --- Window Detection ---
private getWindowTitle(hWnd: any): string { private getWindowTitle(hWnd: any): string {
@@ -605,12 +652,21 @@ export class KeyService {
} }
// 2. Restart WeChat // 2. Restart WeChat
onStatus?.('正在重启微信以进行获取...', 0) onStatus?.('正在关闭微信以进行获取...', 0)
await this.killWeChatProcesses() const closed = await this.killWeChatProcesses()
if (!closed) {
const err = '无法自动关闭微信,请手动退出后重试'
onStatus?.(err, 2)
return { success: false, error: err }
}
// 3. Launch // 3. Launch
onStatus?.('正在启动微信...', 0) onStatus?.('正在启动微信...', 0)
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' }) const sub = spawn(wechatPath, {
detached: true,
stdio: 'ignore',
cwd: dirname(wechatPath)
})
sub.unref() sub.unref()
// 4. Wait for Window & Get PID (Crucial change: discover PID from window) // 4. Wait for Window & Get PID (Crucial change: discover PID from window)

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}` }
} }

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.4.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.4.4",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
@@ -7380,6 +7380,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,17 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.4.4",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
"repository": {
"type": "git",
"url": "https://github.com/hicccc77/WeFlow"
},
"//": "二改不应改变此处的作者与应用信息", "//": "二改不应改变此处的作者与应用信息",
"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",
@@ -55,6 +59,8 @@
"appId": "com.WeFlow.app", "appId": "com.WeFlow.app",
"publish": { "publish": {
"provider": "github", "provider": "github",
"owner": "hicccc77",
"repo": "WeFlow",
"releaseType": "release" "releaseType": "release"
}, },
"productName": "WeFlow", "productName": "WeFlow",

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,63 +72,32 @@ 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.log('Hello verification aborted')
return
}
if (e.name === 'NotAllowedError') {
console.log('User cancelled Hello verification')
} else {
console.error('Hello verification error:', e) console.error('Hello verification error:', e)
// 仅在非手动取消时显示错误 setError(`验证失败: ${e.message || String(e)}`)
if (e.name !== 'AbortError') {
setError(`验证失败: ${e.message || e.name}`)
}
}
} finally { } finally {
if (!abortController.signal.aborted) {
setIsVerifying(false) setIsVerifying(false)
} }
} }
}
const handlePasswordSubmit = async (e?: React.FormEvent) => { const handlePasswordSubmit = async (e?: React.FormEvent) => {
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

@@ -23,7 +23,7 @@ export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
return return
} }
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => { const removeListener = window.electronAPI.whisper.onDownloadProgress((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
if (payload.percent !== undefined) { if (payload.percent !== undefined) {
setDownloadProgress(payload.percent) setDownloadProgress(payload.percent)
} }

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

@@ -491,7 +491,11 @@ function ChatPage(_props: ChatPageProps) {
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
const dllStart = performance.now() const dllStart = performance.now()
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as {
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}
const dllTime = performance.now() - dllStart const dllTime = performance.now() - dllStart
// DLL 调用后再次让出控制权 // DLL 调用后再次让出控制权
@@ -504,7 +508,8 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.contacts) { if (result.success && result.contacts) {
// 将更新加入队列,用于侧边栏更新 // 将更新加入队列,用于侧边栏更新
for (const [username, contact] of Object.entries(result.contacts)) { const contacts = result.contacts || {}
for (const [username, contact] of Object.entries(contacts)) {
contactUpdateQueueRef.current.set(username, contact) contactUpdateQueueRef.current.set(username, contact)
// 如果是自己的信息且当前个人头像为空,同步更新 // 如果是自己的信息且当前个人头像为空,同步更新
@@ -545,7 +550,11 @@ function ChatPage(_props: ChatPageProps) {
setIsRefreshingMessages(true) setIsRefreshingMessages(true)
try { try {
// 获取最新消息并增量添加 // 获取最新消息并增量添加
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as {
success: boolean;
messages?: Message[];
error?: string
}
if (!result.success || !result.messages) { if (!result.success || !result.messages) {
return return
} }
@@ -593,7 +602,12 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try { try {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) as {
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}
if (result.success && result.messages) { if (result.success && result.messages) {
if (offset === 0) { if (offset === 0) {
setMessages(result.messages) setMessages(result.messages)
@@ -690,7 +704,12 @@ function ChatPage(_props: ChatPageProps) {
try { try {
const lastMsg = messages[messages.length - 1] const lastMsg = messages[messages.length - 1]
// 从最后一条消息的时间开始往后找 // 从最后一条消息的时间开始往后找
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) as {
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}
if (result.success && result.messages) { if (result.success && result.messages) {
// 过滤掉已经在列表中的重复消息 // 过滤掉已经在列表中的重复消息
@@ -1501,6 +1520,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const imageClickTimerRef = useRef<number | null>(null) const imageClickTimerRef = useRef<number | null>(null)
const imageContainerRef = useRef<HTMLDivElement>(null) const imageContainerRef = useRef<HTMLDivElement>(null)
const imageAutoDecryptTriggered = useRef(false) const imageAutoDecryptTriggered = useRef(false)
const imageAutoHdTriggered = useRef<string | null>(null)
const [imageInView, setImageInView] = useState(false)
const imageForceHdAttempted = useRef<string | null>(null)
const imageForceHdPending = useRef(false)
const [voiceError, setVoiceError] = useState(false) const [voiceError, setVoiceError] = useState(false)
const [voiceLoading, setVoiceLoading] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false)
const [isVoicePlaying, setIsVoicePlaying] = useState(false) const [isVoicePlaying, setIsVoicePlaying] = useState(false)
@@ -1551,7 +1574,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const contentToUse = message.content || (message as any).rawContent || message.parsedContent const contentToUse = message.content || (message as any).rawContent || message.parsedContent
if (contentToUse) { if (contentToUse) {
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length) console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => { window.electronAPI.video.parseVideoMd5(contentToUse).then((result: { success: boolean; md5?: string; error?: string }) => {
console.log('[Video Debug] Parse result:', result) console.log('[Video Debug] Parse result:', result)
if (result && result.success && result.md5) { if (result && result.success && result.md5) {
console.log('[Video Debug] Parsed MD5:', result.md5) console.log('[Video Debug] Parsed MD5:', result.md5)
@@ -1559,7 +1582,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} else { } else {
console.error('[Video Debug] Failed to parse MD5:', result) console.error('[Video Debug] Failed to parse MD5:', result)
} }
}).catch((err) => { }).catch((err: unknown) => {
console.error('[Video Debug] Parse error:', err) console.error('[Video Debug] Parse error:', err)
}) })
} }
@@ -1667,7 +1690,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} }
const pending = senderAvatarLoading.get(sender) const pending = senderAvatarLoading.get(sender)
if (pending) { if (pending) {
pending.then((result) => { pending.then((result: { avatarUrl?: string; displayName?: string } | null) => {
if (result) { if (result) {
setSenderAvatarUrl(result.avatarUrl) setSenderAvatarUrl(result.avatarUrl)
setSenderName(result.displayName) setSenderName(result.displayName)
@@ -1697,10 +1720,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} }
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError]) }, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
const requestImageDecrypt = useCallback(async (forceUpdate = false) => { const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
if (!isImage || imageLoading) return if (!isImage) return
if (imageLoading) return
if (!silent) {
setImageLoading(true) setImageLoading(true)
setImageError(false) setImageError(false)
}
try { try {
if (message.imageMd5 || message.imageDatName) { if (message.imageMd5 || message.imageDatName) {
const result = await window.electronAPI.image.decrypt({ const result = await window.electronAPI.image.decrypt({
@@ -1726,14 +1752,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
setImageHasUpdate(false) setImageHasUpdate(false)
return return
} }
setImageError(true) if (!silent) setImageError(true)
} catch { } catch {
setImageError(true) if (!silent) setImageError(true)
} finally { } finally {
setImageLoading(false) if (!silent) setImageLoading(false)
} }
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
const triggerForceHd = useCallback(() => {
if (!message.imageMd5 && !message.imageDatName) return
if (imageForceHdAttempted.current === imageCacheKey) return
if (imageForceHdPending.current) return
imageForceHdAttempted.current = imageCacheKey
imageForceHdPending.current = true
requestImageDecrypt(true, true).finally(() => {
imageForceHdPending.current = false
})
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
const handleImageClick = useCallback(() => { const handleImageClick = useCallback(() => {
if (imageClickTimerRef.current) { if (imageClickTimerRef.current) {
window.clearTimeout(imageClickTimerRef.current) window.clearTimeout(imageClickTimerRef.current)
@@ -1769,7 +1806,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
sessionId: session.username, sessionId: session.username,
imageMd5: message.imageMd5 || undefined, imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName imageDatName: message.imageDatName
}).then((result) => { }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
if (cancelled) return if (cancelled) return
if (result.success && result.localPath) { if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath) imageDataUrlCache.set(imageCacheKey, result.localPath)
@@ -1787,7 +1824,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload) => { const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => {
const matchesCacheKey = const matchesCacheKey =
payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageMd5 ||
payload.cacheKey === message.imageDatName || payload.cacheKey === message.imageDatName ||
@@ -1804,7 +1841,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => { const unsubscribe = window.electronAPI.image.onCacheResolved((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => {
const matchesCacheKey = const matchesCacheKey =
payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageMd5 ||
payload.cacheKey === message.imageDatName || payload.cacheKey === message.imageDatName ||
@@ -1846,6 +1883,47 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
return () => observer.disconnect() return () => observer.disconnect()
}, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt]) }, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt])
// 进入视野时自动尝试切换高清图
useEffect(() => {
if (!isImage) return
const container = imageContainerRef.current
if (!container) return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setImageInView(entry.isIntersecting)
},
{ rootMargin: '120px', threshold: 0 }
)
observer.observe(container)
return () => observer.disconnect()
}, [isImage])
useEffect(() => {
if (!isImage || !imageHasUpdate || !imageInView) return
if (imageAutoHdTriggered.current === imageCacheKey) return
imageAutoHdTriggered.current = imageCacheKey
triggerForceHd()
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
useEffect(() => {
if (!isImage || !showImagePreview || !imageHasUpdate) return
if (imageAutoHdTriggered.current === imageCacheKey) return
imageAutoHdTriggered.current = imageCacheKey
triggerForceHd()
}, [isImage, showImagePreview, imageHasUpdate, imageCacheKey, triggerForceHd])
// 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清
useEffect(() => {
if (!isImage || !imageInView) return
triggerForceHd()
}, [isImage, imageInView, triggerForceHd])
useEffect(() => {
if (!isImage || !showImagePreview) return
triggerForceHd()
}, [isImage, showImagePreview, triggerForceHd])
useEffect(() => { useEffect(() => {
if (!isVoice) return if (!isVoice) return
@@ -1933,7 +2011,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isVoice || voiceDataUrl) return if (!isVoice || voiceDataUrl) return
window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId)) window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId))
.then(result => { .then((result: { success: boolean; hasCache: boolean; data?: string; error?: string }) => {
if (result.success && result.hasCache && result.data) { if (result.success && result.hasCache && result.data) {
const url = `data:audio/wav;base64,${result.data}` const url = `data:audio/wav;base64,${result.data}`
voiceDataUrlCache.set(voiceCacheKey, url) voiceDataUrlCache.set(voiceCacheKey, url)
@@ -2066,7 +2144,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
console.log('[Video Debug] Loading video info for MD5:', videoMd5) console.log('[Video Debug] Loading video info for MD5:', videoMd5)
setVideoLoading(true) setVideoLoading(true)
window.electronAPI.video.getVideoInfo(videoMd5).then((result) => { window.electronAPI.video.getVideoInfo(videoMd5).then((result: { success: boolean; exists: boolean; videoUrl?: string; coverUrl?: string; thumbUrl?: string; error?: string }) => {
console.log('[Video Debug] getVideoInfo result:', result) console.log('[Video Debug] getVideoInfo result:', result)
if (result && result.success) { if (result && result.success) {
setVideoInfo({ setVideoInfo({
@@ -2079,7 +2157,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
console.error('[Video Debug] Video info failed:', result) console.error('[Video Debug] Video info failed:', result)
setVideoInfo({ exists: false }) setVideoInfo({ exists: false })
} }
}).catch((err) => { }).catch((err: unknown) => {
console.error('[Video Debug] getVideoInfo error:', err) console.error('[Video Debug] getVideoInfo error:', err)
setVideoInfo({ exists: false }) setVideoInfo({ exists: false })
}).finally(() => { }).finally(() => {
@@ -2092,7 +2170,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
useEffect(() => { useEffect(() => {
window.electronAPI.config.get('autoTranscribeVoice').then((value) => { window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => {
setAutoTranscribeEnabled(value === true) setAutoTranscribeEnabled(value === true)
}) })
}, []) }, [])
@@ -2196,23 +2274,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
src={imageLocalPath} src={imageLocalPath}
alt="图片" alt="图片"
className="image-message" className="image-message"
onClick={() => setShowImagePreview(true)} onClick={() => {
if (imageHasUpdate) {
void requestImageDecrypt(true, true)
}
setShowImagePreview(true)
}}
onLoad={() => setImageError(false)} onLoad={() => setImageError(false)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
/> />
{imageHasUpdate && (
<button
className="image-update-button"
type="button"
title="发现更高清图片,点击更新"
onClick={(event) => {
event.stopPropagation()
void requestImageDecrypt(true)
}}
>
<RefreshCw size={14} />
</button>
)}
</div> </div>
{showImagePreview && ( {showImagePreview && (
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} /> <ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />

View File

@@ -45,18 +45,18 @@ function ContactsPage() {
if (contactsResult.success && contactsResult.contacts) { if (contactsResult.success && contactsResult.contacts) {
console.log('📊 总联系人数:', contactsResult.contacts.length) console.log('📊 总联系人数:', contactsResult.contacts.length)
console.log('📊 按类型统计:', { console.log('📊 按类型统计:', {
friends: contactsResult.contacts.filter(c => c.type === 'friend').length, friends: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'friend').length,
groups: contactsResult.contacts.filter(c => c.type === 'group').length, groups: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'group').length,
officials: contactsResult.contacts.filter(c => c.type === 'official').length, officials: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'official').length,
other: contactsResult.contacts.filter(c => c.type === 'other').length other: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'other').length
}) })
// 获取头像URL // 获取头像URL
const usernames = contactsResult.contacts.map(c => c.username) const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) { if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) { if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach(contact => { contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username] const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) { if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl contact.avatarUrl = enriched.avatarUrl

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,
@@ -187,7 +189,7 @@ function ExportPage() {
}, [loadSessions]) }, [loadSessions])
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => { const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
setExportProgress({ setExportProgress({
current: payload.current, current: payload.current,
total: payload.total, total: payload.total,
@@ -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

@@ -333,7 +333,7 @@
.group-avatar { .group-avatar {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 8px;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
@@ -346,11 +346,11 @@
.avatar-placeholder { .avatar-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); background: var(--bg-tertiary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: var(--text-secondary);
} }
} }
@@ -390,7 +390,7 @@
.skeleton-avatar { .skeleton-avatar {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 8px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
animation: pulse 1.5s infinite; animation: pulse 1.5s infinite;
} }
@@ -500,7 +500,7 @@
.group-avatar.large { .group-avatar.large {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: 10px;
overflow: hidden; overflow: hidden;
margin: 0 auto 16px; margin: 0 auto 16px;
@@ -513,11 +513,11 @@
.avatar-placeholder { .avatar-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); background: var(--bg-tertiary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: var(--text-secondary);
} }
} }
@@ -656,6 +656,32 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: none;
background: var(--bg-tertiary);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
-webkit-app-region: no-drag;
font-size: 12px;
flex-shrink: 0;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
} }
.content-body { .content-body {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker' import DateRangePicker from '../components/DateRangePicker'
@@ -39,6 +39,7 @@ function GroupAnalyticsPage() {
const [activeHours, setActiveHours] = useState<Record<number, number>>({}) const [activeHours, setActiveHours] = useState<Record<number, number>>({})
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null) const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
const [functionLoading, setFunctionLoading] = useState(false) const [functionLoading, setFunctionLoading] = useState(false)
const [isExportingMembers, setIsExportingMembers] = useState(false)
// 成员详情弹框 // 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null) const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
@@ -181,6 +182,10 @@ function GroupAnalyticsPage() {
return num.toLocaleString() return num.toLocaleString()
} }
const sanitizeFileName = (name: string) => {
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
}
const getHourlyOption = () => { const getHourlyOption = () => {
const hours = Array.from({ length: 24 }, (_, i) => i) const hours = Array.from({ length: 24 }, (_, i) => i)
const data = hours.map(h => activeHours[h] || 0) const data = hours.map(h => activeHours[h] || 0)
@@ -252,6 +257,35 @@ function GroupAnalyticsPage() {
setCopiedField(null) setCopiedField(null)
} }
const handleExportMembers = async () => {
if (!selectedGroup || isExportingMembers) return
setIsExportingMembers(true)
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_群成员列表`)
const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx`
const saveResult = await window.electronAPI.dialog.saveFile({
title: '导出群成员列表',
defaultPath,
filters: [{ name: 'Excel', extensions: ['xlsx'] }]
})
if (!saveResult || saveResult.canceled || !saveResult.filePath) return
const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath)
if (result.success) {
alert(`导出成功,共 ${result.count ?? members.length}`)
} else {
alert(`导出失败:${result.error || '未知错误'}`)
}
} catch (e) {
console.error('导出群成员失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExportingMembers(false)
}
}
const handleCopy = async (text: string, field: string) => { const handleCopy = async (text: string, field: string) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
@@ -423,6 +457,12 @@ function GroupAnalyticsPage() {
onRangeComplete={handleDateRangeComplete} onRangeComplete={handleDateRangeComplete}
/> />
)} )}
{selectedFunction === 'members' && (
<button className="export-btn" onClick={handleExportMembers} disabled={functionLoading || isExportingMembers}>
{isExportingMembers ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
<span></span>
</button>
)}
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}> <button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} /> <RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
</button> </button>

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,16 +146,19 @@ 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: { message: string; level: number }) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
}) })
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message) setImageKeyStatus(payload.message)
}) })
return () => { return () => {
@@ -261,7 +270,7 @@ function SettingsPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => { const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
if (typeof payload.percent === 'number') { if (typeof payload.percent === 'number') {
setWhisperDownloadProgress(payload.percent) setWhisperDownloadProgress(payload.percent)
} }
@@ -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,
@@ -358,7 +373,7 @@ function SettingsPage() {
const applyWxidSelection = async ( const applyWxidSelection = async (
selectedWxid: string, selectedWxid: string,
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string } options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string; keysOverride?: WxidKeys }
) => { ) => {
if (!selectedWxid) return if (!selectedWxid) return
@@ -374,9 +389,9 @@ function SettingsPage() {
} }
const preferCurrentKeys = options?.preferCurrentKeys ?? false const preferCurrentKeys = options?.preferCurrentKeys ?? false
const keys = preferCurrentKeys const keys = options?.keysOverride ?? (preferCurrentKeys
? buildKeysFromState() ? buildKeysFromState()
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid)) : buildKeysFromConfig(await configService.getWxidConfig(selectedWxid)))
setWxid(selectedWxid) setWxid(selectedWxid)
applyKeysToState(keys) applyKeysToState(keys)
@@ -444,7 +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) {
setDbPath(result.filePaths[0]) const selectedPath = result.filePaths[0]
setDbPath(selectedPath)
await configService.setDbPath(selectedPath)
showMessage('已选择数据库目录', true) showMessage('已选择数据库目录', true)
} }
} catch (e: any) { } catch (e: any) {
@@ -454,7 +471,7 @@ function SettingsPage() {
const handleScanWxid = async ( const handleScanWxid = async (
silent = false, silent = false,
options?: { preferCurrentKeys?: boolean; showDialog?: boolean } options?: { preferCurrentKeys?: boolean; showDialog?: boolean; keysOverride?: WxidKeys }
) => { ) => {
if (!dbPath) { if (!dbPath) {
if (!silent) showMessage('请先选择数据库目录', false) if (!silent) showMessage('请先选择数据库目录', false)
@@ -468,7 +485,8 @@ function SettingsPage() {
await applyWxidSelection(wxids[0].wxid, { await applyWxidSelection(wxids[0].wxid, {
preferCurrentKeys: options?.preferCurrentKeys ?? false, preferCurrentKeys: options?.preferCurrentKeys ?? false,
showToast: !silent, showToast: !silent,
toastText: `已检测到账号:${wxids[0].wxid}` toastText: `已检测到账号:${wxids[0].wxid}`,
keysOverride: options?.keysOverride
}) })
} else if (wxids.length > 1 && allowDialog) { } else if (wxids.length > 1 && allowDialog) {
setShowWxidSelect(true) setShowWxidSelect(true)
@@ -488,7 +506,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) {
setCachePath(result.filePaths[0]) const selectedPath = result.filePaths[0]
setCachePath(selectedPath)
await configService.setCachePath(selectedPath)
showMessage('已选择缓存目录', true) showMessage('已选择缓存目录', true)
} }
} catch (e: any) { } catch (e: any) {
@@ -554,7 +574,9 @@ function SettingsPage() {
setDecryptKey(result.key) setDecryptKey(result.key)
setDbKeyStatus('密钥获取成功') setDbKeyStatus('密钥获取成功')
showMessage('已自动获取解密密钥', true) showMessage('已自动获取解密密钥', true)
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false }) await syncCurrentKeys({ decryptKey: result.key, wxid })
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride })
} else { } else {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
@@ -575,12 +597,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
@@ -804,10 +839,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, wxid }))
// showMessage('解密密钥已保存', true) // showMessage('解密密钥已保存', true)
} }
}} }}
@@ -839,11 +875,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">
@@ -862,12 +901,43 @@ 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) { const previousWxid = wxid
await configService.setMyWxid(wxid) setWxid(value)
await syncCurrentKeys() // Sync keys to the new wxid entry scheduleConfigSave('wxid', async () => {
if (previousWxid && previousWxid !== value) {
const currentKeys = buildKeysFromState()
await configService.setWxidConfig(previousWxid, {
decryptKey: currentKeys.decryptKey,
imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0,
imageAesKey: currentKeys.imageAesKey
})
} }
if (value) {
await configService.setMyWxid(value)
await syncCurrentKeys({ wxid: value }) // Sync keys to the new wxid entry
}
if (value && previousWxid !== value) {
if (isDbConnected) {
try {
await window.electronAPI.chat.close()
const result = await window.electronAPI.chat.connect()
setDbConnected(result.success, dbPath || undefined)
if (!result.success && result.error) {
showMessage(result.error, false)
}
} catch (e: any) {
showMessage(`切换账号后重新连接失败: ${e}`, false)
setDbConnected(false)
}
}
clearAnalyticsStoreCache()
resetChatStore()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: value } }))
}
})
}} }}
/> />
</div> </div>
@@ -881,8 +951,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, wxid }))
}
}}
/> />
</div> </div>
@@ -893,8 +969,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, wxid }))
}}
/> />
<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 ? '获取中...' : '自动获取图片密钥'}
@@ -1009,8 +1088,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>
@@ -1064,6 +1146,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
} }
@@ -1073,6 +1164,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">
@@ -1087,6 +1179,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>
@@ -1126,6 +1219,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>
@@ -1210,6 +1304,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>
@@ -1239,6 +1334,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>
) )
} }
@@ -1252,14 +1386,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

@@ -165,8 +165,8 @@ export default function SnsPage() {
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight; scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
} }
const existingIds = new Set(currentPosts.map(p => p.id)); const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id));
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id)); const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) { if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev]); setPosts(prev => [...uniqueNewer, ...prev]);
@@ -253,7 +253,7 @@ export default function SnsPage() {
})) }))
setContacts(initialContacts) setContacts(initialContacts)
const usernames = initialContacts.map(c => c.username) const usernames = initialContacts.map((c: { username: string }) => c.username)
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (enriched.success && enriched.contacts) { if (enriched.success && enriched.contacts) {
setContacts(prev => prev.map(c => { setContacts(prev => prev.map(c => {

View File

@@ -435,6 +435,58 @@
} }
} }
.wxid-select {
position: relative;
}
.wxid-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
max-height: 220px;
overflow: auto;
z-index: 20;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
}
.wxid-option {
width: 100%;
border: none;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 13px;
&:hover {
background: var(--bg-hover);
}
&.active {
background: var(--primary-light);
}
}
.wxid-name {
font-weight: 600;
}
.wxid-time {
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
}
.field-with-toggle { .field-with-toggle {
position: relative; position: relative;
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { dialog } from '../services/ipc' import { dialog } from '../services/ipc'
@@ -35,6 +35,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([]) const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidSelectRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [isDetectingPath, setIsDetectingPath] = useState(false) const [isDetectingPath, setIsDetectingPath] = useState(false)
@@ -106,10 +108,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
}) })
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message) setImageKeyStatus(payload.message)
}) })
return () => { return () => {
@@ -127,8 +129,22 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
useEffect(() => { useEffect(() => {
setWxidOptions([]) setWxidOptions([])
setWxid('') setWxid('')
setShowWxidSelect(false)
}, [dbPath]) }, [dbPath])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!showWxidSelect) return
const target = event.target as Node
if (wxidSelectRef.current && !wxidSelectRef.current.contains(target)) {
setShowWxidSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect])
const currentStep = steps[stepIndex] const currentStep = steps[stepIndex]
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}` const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
const showWindowControls = standalone const showWindowControls = standalone
@@ -217,6 +233,28 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
} }
const handleScanWxidCandidates = async () => {
if (!dbPath) {
setError('请先选择数据库目录')
return
}
if (isScanningWxid) return
setIsScanningWxid(true)
setError('')
try {
const wxids = await window.electronAPI.dbPath.scanWxidCandidates(dbPath)
setWxidOptions(wxids)
setShowWxidSelect(true)
if (!wxids.length) {
setError('未检测到可用的账号目录,请检查路径')
}
} catch (e) {
setError(`扫描失败: ${e}`)
} finally {
setIsScanningWxid(false)
}
}
const handleAutoGetDbKey = async () => { const handleAutoGetDbKey = async () => {
if (isFetchingDbKey) return if (isFetchingDbKey) return
setIsFetchingDbKey(true) setIsFetchingDbKey(true)
@@ -556,14 +594,35 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'key' && ( {currentStep.id === 'key' && (
<div className="form-group"> <div className="form-group">
<label className="field-label"> (Wxid)</label> <label className="field-label"> (Wxid)</label>
<div className="wxid-select" ref={wxidSelectRef}>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="等待获取..." placeholder="点击选择..."
value={wxid} value={wxid}
readOnly readOnly
onClick={handleScanWxidCandidates}
onChange={(e) => setWxid(e.target.value)} onChange={(e) => setWxid(e.target.value)}
/> />
{showWxidSelect && wxidOptions.length > 0 && (
<div className="wxid-dropdown">
{wxidOptions.map((opt) => (
<button
key={opt.wxid}
type="button"
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => {
setWxid(opt.wxid)
setShowWxidSelect(false)
}}
>
<span className="wxid-name">{opt.wxid}</span>
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
</button>
))}
</div>
)}
</div>
<label className="field-label mt-4"></label> <label className="field-label mt-4"></label>
<div className="field-with-toggle"> <div className="field-with-toggle">
@@ -733,4 +792,3 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
export default WelcomePage export default WelcomePage

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 },

View File

@@ -42,6 +42,7 @@ export interface ElectronAPI {
dbPath: { dbPath: {
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }> autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
scanWxids: (rootPath: string) => Promise<WxidInfo[]> scanWxids: (rootPath: string) => Promise<WxidInfo[]>
scanWxidCandidates: (rootPath: string) => Promise<WxidInfo[]>
getDefault: () => Promise<string> getDefault: () => Promise<string>
} }
wcdb: { wcdb: {
@@ -232,6 +233,11 @@ export interface ElectronAPI {
} }
error?: string error?: string
}> }>
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
success: boolean
count?: number
error?: string
}>
} }
annualReport: { annualReport: {
getAvailableYears: () => Promise<{ getAvailableYears: () => Promise<{

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
}
}