Compare commits

...

25 Commits

Author SHA1 Message Date
xuncha
97f0077e95 打包你快修好啊 我服了 2026-01-31 16:14:00 +08:00
xuncha
cf292ca9e2 hh 2026-01-31 16:06:36 +08:00
xuncha
2cfe0d8ee8 ee 2026-01-31 16:01:21 +08:00
xuncha
baa949a301 呃呃 2026-01-31 15:56:27 +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
33 changed files with 1125 additions and 899 deletions

View File

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

View File

@@ -20,6 +20,7 @@ import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
import { snsService } from './services/snsService'
import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService'
// 配置自动更新
@@ -798,6 +799,17 @@ function registerIpcHandlers() {
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) => {
const onProgress = (progress: ExportProgress) => {
@@ -894,6 +906,10 @@ function registerIpcHandlers() {
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 () => {
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')
},
// 认证
auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
},
// 对话框
dialog: {
@@ -178,7 +183,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
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),
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

@@ -73,6 +73,7 @@ export interface ExportOptions {
exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
@@ -141,6 +142,12 @@ class ExportService {
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 {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
@@ -186,6 +193,20 @@ class ExportService {
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 包含类似 protobuf 编码的数据,格式示例:
@@ -859,10 +880,10 @@ class ExportService {
options: {
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportVoiceAsText?: boolean
includeVoiceWithTranscript?: boolean
exportVideos?: boolean
}
): Promise<MediaExportItem | null> {
const localType = msg.localType
@@ -877,8 +898,7 @@ class ExportService {
// 语音消息
if (localType === 34) {
const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText
if (shouldKeepVoiceFile && options.exportVoices) {
if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
}
if (options.exportVoiceAsText) {
@@ -1233,7 +1253,7 @@ class ExportService {
mediaRelativePrefix: string
} {
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 outputBaseName = path.basename(outputPath, path.extname(outputPath))
const useSharedMediaLayout = options.sessionLayout === 'shared'
@@ -1681,10 +1701,6 @@ class ExportService {
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows
@@ -1693,6 +1709,14 @@ class ExportService {
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) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
@@ -1707,7 +1731,8 @@ class ExportService {
const t = msg.localType
return (t === 3 && options.exportImages) || // 图片
(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'
})
// 并行导出媒体,限制 8 个并发
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
// 并行导出媒体,并发数跟随导出设置
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
@@ -1738,10 +1764,6 @@ class ExportService {
}
// ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
@@ -1895,10 +1917,6 @@ class ExportService {
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
@@ -1906,6 +1924,21 @@ class ExportService {
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)
// ========== 阶段1并行导出媒体文件 ==========
@@ -1914,7 +1947,8 @@ class ExportService {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(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'
})
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
@@ -1944,10 +1979,6 @@ class ExportService {
}
// ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
@@ -1988,10 +2019,10 @@ class ExportService {
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
if (mediaItem) {
content = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
content = mediaItem.relativePath
} else {
content = this.parseMessageContent(msg.content, msg.localType)
}
@@ -2156,10 +2187,6 @@ class ExportService {
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
@@ -2167,6 +2194,21 @@ class ExportService {
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?.({
current: 30,
total: 100,
@@ -2297,7 +2339,8 @@ class ExportService {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(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'
})
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
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>()
if (voiceMessages.length > 0) {
@@ -2416,13 +2456,21 @@ class ExportService {
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath
|| this.formatPlainExportContent(
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const contentValue = shouldUseTranscript
? this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
)
: (mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
))
// 调试日志
if (msg.localType === 3 || msg.localType === 47) {
@@ -2549,6 +2597,16 @@ class ExportService {
const sessionInfo = await this.getContactInfo(sessionId)
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?.({
current: 0,
total: 100,
@@ -2556,10 +2614,6 @@ class ExportService {
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
@@ -2567,6 +2621,21 @@ class ExportService {
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 { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
@@ -2575,7 +2644,8 @@ class ExportService {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(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'
})
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
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>()
if (voiceMessages.length > 0) {
@@ -2637,13 +2705,21 @@ class ExportService {
const msg = sortedMessages[i]
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath
|| this.formatPlainExportContent(
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const contentValue = shouldUseTranscript
? this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
)
: (mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
))
let senderRole: string
let senderWxid: string
@@ -2763,7 +2839,7 @@ class ExportService {
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices) ||
t === 43
(t === 43 && options.exportVideos)
})
: []
@@ -2787,7 +2863,7 @@ class ExportService {
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
includeVoiceWithTranscript: true,
exportVideos: true
exportVideos: options.exportVideos
})
mediaCache.set(mediaKey, mediaItem)
}
@@ -3094,7 +3170,7 @@ class ExportService {
}
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session')
: '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 { wcdbService } from './wcdbService'
import { chatService } from './chatService'
export interface GroupChatInfo {
username: string
@@ -41,6 +45,30 @@ class GroupAnalyticsService {
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 {
const trimmed = name.trim()
if (!trimmed) return trimmed
@@ -65,6 +93,139 @@ class GroupAnalyticsService {
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 }> {
try {
const conn = await this.ensureConnected()
@@ -80,23 +241,38 @@ class GroupAnalyticsService {
.map((row) => row.username || row.user_name || row.userName || '')
.filter((username) => username.includes('@chatroom'))
const [displayNames, avatarUrls, memberCounts] = await Promise.all([
wcdbService.getDisplayNames(groupIds),
wcdbService.getAvatarUrls(groupIds),
wcdbService.getGroupMemberCounts(groupIds)
const [memberCounts, contactInfo] = await Promise.all([
wcdbService.getGroupMemberCounts(groupIds),
chatService.enrichSessionsContactInfo(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[] = []
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({
username: groupId,
displayName: displayNames.success && displayNames.map
? (displayNames.map[groupId] || groupId)
: groupId,
displayName,
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
? memberCounts.map[groupId]
: 0,
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined
avatarUrl
})
}
@@ -248,6 +424,187 @@ class GroupAnalyticsService {
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()

View File

@@ -57,6 +57,7 @@ export class WcdbCore {
private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null
private wcdbGetSnsTimeline: any = null
private wcdbVerifyUser: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null
@@ -259,24 +260,24 @@ export class WcdbCore {
let protectionOk = false
for (const resPath of resourcePaths) {
try {
console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
// console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) {
console.log(`[WCDB] InitProtection 成功: ${resPath}`)
// console.log(`[WCDB] InitProtection 成功: ${resPath}`)
break
}
} catch (e) {
console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
}
}
if (!protectionOk) {
console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
this.writeLog('InitProtection 失败,继续运行')
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
// this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行
}
} 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
}
// 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()
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 }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
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 })
}
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('verifyUser', { message, hwnd })
}
}
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':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break
case 'verifyUser':
result = await core.verifyUser(payload.message, payload.hwnd)
break
default:
result = { success: false, error: `Unknown method: ${type}` }
}

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "weflow",
"version": "1.4.1",
"version": "1.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "weflow",
"version": "1.4.1",
"version": "1.4.2",
"hasInstallScript": true,
"dependencies": {
"better-sqlite3": "^12.5.0",
@@ -7380,6 +7380,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"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": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",

View File

@@ -1,13 +1,13 @@
{
"name": "weflow",
"version": "1.4.1",
"version": "1.4.3",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",
"//": "二改不应改变此处的作者与应用信息",
"scripts": {
"postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'",
"rebuild": "electron-rebuild",
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -76,7 +76,7 @@
}
.sidebar-footer {
padding: 0 8px;
padding: 0 12px;
border-top: 1px solid var(--border-color);
padding-top: 12px;
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 { 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'
function Sidebar() {
const location = useLocation()
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) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
@@ -94,18 +102,21 @@ function Sidebar() {
<span className="nav-label"></span>
</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>
<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
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}

View File

@@ -23,7 +23,7 @@ export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
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) {
setDownloadProgress(payload.percent)
}

View File

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

View File

@@ -491,7 +491,11 @@ function ChatPage(_props: ChatPageProps) {
await new Promise(resolve => setTimeout(resolve, 0))
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
// DLL 调用后再次让出控制权
@@ -504,7 +508,8 @@ function ChatPage(_props: ChatPageProps) {
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)
// 如果是自己的信息且当前个人头像为空,同步更新
@@ -545,7 +550,11 @@ function ChatPage(_props: ChatPageProps) {
setIsRefreshingMessages(true)
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) {
return
}
@@ -593,7 +602,12 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
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 (offset === 0) {
setMessages(result.messages)
@@ -690,7 +704,12 @@ function ChatPage(_props: ChatPageProps) {
try {
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) {
// 过滤掉已经在列表中的重复消息
@@ -1501,6 +1520,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const imageClickTimerRef = useRef<number | null>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
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 [voiceLoading, setVoiceLoading] = 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
if (contentToUse) {
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)
if (result && result.success && result.md5) {
console.log('[Video Debug] Parsed MD5:', result.md5)
@@ -1559,7 +1582,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} else {
console.error('[Video Debug] Failed to parse MD5:', result)
}
}).catch((err) => {
}).catch((err: unknown) => {
console.error('[Video Debug] Parse error:', err)
})
}
@@ -1667,7 +1690,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
}
const pending = senderAvatarLoading.get(sender)
if (pending) {
pending.then((result) => {
pending.then((result: { avatarUrl?: string; displayName?: string } | null) => {
if (result) {
setSenderAvatarUrl(result.avatarUrl)
setSenderName(result.displayName)
@@ -1697,10 +1720,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
}
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
const requestImageDecrypt = useCallback(async (forceUpdate = false) => {
if (!isImage || imageLoading) return
setImageLoading(true)
setImageError(false)
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
if (!isImage) return
if (imageLoading) return
if (!silent) {
setImageLoading(true)
setImageError(false)
}
try {
if (message.imageMd5 || message.imageDatName) {
const result = await window.electronAPI.image.decrypt({
@@ -1726,14 +1752,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
setImageHasUpdate(false)
return
}
setImageError(true)
if (!silent) setImageError(true)
} catch {
setImageError(true)
if (!silent) setImageError(true)
} finally {
setImageLoading(false)
if (!silent) setImageLoading(false)
}
}, [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(() => {
if (imageClickTimerRef.current) {
window.clearTimeout(imageClickTimerRef.current)
@@ -1769,7 +1806,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName
}).then((result) => {
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
if (cancelled) return
if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath)
@@ -1787,7 +1824,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => {
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 =
payload.cacheKey === message.imageMd5 ||
payload.cacheKey === message.imageDatName ||
@@ -1804,7 +1841,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => {
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 =
payload.cacheKey === message.imageMd5 ||
payload.cacheKey === message.imageDatName ||
@@ -1846,6 +1883,47 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
return () => observer.disconnect()
}, [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(() => {
if (!isVoice) return
@@ -1933,7 +2011,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => {
if (!isVoice || voiceDataUrl) return
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) {
const url = `data:audio/wav;base64,${result.data}`
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)
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)
if (result && result.success) {
setVideoInfo({
@@ -2079,7 +2157,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
console.error('[Video Debug] Video info failed:', result)
setVideoInfo({ exists: false })
}
}).catch((err) => {
}).catch((err: unknown) => {
console.error('[Video Debug] getVideoInfo error:', err)
setVideoInfo({ exists: false })
}).finally(() => {
@@ -2092,7 +2170,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
useEffect(() => {
window.electronAPI.config.get('autoTranscribeVoice').then((value) => {
window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => {
setAutoTranscribeEnabled(value === true)
})
}, [])
@@ -2196,23 +2274,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
src={imageLocalPath}
alt="图片"
className="image-message"
onClick={() => setShowImagePreview(true)}
onClick={() => {
if (imageHasUpdate) {
void requestImageDecrypt(true, true)
}
setShowImagePreview(true)
}}
onLoad={() => setImageError(false)}
onError={() => setImageError(true)}
/>
{imageHasUpdate && (
<button
className="image-update-button"
type="button"
title="发现更高清图片,点击更新"
onClick={(event) => {
event.stopPropagation()
void requestImageDecrypt(true)
}}
>
<RefreshCw size={14} />
</button>
)}
</div>
{showImagePreview && (
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />

View File

@@ -45,18 +45,18 @@ function ContactsPage() {
if (contactsResult.success && contactsResult.contacts) {
console.log('📊 总联系人数:', contactsResult.contacts.length)
console.log('📊 按类型统计:', {
friends: contactsResult.contacts.filter(c => c.type === 'friend').length,
groups: contactsResult.contacts.filter(c => c.type === 'group').length,
officials: contactsResult.contacts.filter(c => c.type === 'official').length,
other: contactsResult.contacts.filter(c => c.type === 'other').length
friends: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'friend').length,
groups: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'group').length,
officials: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'official').length,
other: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'other').length
})
// 获取头像URL
const usernames = contactsResult.contacts.map(c => c.username)
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach(contact => {
contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username]
if (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
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
exportVoiceAsText: boolean
excelCompactColumns: boolean
@@ -65,6 +66,7 @@ function ExportPage() {
exportMedia: false,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: true,
excelCompactColumns: true,
@@ -187,7 +189,7 @@ function ExportPage() {
}, [loadSessions])
useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
setExportProgress({
current: payload.current,
total: payload.total,
@@ -257,6 +259,7 @@ function ExportPage() {
exportMedia: true,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: true
}
@@ -286,6 +289,7 @@ function ExportPage() {
exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportVideos: options.exportMedia && options.exportVideos,
exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns,
@@ -609,7 +613,7 @@ function ExportPage() {
)}
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle">//</p>
<p className="setting-subtitle">///</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
@@ -661,7 +665,7 @@ function ExportPage() {
<label className="media-checkbox-row">
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
@@ -672,6 +676,21 @@ function ExportPage() {
<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' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>

View File

@@ -333,7 +333,7 @@
.group-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
@@ -346,11 +346,11 @@
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
color: var(--text-secondary);
}
}
@@ -390,7 +390,7 @@
.skeleton-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
border-radius: 8px;
background: var(--bg-tertiary);
animation: pulse 1.5s infinite;
}
@@ -500,7 +500,7 @@
.group-avatar.large {
width: 80px;
height: 80px;
border-radius: 50%;
border-radius: 10px;
overflow: hidden;
margin: 0 auto 16px;
@@ -513,11 +513,11 @@
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
color: var(--text-secondary);
}
}
@@ -656,6 +656,32 @@
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 {

View File

@@ -1,5 +1,5 @@
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 ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
@@ -39,6 +39,7 @@ function GroupAnalyticsPage() {
const [activeHours, setActiveHours] = useState<Record<number, number>>({})
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
const [functionLoading, setFunctionLoading] = useState(false)
const [isExportingMembers, setIsExportingMembers] = useState(false)
// 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
@@ -181,6 +182,10 @@ function GroupAnalyticsPage() {
return num.toLocaleString()
}
const sanitizeFileName = (name: string) => {
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
}
const getHourlyOption = () => {
const hours = Array.from({ length: 24 }, (_, i) => i)
const data = hours.map(h => activeHours[h] || 0)
@@ -252,6 +257,35 @@ function GroupAnalyticsPage() {
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) => {
try {
await navigator.clipboard.writeText(text)
@@ -423,6 +457,12 @@ function GroupAnalyticsPage() {
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}>
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
</button>

View File

@@ -62,9 +62,11 @@ function SettingsPage() {
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
@@ -96,6 +98,7 @@ function SettingsPage() {
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
// 安全设置 state
const [authEnabled, setAuthEnabled] = useState(false)
@@ -125,6 +128,9 @@ function SettingsPage() {
useEffect(() => {
loadConfig()
loadAppVersion()
return () => {
Object.values(saveTimersRef.current).forEach((timer) => clearTimeout(timer))
}
}, [])
// 点击外部关闭下拉框
@@ -140,16 +146,19 @@ function SettingsPage() {
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
setShowExportConcurrencySelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message)
})
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => {
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message)
})
return () => {
@@ -261,7 +270,7 @@ function SettingsPage() {
}, [])
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') {
setWhisperDownloadProgress(payload.percent)
}
@@ -334,6 +343,12 @@ function SettingsPage() {
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 => ({
decryptKey: wxidConfig?.decryptKey || '',
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
@@ -358,7 +373,7 @@ function SettingsPage() {
const applyWxidSelection = async (
selectedWxid: string,
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string }
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string; keysOverride?: WxidKeys }
) => {
if (!selectedWxid) return
@@ -374,9 +389,9 @@ function SettingsPage() {
}
const preferCurrentKeys = options?.preferCurrentKeys ?? false
const keys = preferCurrentKeys
const keys = options?.keysOverride ?? (preferCurrentKeys
? buildKeysFromState()
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid)))
setWxid(selectedWxid)
applyKeysToState(keys)
@@ -444,7 +459,9 @@ function SettingsPage() {
try {
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
setDbPath(result.filePaths[0])
const selectedPath = result.filePaths[0]
setDbPath(selectedPath)
await configService.setDbPath(selectedPath)
showMessage('已选择数据库目录', true)
}
} catch (e: any) {
@@ -454,7 +471,7 @@ function SettingsPage() {
const handleScanWxid = async (
silent = false,
options?: { preferCurrentKeys?: boolean; showDialog?: boolean }
options?: { preferCurrentKeys?: boolean; showDialog?: boolean; keysOverride?: WxidKeys }
) => {
if (!dbPath) {
if (!silent) showMessage('请先选择数据库目录', false)
@@ -468,7 +485,8 @@ function SettingsPage() {
await applyWxidSelection(wxids[0].wxid, {
preferCurrentKeys: options?.preferCurrentKeys ?? false,
showToast: !silent,
toastText: `已检测到账号:${wxids[0].wxid}`
toastText: `已检测到账号:${wxids[0].wxid}`,
keysOverride: options?.keysOverride
})
} else if (wxids.length > 1 && allowDialog) {
setShowWxidSelect(true)
@@ -488,7 +506,9 @@ function SettingsPage() {
try {
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
setCachePath(result.filePaths[0])
const selectedPath = result.filePaths[0]
setCachePath(selectedPath)
await configService.setCachePath(selectedPath)
showMessage('已选择缓存目录', true)
}
} catch (e: any) {
@@ -554,7 +574,9 @@ function SettingsPage() {
setDecryptKey(result.key)
setDbKeyStatus('密钥获取成功')
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 {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
setIsManualStartPrompt(true)
@@ -575,12 +597,25 @@ function SettingsPage() {
handleAutoGetDbKey()
}
// Helper to sync current keys to wxid config
const syncCurrentKeys = async () => {
const keys = buildKeysFromState()
// Debounce config writes to avoid excessive disk IO
const scheduleConfigSave = (key: string, task: () => Promise<void> | void, delay = 300) => {
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)
if (wxid) {
await configService.setWxidConfig(wxid, {
const wxidToUse = options?.wxid ?? wxid
if (wxidToUse) {
await configService.setWxidConfig(wxidToUse, {
decryptKey: keys.decryptKey,
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
imageAesKey: keys.imageAesKey
@@ -804,10 +839,11 @@ function SettingsPage() {
type={showDecryptKey ? 'text' : 'password'}
placeholder="例如: a1b2c3d4e5f6..."
value={decryptKey}
onChange={(e) => setDecryptKey(e.target.value)}
onBlur={async () => {
if (decryptKey && decryptKey.length === 64) {
await syncCurrentKeys()
onChange={(e) => {
const value = e.target.value
setDecryptKey(value)
if (value && value.length === 64) {
scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value, wxid }))
// showMessage('解密密钥已保存', true)
}
}}
@@ -839,11 +875,14 @@ function SettingsPage() {
type="text"
placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
value={dbPath}
onChange={(e) => setDbPath(e.target.value)}
onBlur={async () => {
if (dbPath) {
await configService.setDbPath(dbPath)
}
onChange={(e) => {
const value = e.target.value
setDbPath(value)
scheduleConfigSave('dbPath', async () => {
if (value) {
await configService.setDbPath(value)
}
})
}}
/>
<div className="btn-row">
@@ -862,12 +901,43 @@ function SettingsPage() {
type="text"
placeholder="例如: wxid_xxxxxx"
value={wxid}
onChange={(e) => setWxid(e.target.value)}
onBlur={async () => {
if (wxid) {
await configService.setMyWxid(wxid)
await syncCurrentKeys() // Sync keys to the new wxid entry
}
onChange={(e) => {
const value = e.target.value
const previousWxid = wxid
setWxid(value)
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>
@@ -881,8 +951,14 @@ function SettingsPage() {
type="text"
placeholder="例如: 0xA4"
value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)}
onBlur={syncCurrentKeys}
onChange={(e) => {
const value = e.target.value
setImageXorKey(value)
const parsed = parseImageXorKey(value)
if (value === '' || parsed !== null) {
scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value, wxid }))
}
}}
/>
</div>
@@ -893,8 +969,11 @@ function SettingsPage() {
type="text"
placeholder="16 位 AES 密钥"
value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)}
onBlur={syncCurrentKeys}
onChange={(e) => {
const value = e.target.value
setImageAesKey(value)
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
}}
/>
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
@@ -1009,8 +1088,11 @@ function SettingsPage() {
type="text"
placeholder="留空使用默认目录"
value={whisperModelDir}
onChange={(e) => setWhisperModelDir(e.target.value)}
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
onChange={(e) => {
const value = e.target.value
setWhisperModelDir(value)
scheduleConfigSave('whisperModelDir', () => configService.setWhisperModelDir(value))
}}
/>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> </button>
@@ -1064,6 +1146,15 @@ function SettingsPage() {
{ 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) => {
return options.find((option) => option.value === value)?.label ?? value
}
@@ -1073,6 +1164,7 @@ function SettingsPage() {
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
const exportConcurrencyLabel = String(exportDefaultConcurrency)
return (
<div className="tab-content">
@@ -1087,6 +1179,7 @@ function SettingsPage() {
setShowExportFormatSelect(!showExportFormatSelect)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportFormatLabel}</span>
@@ -1126,6 +1219,7 @@ function SettingsPage() {
setShowExportDateRangeSelect(!showExportDateRangeSelect)
setShowExportFormatSelect(false)
setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportDateRangeLabel}</span>
@@ -1210,6 +1304,7 @@ function SettingsPage() {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
@@ -1239,6 +1334,45 @@ function SettingsPage() {
</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>
)
}
@@ -1252,14 +1386,23 @@ function SettingsPage() {
type="text"
placeholder="留空使用默认目录"
value={cachePath}
onChange={(e) => setCachePath(e.target.value)}
onBlur={async () => {
await configService.setCachePath(cachePath)
onChange={(e) => {
const value = e.target.value
setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}}
/>
<div className="btn-row">
<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>

View File

@@ -165,8 +165,8 @@ export default function SnsPage() {
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
}
const existingIds = new Set(currentPosts.map(p => p.id));
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id));
const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id));
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev]);
@@ -253,7 +253,7 @@ export default function SnsPage() {
}))
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)
if (enriched.success && enriched.contacts) {
setContacts(prev => prev.map(c => {

View File

@@ -106,10 +106,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message)
})
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => {
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message)
})
return () => {

View File

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

View File

@@ -232,6 +232,11 @@ export interface ElectronAPI {
}
error?: string
}>
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
success: boolean
count?: number
error?: string
}>
}
annualReport: {
getAvailableYears: () => Promise<{

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

@@ -1 +1,14 @@
/// <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
}
}