mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d4751e80 | ||
|
|
b8120a5119 | ||
|
|
68a13cefc3 | ||
|
|
cd4b8f3702 | ||
|
|
c5956ba203 | ||
|
|
f456357e01 | ||
|
|
4ef821f45f | ||
|
|
912c78e9e9 | ||
|
|
bfcd154a25 | ||
|
|
a1c8ba48b0 | ||
|
|
f93369489d | ||
|
|
014f57f152 | ||
|
|
3f1eb58af4 | ||
|
|
97f0077e95 | ||
|
|
3d9b1b0f8c | ||
|
|
cf292ca9e2 | ||
|
|
97f14030de | ||
|
|
2cfe0d8ee8 | ||
|
|
a760f45823 | ||
|
|
baa949a301 | ||
|
|
c29bbab25f | ||
|
|
29981e1232 | ||
|
|
2d043cd929 | ||
|
|
d6dca0e5f7 | ||
|
|
d47166e6f9 | ||
|
|
6e3bb9e361 | ||
|
|
b8dbc3caf1 | ||
|
|
c1145c8f89 | ||
|
|
0cba8e6d89 | ||
|
|
f6f468dff3 | ||
|
|
04fc5f9104 | ||
|
|
3c9ab6763c | ||
|
|
f360333ab4 | ||
|
|
834aa6eecb | ||
|
|
2400cc8b55 | ||
|
|
e4ed7faca9 | ||
|
|
8012aa49ee | ||
|
|
7225358b91 | ||
|
|
39688e8e0c | ||
|
|
592ca6128f | ||
|
|
7cd27d8905 | ||
|
|
bca387c54b |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22.12
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -58,4 +58,4 @@ jobs:
|
|||||||
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
|
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { voiceTranscribeService } from './services/voiceTranscribeService'
|
|||||||
import { videoService } from './services/videoService'
|
import { videoService } from './services/videoService'
|
||||||
import { snsService } from './services/snsService'
|
import { snsService } from './services/snsService'
|
||||||
import { contactExportService } from './services/contactExportService'
|
import { contactExportService } from './services/contactExportService'
|
||||||
|
import { windowsHelloService } from './services/windowsHelloService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -673,6 +674,10 @@ function registerIpcHandlers() {
|
|||||||
return dbPathService.scanWxids(rootPath)
|
return dbPathService.scanWxids(rootPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => {
|
||||||
|
return dbPathService.scanWxidCandidates(rootPath)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('dbpath:getDefault', async () => {
|
ipcMain.handle('dbpath:getDefault', async () => {
|
||||||
return dbPathService.getDefaultPath()
|
return dbPathService.getDefaultPath()
|
||||||
})
|
})
|
||||||
@@ -798,6 +803,17 @@ function registerIpcHandlers() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Windows Hello
|
||||||
|
ipcMain.handle('auth:hello', async (event, message?: string) => {
|
||||||
|
// 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致
|
||||||
|
// 如果主窗口不存在(极其罕见),则回退到调用者窗口
|
||||||
|
const targetWin = (mainWindow && !mainWindow.isDestroyed())
|
||||||
|
? mainWindow
|
||||||
|
: (BrowserWindow.fromWebContents(event.sender) || undefined)
|
||||||
|
|
||||||
|
return windowsHelloService.verify(message, targetWin)
|
||||||
|
})
|
||||||
|
|
||||||
// 导出相关
|
// 导出相关
|
||||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||||
const onProgress = (progress: ExportProgress) => {
|
const onProgress = (progress: ExportProgress) => {
|
||||||
@@ -894,6 +910,10 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
|
||||||
|
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||||
|
})
|
||||||
|
|
||||||
// 打开协议窗口
|
// 打开协议窗口
|
||||||
ipcMain.handle('window:openAgreementWindow', async () => {
|
ipcMain.handle('window:openAgreementWindow', async () => {
|
||||||
createAgreementWindow()
|
createAgreementWindow()
|
||||||
|
|||||||
24
electron/nodert.d.ts
vendored
Normal file
24
electron/nodert.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
clear: () => ipcRenderer.invoke('config:clear')
|
clear: () => ipcRenderer.invoke('config:clear')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 认证
|
||||||
|
auth: {
|
||||||
|
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// 对话框
|
// 对话框
|
||||||
dialog: {
|
dialog: {
|
||||||
@@ -66,6 +71,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
dbPath: {
|
dbPath: {
|
||||||
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
|
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
|
||||||
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
|
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
|
||||||
|
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
|
||||||
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
|
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -178,7 +184,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime)
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 年度报告
|
// 年度报告
|
||||||
|
|||||||
@@ -118,6 +118,48 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||||
|
*/
|
||||||
|
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||||
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existsSync(rootPath)) {
|
||||||
|
const entries = readdirSync(rootPath)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(rootPath, entry)
|
||||||
|
let stat: ReturnType<typeof statSync>
|
||||||
|
try {
|
||||||
|
stat = statSync(entryPath)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) continue
|
||||||
|
const lower = entry.toLowerCase()
|
||||||
|
if (lower === 'all_users') continue
|
||||||
|
if (!entry.includes('_')) continue
|
||||||
|
|
||||||
|
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wxids.length === 0) {
|
||||||
|
const rootName = basename(rootPath)
|
||||||
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
|
const rootStat = statSync(rootPath)
|
||||||
|
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return wxids.sort((a, b) => {
|
||||||
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
|
return a.wxid.localeCompare(b.wxid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描 wxid 列表
|
* 扫描 wxid 列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface ExportOptions {
|
|||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
exportVoices?: boolean
|
exportVoices?: boolean
|
||||||
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
@@ -141,6 +142,12 @@ class ExportService {
|
|||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
||||||
|
const raw = Math.floor(value)
|
||||||
|
return Math.max(1, Math.min(raw, max))
|
||||||
|
}
|
||||||
|
|
||||||
private cleanAccountDirName(dirName: string): string {
|
private cleanAccountDirName(dirName: string): string {
|
||||||
const trimmed = dirName.trim()
|
const trimmed = dirName.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
@@ -186,6 +193,20 @@ class ExportService {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async preloadContacts(
|
||||||
|
usernames: Iterable<string>,
|
||||||
|
cache: Map<string, { success: boolean; contact?: any; error?: string }>,
|
||||||
|
limit = 8
|
||||||
|
): Promise<void> {
|
||||||
|
const unique = Array.from(new Set(Array.from(usernames).filter(Boolean)))
|
||||||
|
if (unique.length === 0) return
|
||||||
|
await parallelLimit(unique, limit, async (username) => {
|
||||||
|
if (cache.has(username)) return
|
||||||
|
const result = await wcdbService.getContact(username)
|
||||||
|
cache.set(username, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
|
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
|
||||||
* ext_buffer 包含类似 protobuf 编码的数据,格式示例:
|
* ext_buffer 包含类似 protobuf 编码的数据,格式示例:
|
||||||
@@ -859,10 +880,10 @@ class ExportService {
|
|||||||
options: {
|
options: {
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
exportVoices?: boolean
|
exportVoices?: boolean
|
||||||
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
includeVoiceWithTranscript?: boolean
|
includeVoiceWithTranscript?: boolean
|
||||||
exportVideos?: boolean
|
|
||||||
}
|
}
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
const localType = msg.localType
|
const localType = msg.localType
|
||||||
@@ -877,8 +898,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 语音消息
|
// 语音消息
|
||||||
if (localType === 34) {
|
if (localType === 34) {
|
||||||
const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText
|
if (options.exportVoices) {
|
||||||
if (shouldKeepVoiceFile && options.exportVoices) {
|
|
||||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||||
}
|
}
|
||||||
if (options.exportVoiceAsText) {
|
if (options.exportVoiceAsText) {
|
||||||
@@ -1233,7 +1253,7 @@ class ExportService {
|
|||||||
mediaRelativePrefix: string
|
mediaRelativePrefix: string
|
||||||
} {
|
} {
|
||||||
const exportMediaEnabled = options.exportMedia === true &&
|
const exportMediaEnabled = options.exportMedia === true &&
|
||||||
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
|
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||||
const outputDir = path.dirname(outputPath)
|
const outputDir = path.dirname(outputPath)
|
||||||
const outputBaseName = path.basename(outputPath, path.extname(outputPath))
|
const outputBaseName = path.basename(outputPath, path.extname(outputPath))
|
||||||
const useSharedMediaLayout = options.sessionLayout === 'shared'
|
const useSharedMediaLayout = options.sessionLayout === 'shared'
|
||||||
@@ -1681,10 +1701,6 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (options.exportVoiceAsText) {
|
|
||||||
await this.ensureVoiceModel(onProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||||
const allMessages = collected.rows
|
const allMessages = collected.rows
|
||||||
|
|
||||||
@@ -1693,6 +1709,14 @@ class ExportService {
|
|||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voiceMessages = options.exportVoiceAsText
|
||||||
|
? allMessages.filter(msg => msg.localType === 34)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||||
|
await this.ensureVoiceModel(onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
}
|
}
|
||||||
@@ -1707,7 +1731,8 @@ class ExportService {
|
|||||||
const t = msg.localType
|
const t = msg.localType
|
||||||
return (t === 3 && options.exportImages) || // 图片
|
return (t === 3 && options.exportImages) || // 图片
|
||||||
(t === 47 && options.exportEmojis) || // 表情
|
(t === 47 && options.exportEmojis) || // 表情
|
||||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字)
|
(t === 43 && options.exportVideos) || // 视频
|
||||||
|
(t === 34 && options.exportVoices) // 语音文件
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -1721,14 +1746,15 @@ class ExportService {
|
|||||||
phase: 'exporting-media'
|
phase: 'exporting-media'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 并行导出媒体,限制 8 个并发
|
// 并行导出媒体,并发数跟随导出设置
|
||||||
const MEDIA_CONCURRENCY = 8
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
exportImages: options.exportImages,
|
exportImages: options.exportImages,
|
||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText
|
exportVoiceAsText: options.exportVoiceAsText
|
||||||
})
|
})
|
||||||
@@ -1738,10 +1764,6 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== 阶段2:并行语音转文字 ==========
|
// ========== 阶段2:并行语音转文字 ==========
|
||||||
const voiceMessages = options.exportVoiceAsText
|
|
||||||
? allMessages.filter(msg => msg.localType === 34)
|
|
||||||
: []
|
|
||||||
|
|
||||||
const voiceTranscriptMap = new Map<number, string>()
|
const voiceTranscriptMap = new Map<number, string>()
|
||||||
|
|
||||||
if (voiceMessages.length > 0) {
|
if (voiceMessages.length > 0) {
|
||||||
@@ -1895,10 +1917,6 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (options.exportVoiceAsText) {
|
|
||||||
await this.ensureVoiceModel(onProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -1906,6 +1924,21 @@ class ExportService {
|
|||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voiceMessages = options.exportVoiceAsText
|
||||||
|
? collected.rows.filter(msg => msg.localType === 34)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||||
|
await this.ensureVoiceModel(onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderUsernames = new Set<string>()
|
||||||
|
for (const msg of collected.rows) {
|
||||||
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
|
}
|
||||||
|
senderUsernames.add(sessionId)
|
||||||
|
await this.preloadContacts(senderUsernames, contactCache)
|
||||||
|
|
||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
|
|
||||||
// ========== 阶段1:并行导出媒体文件 ==========
|
// ========== 阶段1:并行导出媒体文件 ==========
|
||||||
@@ -1914,7 +1947,8 @@ class ExportService {
|
|||||||
const t = msg.localType
|
const t = msg.localType
|
||||||
return (t === 3 && options.exportImages) ||
|
return (t === 3 && options.exportImages) ||
|
||||||
(t === 47 && options.exportEmojis) ||
|
(t === 47 && options.exportEmojis) ||
|
||||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
(t === 43 && options.exportVideos) ||
|
||||||
|
(t === 34 && options.exportVoices)
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -1928,13 +1962,14 @@ class ExportService {
|
|||||||
phase: 'exporting-media'
|
phase: 'exporting-media'
|
||||||
})
|
})
|
||||||
|
|
||||||
const MEDIA_CONCURRENCY = 8
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
exportImages: options.exportImages,
|
exportImages: options.exportImages,
|
||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText
|
exportVoiceAsText: options.exportVoiceAsText
|
||||||
})
|
})
|
||||||
@@ -1944,10 +1979,6 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== 阶段2:并行语音转文字 ==========
|
// ========== 阶段2:并行语音转文字 ==========
|
||||||
const voiceMessages = options.exportVoiceAsText
|
|
||||||
? collected.rows.filter(msg => msg.localType === 34)
|
|
||||||
: []
|
|
||||||
|
|
||||||
const voiceTranscriptMap = new Map<number, string>()
|
const voiceTranscriptMap = new Map<number, string>()
|
||||||
|
|
||||||
if (voiceMessages.length > 0) {
|
if (voiceMessages.length > 0) {
|
||||||
@@ -1988,10 +2019,10 @@ class ExportService {
|
|||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
const mediaItem = mediaCache.get(mediaKey)
|
const mediaItem = mediaCache.get(mediaKey)
|
||||||
|
|
||||||
if (mediaItem) {
|
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||||
content = mediaItem.relativePath
|
|
||||||
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
|
||||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
|
} else if (mediaItem) {
|
||||||
|
content = mediaItem.relativePath
|
||||||
} else {
|
} else {
|
||||||
content = this.parseMessageContent(msg.content, msg.localType)
|
content = this.parseMessageContent(msg.content, msg.localType)
|
||||||
}
|
}
|
||||||
@@ -2156,10 +2187,6 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (options.exportVoiceAsText) {
|
|
||||||
await this.ensureVoiceModel(onProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -2167,6 +2194,21 @@ class ExportService {
|
|||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voiceMessages = options.exportVoiceAsText
|
||||||
|
? collected.rows.filter(msg => msg.localType === 34)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||||
|
await this.ensureVoiceModel(onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderUsernames = new Set<string>()
|
||||||
|
for (const msg of collected.rows) {
|
||||||
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
|
}
|
||||||
|
senderUsernames.add(sessionId)
|
||||||
|
await this.preloadContacts(senderUsernames, contactCache)
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
current: 30,
|
current: 30,
|
||||||
total: 100,
|
total: 100,
|
||||||
@@ -2297,7 +2339,8 @@ class ExportService {
|
|||||||
const t = msg.localType
|
const t = msg.localType
|
||||||
return (t === 3 && options.exportImages) ||
|
return (t === 3 && options.exportImages) ||
|
||||||
(t === 47 && options.exportEmojis) ||
|
(t === 47 && options.exportEmojis) ||
|
||||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
(t === 43 && options.exportVideos) ||
|
||||||
|
(t === 34 && options.exportVoices)
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -2311,13 +2354,14 @@ class ExportService {
|
|||||||
phase: 'exporting-media'
|
phase: 'exporting-media'
|
||||||
})
|
})
|
||||||
|
|
||||||
const MEDIA_CONCURRENCY = 8
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
exportImages: options.exportImages,
|
exportImages: options.exportImages,
|
||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText
|
exportVoiceAsText: options.exportVoiceAsText
|
||||||
})
|
})
|
||||||
@@ -2327,10 +2371,6 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== 并行预处理:语音转文字 ==========
|
// ========== 并行预处理:语音转文字 ==========
|
||||||
const voiceMessages = options.exportVoiceAsText
|
|
||||||
? sortedMessages.filter(msg => msg.localType === 34)
|
|
||||||
: []
|
|
||||||
|
|
||||||
const voiceTranscriptMap = new Map<number, string>()
|
const voiceTranscriptMap = new Map<number, string>()
|
||||||
|
|
||||||
if (voiceMessages.length > 0) {
|
if (voiceMessages.length > 0) {
|
||||||
@@ -2416,13 +2456,21 @@ class ExportService {
|
|||||||
|
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
const mediaItem = mediaCache.get(mediaKey)
|
const mediaItem = mediaCache.get(mediaKey)
|
||||||
const contentValue = mediaItem?.relativePath
|
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|
||||||
|| this.formatPlainExportContent(
|
const contentValue = shouldUseTranscript
|
||||||
|
? this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId)
|
||||||
)
|
)
|
||||||
|
: (mediaItem?.relativePath
|
||||||
|
|| this.formatPlainExportContent(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
options,
|
||||||
|
voiceTranscriptMap.get(msg.localId)
|
||||||
|
))
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
if (msg.localType === 3 || msg.localType === 47) {
|
if (msg.localType === 3 || msg.localType === 47) {
|
||||||
@@ -2549,6 +2597,16 @@ class ExportService {
|
|||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
|
||||||
|
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||||
|
const getContactCached = async (username: string) => {
|
||||||
|
if (contactCache.has(username)) {
|
||||||
|
return contactCache.get(username)!
|
||||||
|
}
|
||||||
|
const result = await wcdbService.getContact(username)
|
||||||
|
contactCache.set(username, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
current: 0,
|
current: 0,
|
||||||
total: 100,
|
total: 100,
|
||||||
@@ -2556,10 +2614,6 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (options.exportVoiceAsText) {
|
|
||||||
await this.ensureVoiceModel(onProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -2567,6 +2621,21 @@ class ExportService {
|
|||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voiceMessages = options.exportVoiceAsText
|
||||||
|
? collected.rows.filter(msg => msg.localType === 34)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||||
|
await this.ensureVoiceModel(onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderUsernames = new Set<string>()
|
||||||
|
for (const msg of collected.rows) {
|
||||||
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
|
}
|
||||||
|
senderUsernames.add(sessionId)
|
||||||
|
await this.preloadContacts(senderUsernames, contactCache)
|
||||||
|
|
||||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
@@ -2575,7 +2644,8 @@ class ExportService {
|
|||||||
const t = msg.localType
|
const t = msg.localType
|
||||||
return (t === 3 && options.exportImages) ||
|
return (t === 3 && options.exportImages) ||
|
||||||
(t === 47 && options.exportEmojis) ||
|
(t === 47 && options.exportEmojis) ||
|
||||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
(t === 43 && options.exportVideos) ||
|
||||||
|
(t === 34 && options.exportVoices)
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -2589,13 +2659,14 @@ class ExportService {
|
|||||||
phase: 'exporting-media'
|
phase: 'exporting-media'
|
||||||
})
|
})
|
||||||
|
|
||||||
const MEDIA_CONCURRENCY = 8
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
exportImages: options.exportImages,
|
exportImages: options.exportImages,
|
||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText
|
exportVoiceAsText: options.exportVoiceAsText
|
||||||
})
|
})
|
||||||
@@ -2604,9 +2675,6 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const voiceMessages = options.exportVoiceAsText
|
|
||||||
? sortedMessages.filter(msg => msg.localType === 34)
|
|
||||||
: []
|
|
||||||
const voiceTranscriptMap = new Map<number, string>()
|
const voiceTranscriptMap = new Map<number, string>()
|
||||||
|
|
||||||
if (voiceMessages.length > 0) {
|
if (voiceMessages.length > 0) {
|
||||||
@@ -2637,13 +2705,21 @@ class ExportService {
|
|||||||
const msg = sortedMessages[i]
|
const msg = sortedMessages[i]
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
const mediaItem = mediaCache.get(mediaKey)
|
const mediaItem = mediaCache.get(mediaKey)
|
||||||
const contentValue = mediaItem?.relativePath
|
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|
||||||
|| this.formatPlainExportContent(
|
const contentValue = shouldUseTranscript
|
||||||
|
? this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId)
|
||||||
)
|
)
|
||||||
|
: (mediaItem?.relativePath
|
||||||
|
|| this.formatPlainExportContent(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
options,
|
||||||
|
voiceTranscriptMap.get(msg.localId)
|
||||||
|
))
|
||||||
|
|
||||||
let senderRole: string
|
let senderRole: string
|
||||||
let senderWxid: string
|
let senderWxid: string
|
||||||
@@ -2763,7 +2839,7 @@ class ExportService {
|
|||||||
return (t === 3 && options.exportImages) ||
|
return (t === 3 && options.exportImages) ||
|
||||||
(t === 47 && options.exportEmojis) ||
|
(t === 47 && options.exportEmojis) ||
|
||||||
(t === 34 && options.exportVoices) ||
|
(t === 34 && options.exportVoices) ||
|
||||||
t === 43
|
(t === 43 && options.exportVideos)
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -2787,7 +2863,7 @@ class ExportService {
|
|||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVoiceWithTranscript: true,
|
includeVoiceWithTranscript: true,
|
||||||
exportVideos: true
|
exportVideos: options.exportVideos
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -3094,7 +3170,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exportMediaEnabled = options.exportMedia === true &&
|
const exportMediaEnabled = options.exportMedia === true &&
|
||||||
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
|
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||||
const sessionLayout = exportMediaEnabled
|
const sessionLayout = exportMediaEnabled
|
||||||
? (options.sessionLayout ?? 'per-session')
|
? (options.sessionLayout ?? 'per-session')
|
||||||
: 'shared'
|
: 'shared'
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import ExcelJS from 'exceljs'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { chatService } from './chatService'
|
||||||
|
|
||||||
export interface GroupChatInfo {
|
export interface GroupChatInfo {
|
||||||
username: string
|
username: string
|
||||||
@@ -41,6 +45,30 @@ class GroupAnalyticsService {
|
|||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 并发控制:限制同时执行的 Promise 数量
|
||||||
|
private async parallelLimit<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T, index: number) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results: R[] = new Array(items.length)
|
||||||
|
let currentIndex = 0
|
||||||
|
|
||||||
|
async function runNext(): Promise<void> {
|
||||||
|
while (currentIndex < items.length) {
|
||||||
|
const index = currentIndex++
|
||||||
|
results[index] = await fn(items[index], index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array(Math.min(limit, items.length))
|
||||||
|
.fill(null)
|
||||||
|
.map(() => runNext())
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
private cleanAccountDirName(name: string): string {
|
private cleanAccountDirName(name: string): string {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
@@ -65,6 +93,139 @@ class GroupAnalyticsService {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private looksLikeHex(s: string): boolean {
|
||||||
|
if (s.length % 2 !== 0) return false
|
||||||
|
return /^[0-9a-fA-F]+$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeBase64(s: string): boolean {
|
||||||
|
if (s.length % 4 !== 0) return false
|
||||||
|
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
|
||||||
|
*/
|
||||||
|
private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map<string, string> {
|
||||||
|
const nicknameMap = new Map<string, string>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = buffer.toString('utf8')
|
||||||
|
const wxidPattern = /wxid_[a-z0-9_]+/gi
|
||||||
|
const wxids = raw.match(wxidPattern) || []
|
||||||
|
|
||||||
|
for (const wxid of wxids) {
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const wxidIndex = raw.toLowerCase().indexOf(wxidLower)
|
||||||
|
if (wxidIndex === -1) continue
|
||||||
|
|
||||||
|
const afterWxid = raw.slice(wxidIndex + wxid.length)
|
||||||
|
let nickname = ''
|
||||||
|
let foundStart = false
|
||||||
|
|
||||||
|
for (let i = 0; i < afterWxid.length && i < 100; i++) {
|
||||||
|
const char = afterWxid[i]
|
||||||
|
const code = char.charCodeAt(0)
|
||||||
|
const isPrintable = (
|
||||||
|
(code >= 0x4E00 && code <= 0x9FFF) ||
|
||||||
|
(code >= 0x3000 && code <= 0x303F) ||
|
||||||
|
(code >= 0xFF00 && code <= 0xFFEF) ||
|
||||||
|
(code >= 0x20 && code <= 0x7E)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isPrintable && code !== 0x01 && code !== 0x18) {
|
||||||
|
foundStart = true
|
||||||
|
nickname += char
|
||||||
|
} else if (foundStart) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '')
|
||||||
|
if (nickname && nickname.length < 50) {
|
||||||
|
nicknameMap.set(wxidLower, nickname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse ext_buffer:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nicknameMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 contact.db 的 chat_room 表获取群成员的群昵称
|
||||||
|
*/
|
||||||
|
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
||||||
|
try {
|
||||||
|
const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'`
|
||||||
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
|
||||||
|
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||||
|
return new Map<string, string>()
|
||||||
|
}
|
||||||
|
|
||||||
|
let extBuffer = result.rows[0].ext_buffer
|
||||||
|
|
||||||
|
if (typeof extBuffer === 'string') {
|
||||||
|
if (this.looksLikeHex(extBuffer)) {
|
||||||
|
extBuffer = Buffer.from(extBuffer, 'hex')
|
||||||
|
} else if (this.looksLikeBase64(extBuffer)) {
|
||||||
|
extBuffer = Buffer.from(extBuffer, 'base64')
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
extBuffer = Buffer.from(extBuffer, 'hex')
|
||||||
|
} catch {
|
||||||
|
extBuffer = Buffer.from(extBuffer, 'base64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extBuffer || !Buffer.isBuffer(extBuffer)) {
|
||||||
|
return new Map<string, string>()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parseGroupNicknamesFromExtBuffer(extBuffer)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('getGroupNicknamesForRoom error:', e)
|
||||||
|
return new Map<string, string>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeCsvValue(value: string): string {
|
||||||
|
if (value == null) return ''
|
||||||
|
const str = String(value)
|
||||||
|
if (/[",\n\r]/.test(str)) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeGroupNickname(value: string, wxid: string, fallback: string): string {
|
||||||
|
const trimmed = (value || '').trim()
|
||||||
|
if (!trimmed) return fallback
|
||||||
|
if (/^["'@]+$/.test(trimmed)) return fallback
|
||||||
|
if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeWorksheetName(name: string): string {
|
||||||
|
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
|
||||||
|
const limited = cleaned.slice(0, 31)
|
||||||
|
return limited || 'Sheet1'
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDateTime(date: Date): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = pad(date.getMonth() + 1)
|
||||||
|
const day = pad(date.getDate())
|
||||||
|
const hour = pad(date.getHours())
|
||||||
|
const minute = pad(date.getMinutes())
|
||||||
|
const second = pad(date.getSeconds())
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -80,23 +241,38 @@ class GroupAnalyticsService {
|
|||||||
.map((row) => row.username || row.user_name || row.userName || '')
|
.map((row) => row.username || row.user_name || row.userName || '')
|
||||||
.filter((username) => username.includes('@chatroom'))
|
.filter((username) => username.includes('@chatroom'))
|
||||||
|
|
||||||
const [displayNames, avatarUrls, memberCounts] = await Promise.all([
|
const [memberCounts, contactInfo] = await Promise.all([
|
||||||
wcdbService.getDisplayNames(groupIds),
|
wcdbService.getGroupMemberCounts(groupIds),
|
||||||
wcdbService.getAvatarUrls(groupIds),
|
chatService.enrichSessionsContactInfo(groupIds)
|
||||||
wcdbService.getGroupMemberCounts(groupIds)
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
let fallbackNames: { success: boolean; map?: Record<string, string> } | null = null
|
||||||
|
let fallbackAvatars: { success: boolean; map?: Record<string, string> } | null = null
|
||||||
|
if (!contactInfo.success || !contactInfo.contacts) {
|
||||||
|
const [displayNames, avatarUrls] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames(groupIds),
|
||||||
|
wcdbService.getAvatarUrls(groupIds)
|
||||||
|
])
|
||||||
|
fallbackNames = displayNames
|
||||||
|
fallbackAvatars = avatarUrls
|
||||||
|
}
|
||||||
|
|
||||||
const groups: GroupChatInfo[] = []
|
const groups: GroupChatInfo[] = []
|
||||||
for (const groupId of groupIds) {
|
for (const groupId of groupIds) {
|
||||||
|
const contact = contactInfo.success && contactInfo.contacts ? contactInfo.contacts[groupId] : undefined
|
||||||
|
const displayName = contact?.displayName ||
|
||||||
|
(fallbackNames && fallbackNames.success && fallbackNames.map ? (fallbackNames.map[groupId] || '') : '') ||
|
||||||
|
groupId
|
||||||
|
const avatarUrl = contact?.avatarUrl ||
|
||||||
|
(fallbackAvatars && fallbackAvatars.success && fallbackAvatars.map ? fallbackAvatars.map[groupId] : undefined)
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
username: groupId,
|
username: groupId,
|
||||||
displayName: displayNames.success && displayNames.map
|
displayName,
|
||||||
? (displayNames.map[groupId] || groupId)
|
|
||||||
: groupId,
|
|
||||||
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
|
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
|
||||||
? memberCounts.map[groupId]
|
? memberCounts.map[groupId]
|
||||||
: 0,
|
: 0,
|
||||||
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined
|
avatarUrl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +424,187 @@ class GroupAnalyticsService {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const exportDate = new Date()
|
||||||
|
const exportTime = this.formatDateTime(exportDate)
|
||||||
|
const exportVersion = '0.0.2'
|
||||||
|
const exportGenerator = 'WeFlow'
|
||||||
|
const exportPlatform = 'wechat'
|
||||||
|
|
||||||
|
const groupDisplay = await wcdbService.getDisplayNames([chatroomId])
|
||||||
|
const groupName = groupDisplay.success && groupDisplay.map
|
||||||
|
? (groupDisplay.map[chatroomId] || chatroomId)
|
||||||
|
: chatroomId
|
||||||
|
|
||||||
|
const groupContact = await wcdbService.getContact(chatroomId)
|
||||||
|
const sessionRemark = (groupContact.success && groupContact.contact)
|
||||||
|
? (groupContact.contact.remark || '')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
||||||
|
if (!membersResult.success || !membersResult.members) {
|
||||||
|
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = membersResult.members as { username: string; avatarUrl?: string }[]
|
||||||
|
if (members.length === 0) {
|
||||||
|
return { success: false, error: '群成员为空' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||||
|
const [displayNames, groupNicknames] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames(usernames),
|
||||||
|
this.getGroupNicknamesForRoom(chatroomId)
|
||||||
|
])
|
||||||
|
|
||||||
|
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
||||||
|
const concurrency = 6
|
||||||
|
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||||
|
const result = await wcdbService.getContact(username)
|
||||||
|
if (result.success && result.contact) {
|
||||||
|
const contact = result.contact as any
|
||||||
|
contactMap.set(username, {
|
||||||
|
remark: contact.remark || '',
|
||||||
|
nickName: contact.nickName || contact.nick_name || '',
|
||||||
|
alias: contact.alias || ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const infoTitleRow = ['会话信息']
|
||||||
|
const infoRow = ['微信ID', chatroomId, '', '昵称', groupName, '备注', sessionRemark || '', '']
|
||||||
|
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
|
||||||
|
|
||||||
|
const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号']
|
||||||
|
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
|
||||||
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const wxid = member.username
|
||||||
|
const normalizedWxid = this.cleanAccountDirName(wxid || '')
|
||||||
|
const contact = contactMap.get(wxid)
|
||||||
|
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
|
||||||
|
const nickName = contact?.nickName || fallbackName || ''
|
||||||
|
const remark = contact?.remark || ''
|
||||||
|
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
|
||||||
|
const alias = contact?.alias || ''
|
||||||
|
const groupNickname = this.normalizeGroupNickname(
|
||||||
|
rawGroupNickname,
|
||||||
|
normalizedWxid === myWxid ? myWxid : wxid,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.push([nickName, remark, groupNickname, wxid, alias])
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(outputPath).toLowerCase()
|
||||||
|
if (ext === '.csv') {
|
||||||
|
const csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
|
||||||
|
const content = '\ufeff' + csvLines.join('\n')
|
||||||
|
fs.writeFileSync(outputPath, content, 'utf8')
|
||||||
|
} else {
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
const sheet = workbook.addWorksheet(this.sanitizeWorksheetName('群成员列表'))
|
||||||
|
|
||||||
|
let currentRow = 1
|
||||||
|
const titleCell = sheet.getCell(currentRow, 1)
|
||||||
|
titleCell.value = '会话信息'
|
||||||
|
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
|
||||||
|
sheet.getRow(currentRow).height = 25
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
sheet.getCell(currentRow, 1).value = '微信ID'
|
||||||
|
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
sheet.mergeCells(currentRow, 2, currentRow, 3)
|
||||||
|
sheet.getCell(currentRow, 2).value = chatroomId
|
||||||
|
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
|
||||||
|
|
||||||
|
sheet.getCell(currentRow, 4).value = '昵称'
|
||||||
|
sheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
sheet.getCell(currentRow, 5).value = groupName
|
||||||
|
sheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
|
||||||
|
|
||||||
|
sheet.getCell(currentRow, 6).value = '备注'
|
||||||
|
sheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
sheet.mergeCells(currentRow, 7, currentRow, 8)
|
||||||
|
sheet.getCell(currentRow, 7).value = sessionRemark
|
||||||
|
sheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
|
||||||
|
|
||||||
|
sheet.getRow(currentRow).height = 20
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
sheet.getCell(currentRow, 1).value = '导出工具'
|
||||||
|
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
sheet.getCell(currentRow, 2).value = exportGenerator
|
||||||
|
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
|
||||||
|
|
||||||
|
sheet.getCell(currentRow, 3).value = '导出版本'
|
||||||
|
sheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
sheet.getCell(currentRow, 4).value = exportVersion
|
||||||
|
sheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
|
||||||
|
|
||||||
|
sheet.getCell(currentRow, 5).value = '平台'
|
||||||
|
sheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
sheet.getCell(currentRow, 6).value = exportPlatform
|
||||||
|
sheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
|
||||||
|
|
||||||
|
sheet.getCell(currentRow, 7).value = '导出时间'
|
||||||
|
sheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
sheet.getCell(currentRow, 8).value = exportTime
|
||||||
|
sheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
|
||||||
|
|
||||||
|
sheet.getRow(currentRow).height = 20
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
const headerRow = sheet.getRow(currentRow)
|
||||||
|
headerRow.height = 22
|
||||||
|
header.forEach((text, index) => {
|
||||||
|
const cell = headerRow.getCell(index + 1)
|
||||||
|
cell.value = text
|
||||||
|
cell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
})
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
sheet.getColumn(1).width = 28
|
||||||
|
sheet.getColumn(2).width = 28
|
||||||
|
sheet.getColumn(3).width = 28
|
||||||
|
sheet.getColumn(4).width = 36
|
||||||
|
sheet.getColumn(5).width = 28
|
||||||
|
sheet.getColumn(6).width = 18
|
||||||
|
sheet.getColumn(7).width = 24
|
||||||
|
sheet.getColumn(8).width = 22
|
||||||
|
|
||||||
|
for (let i = 4; i < rows.length; i++) {
|
||||||
|
const [nickName, remark, groupNickname, wxid, alias] = rows[i]
|
||||||
|
const row = sheet.getRow(currentRow)
|
||||||
|
row.getCell(1).value = nickName
|
||||||
|
row.getCell(2).value = remark
|
||||||
|
row.getCell(3).value = groupNickname
|
||||||
|
row.getCell(4).value = wxid
|
||||||
|
row.getCell(5).value = alias
|
||||||
|
row.alignment = { vertical: 'top', wrapText: true }
|
||||||
|
currentRow++
|
||||||
|
}
|
||||||
|
|
||||||
|
await workbook.xlsx.writeFile(outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, count: members.length }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const groupAnalyticsService = new GroupAnalyticsService()
|
export const groupAnalyticsService = new GroupAnalyticsService()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class KeyService {
|
|||||||
private GetWindowThreadProcessId: any = null
|
private GetWindowThreadProcessId: any = null
|
||||||
private IsWindowVisible: any = null
|
private IsWindowVisible: any = null
|
||||||
private EnumChildWindows: any = null
|
private EnumChildWindows: any = null
|
||||||
|
private PostMessageW: any = null
|
||||||
private WNDENUMPROC_PTR: any = null
|
private WNDENUMPROC_PTR: any = null
|
||||||
|
|
||||||
// Advapi32
|
// Advapi32
|
||||||
@@ -57,6 +58,7 @@ export class KeyService {
|
|||||||
private readonly HKEY_LOCAL_MACHINE = 0x80000002
|
private readonly HKEY_LOCAL_MACHINE = 0x80000002
|
||||||
private readonly HKEY_CURRENT_USER = 0x80000001
|
private readonly HKEY_CURRENT_USER = 0x80000001
|
||||||
private readonly ERROR_SUCCESS = 0
|
private readonly ERROR_SUCCESS = 0
|
||||||
|
private readonly WM_CLOSE = 0x0010
|
||||||
|
|
||||||
private getDllPath(): string {
|
private getDllPath(): string {
|
||||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||||
@@ -224,6 +226,7 @@ export class KeyService {
|
|||||||
|
|
||||||
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||||
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
|
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||||
|
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
|
||||||
|
|
||||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||||
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
||||||
@@ -437,16 +440,60 @@ export class KeyService {
|
|||||||
return fallbackPid ?? null
|
return fallbackPid ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
private async killWeChatProcesses() {
|
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const weixinPid = await this.findPidByImageName('Weixin.exe')
|
||||||
|
const wechatPid = await this.findPidByImageName('WeChat.exe')
|
||||||
|
if (!weixinPid && !wechatPid) return true
|
||||||
|
await new Promise(r => setTimeout(r, 400))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeWeChatWindows(): Promise<boolean> {
|
||||||
|
if (!this.ensureUser32()) return false
|
||||||
|
let requested = false
|
||||||
|
|
||||||
|
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||||
|
if (!this.IsWindowVisible(hWnd)) return true
|
||||||
|
const title = this.getWindowTitle(hWnd)
|
||||||
|
const className = this.getClassName(hWnd)
|
||||||
|
const classLower = (className || '').toLowerCase()
|
||||||
|
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
|
||||||
|
if (!isWeChatWindow) return true
|
||||||
|
|
||||||
|
requested = true
|
||||||
|
try {
|
||||||
|
this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
|
||||||
|
} catch { }
|
||||||
|
return true
|
||||||
|
}, this.WNDENUMPROC_PTR)
|
||||||
|
|
||||||
|
this.EnumWindows(enumWindowsCallback, 0)
|
||||||
|
this.koffi.unregister(enumWindowsCallback)
|
||||||
|
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
|
||||||
|
private async killWeChatProcesses(): Promise<boolean> {
|
||||||
|
const requested = await this.closeWeChatWindows()
|
||||||
|
if (requested) {
|
||||||
|
const gracefulOk = await this.waitForWeChatExit(1500)
|
||||||
|
if (gracefulOk) return true
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe'])
|
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
|
||||||
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe'])
|
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore if not found
|
// Ignore if not found
|
||||||
}
|
}
|
||||||
await new Promise(r => setTimeout(r, 1000))
|
|
||||||
|
return await this.waitForWeChatExit(5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Window Detection ---
|
// --- Window Detection ---
|
||||||
|
|
||||||
private getWindowTitle(hWnd: any): string {
|
private getWindowTitle(hWnd: any): string {
|
||||||
@@ -605,15 +652,24 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Restart WeChat
|
// 2. Restart WeChat
|
||||||
onStatus?.('正在重启微信以进行获取...', 0)
|
onStatus?.('正在关闭微信以进行获取...', 0)
|
||||||
await this.killWeChatProcesses()
|
const closed = await this.killWeChatProcesses()
|
||||||
|
if (!closed) {
|
||||||
|
const err = '无法自动关闭微信,请手动退出后重试'
|
||||||
|
onStatus?.(err, 2)
|
||||||
|
return { success: false, error: err }
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Launch
|
// 3. Launch
|
||||||
onStatus?.('正在启动微信...', 0)
|
onStatus?.('正在启动微信...', 0)
|
||||||
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' })
|
const sub = spawn(wechatPath, {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
cwd: dirname(wechatPath)
|
||||||
|
})
|
||||||
sub.unref()
|
sub.unref()
|
||||||
|
|
||||||
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
|
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
|
||||||
onStatus?.('等待微信界面就绪...', 0)
|
onStatus?.('等待微信界面就绪...', 0)
|
||||||
const pid = await this.waitForWeChatWindow()
|
const pid = await this.waitForWeChatWindow()
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetDbStatus: any = null
|
private wcdbGetDbStatus: any = null
|
||||||
private wcdbGetVoiceData: any = null
|
private wcdbGetVoiceData: any = null
|
||||||
private wcdbGetSnsTimeline: any = null
|
private wcdbGetSnsTimeline: any = null
|
||||||
|
private wcdbVerifyUser: any = null
|
||||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
@@ -247,7 +248,7 @@ export class WcdbCore {
|
|||||||
// InitProtection (Added for security)
|
// InitProtection (Added for security)
|
||||||
try {
|
try {
|
||||||
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
|
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
|
||||||
|
|
||||||
// 尝试多个可能的资源路径
|
// 尝试多个可能的资源路径
|
||||||
const resourcePaths = [
|
const resourcePaths = [
|
||||||
dllDir, // DLL 所在目录
|
dllDir, // DLL 所在目录
|
||||||
@@ -255,28 +256,28 @@ export class WcdbCore {
|
|||||||
this.resourcesPath, // 配置的资源路径
|
this.resourcesPath, // 配置的资源路径
|
||||||
join(process.cwd(), 'resources') // 开发环境
|
join(process.cwd(), 'resources') // 开发环境
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
let protectionOk = false
|
let protectionOk = false
|
||||||
for (const resPath of resourcePaths) {
|
for (const resPath of resourcePaths) {
|
||||||
try {
|
try {
|
||||||
console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
|
// console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
|
||||||
protectionOk = this.wcdbInitProtection(resPath)
|
protectionOk = this.wcdbInitProtection(resPath)
|
||||||
if (protectionOk) {
|
if (protectionOk) {
|
||||||
console.log(`[WCDB] InitProtection 成功: ${resPath}`)
|
// console.log(`[WCDB] InitProtection 成功: ${resPath}`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
|
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!protectionOk) {
|
if (!protectionOk) {
|
||||||
console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
|
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
|
||||||
this.writeLog('InitProtection 失败,继续运行')
|
// this.writeLog('InitProtection 失败,继续运行')
|
||||||
// 不返回 false,允许继续运行
|
// 不返回 false,允许继续运行
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('InitProtection symbol not found:', e)
|
// console.warn('InitProtection symbol not found:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义类型
|
// 定义类型
|
||||||
@@ -430,6 +431,13 @@ export class WcdbCore {
|
|||||||
this.wcdbGetSnsTimeline = null
|
this.wcdbGetSnsTimeline = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
||||||
|
try {
|
||||||
|
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbVerifyUser = null
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
if (initResult !== 0) {
|
if (initResult !== 0) {
|
||||||
@@ -1434,6 +1442,39 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Windows Hello
|
||||||
|
*/
|
||||||
|
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
const initOk = await this.initialize()
|
||||||
|
if (!initOk) return { success: false, error: 'WCDB 初始化失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.wcdbVerifyUser) {
|
||||||
|
return { success: false, error: 'Binding not found: VerifyUser' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
// Allocate buffer for result JSON
|
||||||
|
const maxLen = 1024
|
||||||
|
const outBuf = Buffer.alloc(maxLen)
|
||||||
|
|
||||||
|
// Call native function
|
||||||
|
const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0)
|
||||||
|
this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen)
|
||||||
|
|
||||||
|
// Parse result
|
||||||
|
const jsonStr = this.koffi.decode(outBuf, 'char', -1)
|
||||||
|
const result = JSON.parse(jsonStr)
|
||||||
|
resolve(result)
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||||
|
|||||||
@@ -369,6 +369,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Windows Hello
|
||||||
|
*/
|
||||||
|
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('verifyUser', { message, hwnd })
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wcdbService = new WcdbService()
|
export const wcdbService = new WcdbService()
|
||||||
|
|||||||
32
electron/services/windowsHelloService.ts
Normal file
32
electron/services/windowsHelloService.ts
Normal 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()
|
||||||
@@ -119,6 +119,9 @@ if (parentPort) {
|
|||||||
case 'getSnsTimeline':
|
case 'getSnsTimeline':
|
||||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||||
break
|
break
|
||||||
|
case 'verifyUser':
|
||||||
|
result = await core.verifyUser(payload.message, payload.hwnd)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.4.1",
|
"version": "1.4.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.4.1",
|
"version": "1.4.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
@@ -7380,6 +7380,12 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/nan/-/nan-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,13 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.4.1",
|
"version": "1.4.4",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/hicccc77/WeFlow"
|
||||||
|
},
|
||||||
"//": "二改不应改变此处的作者与应用信息",
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo 'No native modules to rebuild'",
|
"postinstall": "echo 'No native modules to rebuild'",
|
||||||
"rebuild": "echo 'No native modules to rebuild'",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -55,6 +59,8 @@
|
|||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
|
"owner": "hicccc77",
|
||||||
|
"repo": "WeFlow",
|
||||||
"releaseType": "release"
|
"releaseType": "release"
|
||||||
},
|
},
|
||||||
"productName": "WeFlow",
|
"productName": "WeFlow",
|
||||||
@@ -126,4 +132,4 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
20
src/App.tsx
20
src/App.tsx
@@ -12,7 +12,6 @@ import AnnualReportPage from './pages/AnnualReportPage'
|
|||||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||||
import AgreementPage from './pages/AgreementPage'
|
import AgreementPage from './pages/AgreementPage'
|
||||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||||
import DataManagementPage from './pages/DataManagementPage'
|
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExportPage from './pages/ExportPage'
|
import ExportPage from './pages/ExportPage'
|
||||||
import VideoWindow from './pages/VideoWindow'
|
import VideoWindow from './pages/VideoWindow'
|
||||||
@@ -43,7 +42,9 @@ function App() {
|
|||||||
setDownloadProgress,
|
setDownloadProgress,
|
||||||
showUpdateDialog,
|
showUpdateDialog,
|
||||||
setShowUpdateDialog,
|
setShowUpdateDialog,
|
||||||
setUpdateError
|
setUpdateError,
|
||||||
|
isLocked,
|
||||||
|
setLocked
|
||||||
} = useAppStore()
|
} = useAppStore()
|
||||||
|
|
||||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
@@ -54,8 +55,10 @@ function App() {
|
|||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
|
|
||||||
// 锁定状态
|
// 锁定状态
|
||||||
const [isLocked, setIsLocked] = useState(false)
|
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||||
const [lockAvatar, setLockAvatar] = useState<string | undefined>(undefined)
|
const [lockAvatar, setLockAvatar] = useState<string | undefined>(
|
||||||
|
localStorage.getItem('app_lock_avatar') || undefined
|
||||||
|
)
|
||||||
const [lockUseHello, setLockUseHello] = useState(false)
|
const [lockUseHello, setLockUseHello] = useState(false)
|
||||||
|
|
||||||
// 协议同意状态
|
// 协议同意状态
|
||||||
@@ -174,7 +177,7 @@ function App() {
|
|||||||
setShowUpdateDialog(true)
|
setShowUpdateDialog(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
|
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => {
|
||||||
setDownloadProgress(progress)
|
setDownloadProgress(progress)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
@@ -271,12 +274,13 @@ function App() {
|
|||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
setLockUseHello(useHello)
|
setLockUseHello(useHello)
|
||||||
setIsLocked(true)
|
setLocked(true)
|
||||||
// 尝试获取头像
|
// 尝试获取头像
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.chat.getMyAvatarUrl()
|
const result = await window.electronAPI.chat.getMyAvatarUrl()
|
||||||
if (result && result.success && result.avatarUrl) {
|
if (result && result.success && result.avatarUrl) {
|
||||||
setLockAvatar(result.avatarUrl)
|
setLockAvatar(result.avatarUrl)
|
||||||
|
localStorage.setItem('app_lock_avatar', result.avatarUrl)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取锁屏头像失败', e)
|
console.error('获取锁屏头像失败', e)
|
||||||
@@ -310,7 +314,7 @@ function App() {
|
|||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
{isLocked && (
|
{isLocked && (
|
||||||
<LockScreen
|
<LockScreen
|
||||||
onUnlock={() => setIsLocked(false)}
|
onUnlock={() => setLocked(false)}
|
||||||
avatar={lockAvatar}
|
avatar={lockAvatar}
|
||||||
useHello={lockUseHello}
|
useHello={lockUseHello}
|
||||||
/>
|
/>
|
||||||
@@ -394,7 +398,7 @@ function App() {
|
|||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||||
<Route path="/data-management" element={<DataManagementPage />} />
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/export" element={<ExportPage />} />
|
<Route path="/export" element={<ExportPage />} />
|
||||||
<Route path="/sns" element={<SnsPage />} />
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import { ArrowRight, Fingerprint, Lock, ShieldCheck } from 'lucide-react'
|
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||||
import './LockScreen.scss'
|
import './LockScreen.scss'
|
||||||
|
|
||||||
interface LockScreenProps {
|
interface LockScreenProps {
|
||||||
@@ -63,18 +63,6 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
|||||||
setShowHello(true)
|
setShowHello(true)
|
||||||
// 立即执行验证 (0延迟)
|
// 立即执行验证 (0延迟)
|
||||||
verifyHello()
|
verifyHello()
|
||||||
|
|
||||||
// 后台再次确认可用性,如果其实不可用,再隐藏?
|
|
||||||
// 或者信任用户的配置。为了速度,我们优先信任配置。
|
|
||||||
if (window.PublicKeyCredential) {
|
|
||||||
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
|
||||||
.then(available => {
|
|
||||||
if (!available) {
|
|
||||||
// 如果系统报告不支持,但配置开了,我们可能需要提示?
|
|
||||||
// 暂时保持开启状态,反正 verifyHello 会报错
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Quick start hello failed', e)
|
console.error('Quick start hello failed', e)
|
||||||
@@ -84,51 +72,23 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
|||||||
const verifyHello = async () => {
|
const verifyHello = async () => {
|
||||||
if (isVerifying || isUnlocked) return
|
if (isVerifying || isUnlocked) return
|
||||||
|
|
||||||
// 取消之前的请求(如果有)
|
|
||||||
if (abortControllerRef.current) {
|
|
||||||
abortControllerRef.current.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortController = new AbortController()
|
|
||||||
abortControllerRef.current = abortController
|
|
||||||
|
|
||||||
setIsVerifying(true)
|
setIsVerifying(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const challenge = new Uint8Array(32)
|
const result = await window.electronAPI.auth.hello()
|
||||||
window.crypto.getRandomValues(challenge)
|
|
||||||
|
|
||||||
const rpId = 'localhost'
|
if (result.success) {
|
||||||
const credential = await navigator.credentials.get({
|
|
||||||
publicKey: {
|
|
||||||
challenge,
|
|
||||||
rpId,
|
|
||||||
userVerification: 'required',
|
|
||||||
},
|
|
||||||
signal: abortController.signal
|
|
||||||
})
|
|
||||||
|
|
||||||
if (credential) {
|
|
||||||
handleUnlock()
|
handleUnlock()
|
||||||
|
} else {
|
||||||
|
console.error('Hello verification failed:', result.error)
|
||||||
|
setError(result.error || '验证失败')
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name === 'AbortError') {
|
console.error('Hello verification error:', e)
|
||||||
console.log('Hello verification aborted')
|
setError(`验证失败: ${e.message || String(e)}`)
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.name === 'NotAllowedError') {
|
|
||||||
console.log('User cancelled Hello verification')
|
|
||||||
} else {
|
|
||||||
console.error('Hello verification error:', e)
|
|
||||||
// 仅在非手动取消时显示错误
|
|
||||||
if (e.name !== 'AbortError') {
|
|
||||||
setError(`验证失败: ${e.message || e.name}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!abortController.signal.aborted) {
|
setIsVerifying(false)
|
||||||
setIsVerifying(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,11 +96,8 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
|||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
if (!password || isUnlocked) return
|
if (!password || isUnlocked) return
|
||||||
|
|
||||||
// 如果正在进行 Hello 验证,取消它
|
// 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消
|
||||||
if (abortControllerRef.current) {
|
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
|
||||||
abortControllerRef.current.abort()
|
|
||||||
abortControllerRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不再检查 isVerifying,因为我们允许打断 Hello
|
// 不再检查 isVerifying,因为我们允许打断 Hello
|
||||||
setIsVerifying(true)
|
setIsVerifying(true)
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ interface RouteGuardProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不需要数据库连接的页面
|
const PUBLIC_ROUTES = ['/', '/home', '/settings']
|
||||||
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management']
|
|
||||||
|
|
||||||
function RouteGuard({ children }: RouteGuardProps) {
|
function RouteGuard({ children }: RouteGuardProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 0 8px;
|
padding: 0 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||||
|
import { useAppStore } from '../stores/appStore'
|
||||||
|
import * as configService from '../services/config'
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [authEnabled, setAuthEnabled] = useState(false)
|
||||||
|
const setLocked = useAppStore(state => state.setLocked)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configService.getAuthEnabled().then(setAuthEnabled)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||||
@@ -94,18 +102,21 @@ function Sidebar() {
|
|||||||
<span className="nav-label">导出</span>
|
<span className="nav-label">导出</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 数据管理 */}
|
|
||||||
<NavLink
|
|
||||||
to="/data-management"
|
|
||||||
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
|
|
||||||
title={collapsed ? '数据管理' : undefined}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><Database size={20} /></span>
|
|
||||||
<span className="nav-label">数据管理</span>
|
|
||||||
</NavLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
|
{authEnabled && (
|
||||||
|
<button
|
||||||
|
className="nav-item"
|
||||||
|
onClick={() => setLocked(true)}
|
||||||
|
title={collapsed ? '锁定' : undefined}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><Lock size={20} /></span>
|
||||||
|
<span className="nav-label">锁定</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => {
|
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
|
||||||
if (payload.percent !== undefined) {
|
if (payload.percent !== undefined) {
|
||||||
setDownloadProgress(payload.percent)
|
setDownloadProgress(payload.percent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import ReactECharts from 'echarts-for-react'
|
|||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import './AnalyticsPage.scss'
|
import './AnalyticsPage.scss'
|
||||||
import './DataManagementPage.scss'
|
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
function AnalyticsPage() {
|
function AnalyticsPage() {
|
||||||
|
|||||||
@@ -491,7 +491,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
|
||||||
const dllStart = performance.now()
|
const dllStart = performance.now()
|
||||||
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as {
|
||||||
|
success: boolean
|
||||||
|
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
const dllTime = performance.now() - dllStart
|
const dllTime = performance.now() - dllStart
|
||||||
|
|
||||||
// DLL 调用后再次让出控制权
|
// DLL 调用后再次让出控制权
|
||||||
@@ -504,7 +508,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
if (result.success && result.contacts) {
|
if (result.success && result.contacts) {
|
||||||
// 将更新加入队列,用于侧边栏更新
|
// 将更新加入队列,用于侧边栏更新
|
||||||
for (const [username, contact] of Object.entries(result.contacts)) {
|
const contacts = result.contacts || {}
|
||||||
|
for (const [username, contact] of Object.entries(contacts)) {
|
||||||
contactUpdateQueueRef.current.set(username, contact)
|
contactUpdateQueueRef.current.set(username, contact)
|
||||||
|
|
||||||
// 如果是自己的信息且当前个人头像为空,同步更新
|
// 如果是自己的信息且当前个人头像为空,同步更新
|
||||||
@@ -545,7 +550,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setIsRefreshingMessages(true)
|
setIsRefreshingMessages(true)
|
||||||
try {
|
try {
|
||||||
// 获取最新消息并增量添加
|
// 获取最新消息并增量添加
|
||||||
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50)
|
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as {
|
||||||
|
success: boolean;
|
||||||
|
messages?: Message[];
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -593,7 +602,12 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime)
|
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) as {
|
||||||
|
success: boolean;
|
||||||
|
messages?: Message[];
|
||||||
|
hasMore?: boolean;
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
if (result.success && result.messages) {
|
if (result.success && result.messages) {
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
setMessages(result.messages)
|
setMessages(result.messages)
|
||||||
@@ -690,7 +704,12 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
try {
|
try {
|
||||||
const lastMsg = messages[messages.length - 1]
|
const lastMsg = messages[messages.length - 1]
|
||||||
// 从最后一条消息的时间开始往后找
|
// 从最后一条消息的时间开始往后找
|
||||||
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true)
|
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) as {
|
||||||
|
success: boolean;
|
||||||
|
messages?: Message[];
|
||||||
|
hasMore?: boolean;
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success && result.messages) {
|
if (result.success && result.messages) {
|
||||||
// 过滤掉已经在列表中的重复消息
|
// 过滤掉已经在列表中的重复消息
|
||||||
@@ -1501,6 +1520,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
const imageClickTimerRef = useRef<number | null>(null)
|
const imageClickTimerRef = useRef<number | null>(null)
|
||||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const imageAutoDecryptTriggered = useRef(false)
|
const imageAutoDecryptTriggered = useRef(false)
|
||||||
|
const imageAutoHdTriggered = useRef<string | null>(null)
|
||||||
|
const [imageInView, setImageInView] = useState(false)
|
||||||
|
const imageForceHdAttempted = useRef<string | null>(null)
|
||||||
|
const imageForceHdPending = useRef(false)
|
||||||
const [voiceError, setVoiceError] = useState(false)
|
const [voiceError, setVoiceError] = useState(false)
|
||||||
const [voiceLoading, setVoiceLoading] = useState(false)
|
const [voiceLoading, setVoiceLoading] = useState(false)
|
||||||
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
||||||
@@ -1551,7 +1574,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
const contentToUse = message.content || (message as any).rawContent || message.parsedContent
|
const contentToUse = message.content || (message as any).rawContent || message.parsedContent
|
||||||
if (contentToUse) {
|
if (contentToUse) {
|
||||||
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
|
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
|
||||||
window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => {
|
window.electronAPI.video.parseVideoMd5(contentToUse).then((result: { success: boolean; md5?: string; error?: string }) => {
|
||||||
console.log('[Video Debug] Parse result:', result)
|
console.log('[Video Debug] Parse result:', result)
|
||||||
if (result && result.success && result.md5) {
|
if (result && result.success && result.md5) {
|
||||||
console.log('[Video Debug] Parsed MD5:', result.md5)
|
console.log('[Video Debug] Parsed MD5:', result.md5)
|
||||||
@@ -1559,7 +1582,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
} else {
|
} else {
|
||||||
console.error('[Video Debug] Failed to parse MD5:', result)
|
console.error('[Video Debug] Failed to parse MD5:', result)
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err: unknown) => {
|
||||||
console.error('[Video Debug] Parse error:', err)
|
console.error('[Video Debug] Parse error:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1667,7 +1690,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
}
|
}
|
||||||
const pending = senderAvatarLoading.get(sender)
|
const pending = senderAvatarLoading.get(sender)
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pending.then((result) => {
|
pending.then((result: { avatarUrl?: string; displayName?: string } | null) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
setSenderAvatarUrl(result.avatarUrl)
|
setSenderAvatarUrl(result.avatarUrl)
|
||||||
setSenderName(result.displayName)
|
setSenderName(result.displayName)
|
||||||
@@ -1697,10 +1720,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
}
|
}
|
||||||
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
|
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
|
||||||
|
|
||||||
const requestImageDecrypt = useCallback(async (forceUpdate = false) => {
|
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
|
||||||
if (!isImage || imageLoading) return
|
if (!isImage) return
|
||||||
setImageLoading(true)
|
if (imageLoading) return
|
||||||
setImageError(false)
|
if (!silent) {
|
||||||
|
setImageLoading(true)
|
||||||
|
setImageError(false)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (message.imageMd5 || message.imageDatName) {
|
if (message.imageMd5 || message.imageDatName) {
|
||||||
const result = await window.electronAPI.image.decrypt({
|
const result = await window.electronAPI.image.decrypt({
|
||||||
@@ -1726,14 +1752,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
setImageHasUpdate(false)
|
setImageHasUpdate(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setImageError(true)
|
if (!silent) setImageError(true)
|
||||||
} catch {
|
} catch {
|
||||||
setImageError(true)
|
if (!silent) setImageError(true)
|
||||||
} finally {
|
} finally {
|
||||||
setImageLoading(false)
|
if (!silent) setImageLoading(false)
|
||||||
}
|
}
|
||||||
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
||||||
|
|
||||||
|
const triggerForceHd = useCallback(() => {
|
||||||
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
|
if (imageForceHdAttempted.current === imageCacheKey) return
|
||||||
|
if (imageForceHdPending.current) return
|
||||||
|
imageForceHdAttempted.current = imageCacheKey
|
||||||
|
imageForceHdPending.current = true
|
||||||
|
requestImageDecrypt(true, true).finally(() => {
|
||||||
|
imageForceHdPending.current = false
|
||||||
|
})
|
||||||
|
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
|
||||||
|
|
||||||
const handleImageClick = useCallback(() => {
|
const handleImageClick = useCallback(() => {
|
||||||
if (imageClickTimerRef.current) {
|
if (imageClickTimerRef.current) {
|
||||||
window.clearTimeout(imageClickTimerRef.current)
|
window.clearTimeout(imageClickTimerRef.current)
|
||||||
@@ -1769,7 +1806,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
sessionId: session.username,
|
sessionId: session.username,
|
||||||
imageMd5: message.imageMd5 || undefined,
|
imageMd5: message.imageMd5 || undefined,
|
||||||
imageDatName: message.imageDatName
|
imageDatName: message.imageDatName
|
||||||
}).then((result) => {
|
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (result.success && result.localPath) {
|
if (result.success && result.localPath) {
|
||||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||||
@@ -1787,7 +1824,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload) => {
|
const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => {
|
||||||
const matchesCacheKey =
|
const matchesCacheKey =
|
||||||
payload.cacheKey === message.imageMd5 ||
|
payload.cacheKey === message.imageMd5 ||
|
||||||
payload.cacheKey === message.imageDatName ||
|
payload.cacheKey === message.imageDatName ||
|
||||||
@@ -1804,7 +1841,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => {
|
const unsubscribe = window.electronAPI.image.onCacheResolved((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => {
|
||||||
const matchesCacheKey =
|
const matchesCacheKey =
|
||||||
payload.cacheKey === message.imageMd5 ||
|
payload.cacheKey === message.imageMd5 ||
|
||||||
payload.cacheKey === message.imageDatName ||
|
payload.cacheKey === message.imageDatName ||
|
||||||
@@ -1846,6 +1883,47 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
}, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
||||||
|
|
||||||
|
// 进入视野时自动尝试切换高清图
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage) return
|
||||||
|
const container = imageContainerRef.current
|
||||||
|
if (!container) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
setImageInView(entry.isIntersecting)
|
||||||
|
},
|
||||||
|
{ rootMargin: '120px', threshold: 0 }
|
||||||
|
)
|
||||||
|
observer.observe(container)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [isImage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage || !imageHasUpdate || !imageInView) return
|
||||||
|
if (imageAutoHdTriggered.current === imageCacheKey) return
|
||||||
|
imageAutoHdTriggered.current = imageCacheKey
|
||||||
|
triggerForceHd()
|
||||||
|
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage || !showImagePreview || !imageHasUpdate) return
|
||||||
|
if (imageAutoHdTriggered.current === imageCacheKey) return
|
||||||
|
imageAutoHdTriggered.current = imageCacheKey
|
||||||
|
triggerForceHd()
|
||||||
|
}, [isImage, showImagePreview, imageHasUpdate, imageCacheKey, triggerForceHd])
|
||||||
|
|
||||||
|
// 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage || !imageInView) return
|
||||||
|
triggerForceHd()
|
||||||
|
}, [isImage, imageInView, triggerForceHd])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage || !showImagePreview) return
|
||||||
|
triggerForceHd()
|
||||||
|
}, [isImage, showImagePreview, triggerForceHd])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVoice) return
|
if (!isVoice) return
|
||||||
@@ -1933,7 +2011,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVoice || voiceDataUrl) return
|
if (!isVoice || voiceDataUrl) return
|
||||||
window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId))
|
window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId))
|
||||||
.then(result => {
|
.then((result: { success: boolean; hasCache: boolean; data?: string; error?: string }) => {
|
||||||
if (result.success && result.hasCache && result.data) {
|
if (result.success && result.hasCache && result.data) {
|
||||||
const url = `data:audio/wav;base64,${result.data}`
|
const url = `data:audio/wav;base64,${result.data}`
|
||||||
voiceDataUrlCache.set(voiceCacheKey, url)
|
voiceDataUrlCache.set(voiceCacheKey, url)
|
||||||
@@ -2066,7 +2144,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
|
|
||||||
console.log('[Video Debug] Loading video info for MD5:', videoMd5)
|
console.log('[Video Debug] Loading video info for MD5:', videoMd5)
|
||||||
setVideoLoading(true)
|
setVideoLoading(true)
|
||||||
window.electronAPI.video.getVideoInfo(videoMd5).then((result) => {
|
window.electronAPI.video.getVideoInfo(videoMd5).then((result: { success: boolean; exists: boolean; videoUrl?: string; coverUrl?: string; thumbUrl?: string; error?: string }) => {
|
||||||
console.log('[Video Debug] getVideoInfo result:', result)
|
console.log('[Video Debug] getVideoInfo result:', result)
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
setVideoInfo({
|
setVideoInfo({
|
||||||
@@ -2079,7 +2157,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
console.error('[Video Debug] Video info failed:', result)
|
console.error('[Video Debug] Video info failed:', result)
|
||||||
setVideoInfo({ exists: false })
|
setVideoInfo({ exists: false })
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err: unknown) => {
|
||||||
console.error('[Video Debug] getVideoInfo error:', err)
|
console.error('[Video Debug] getVideoInfo error:', err)
|
||||||
setVideoInfo({ exists: false })
|
setVideoInfo({ exists: false })
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -2092,7 +2170,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
|
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronAPI.config.get('autoTranscribeVoice').then((value) => {
|
window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => {
|
||||||
setAutoTranscribeEnabled(value === true)
|
setAutoTranscribeEnabled(value === true)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -2196,23 +2274,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
src={imageLocalPath}
|
src={imageLocalPath}
|
||||||
alt="图片"
|
alt="图片"
|
||||||
className="image-message"
|
className="image-message"
|
||||||
onClick={() => setShowImagePreview(true)}
|
onClick={() => {
|
||||||
|
if (imageHasUpdate) {
|
||||||
|
void requestImageDecrypt(true, true)
|
||||||
|
}
|
||||||
|
setShowImagePreview(true)
|
||||||
|
}}
|
||||||
onLoad={() => setImageError(false)}
|
onLoad={() => setImageError(false)}
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
/>
|
/>
|
||||||
{imageHasUpdate && (
|
|
||||||
<button
|
|
||||||
className="image-update-button"
|
|
||||||
type="button"
|
|
||||||
title="发现更高清图片,点击更新"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
void requestImageDecrypt(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{showImagePreview && (
|
{showImagePreview && (
|
||||||
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
|
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
|
||||||
|
|||||||
@@ -45,18 +45,18 @@ function ContactsPage() {
|
|||||||
if (contactsResult.success && contactsResult.contacts) {
|
if (contactsResult.success && contactsResult.contacts) {
|
||||||
console.log('📊 总联系人数:', contactsResult.contacts.length)
|
console.log('📊 总联系人数:', contactsResult.contacts.length)
|
||||||
console.log('📊 按类型统计:', {
|
console.log('📊 按类型统计:', {
|
||||||
friends: contactsResult.contacts.filter(c => c.type === 'friend').length,
|
friends: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'friend').length,
|
||||||
groups: contactsResult.contacts.filter(c => c.type === 'group').length,
|
groups: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'group').length,
|
||||||
officials: contactsResult.contacts.filter(c => c.type === 'official').length,
|
officials: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'official').length,
|
||||||
other: contactsResult.contacts.filter(c => c.type === 'other').length
|
other: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'other').length
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取头像URL
|
// 获取头像URL
|
||||||
const usernames = contactsResult.contacts.map(c => c.username)
|
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
|
||||||
if (usernames.length > 0) {
|
if (usernames.length > 0) {
|
||||||
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||||
if (avatarResult.success && avatarResult.contacts) {
|
if (avatarResult.success && avatarResult.contacts) {
|
||||||
contactsResult.contacts.forEach(contact => {
|
contactsResult.contacts.forEach((contact: ContactInfo) => {
|
||||||
const enriched = avatarResult.contacts?.[contact.username]
|
const enriched = avatarResult.contacts?.[contact.username]
|
||||||
if (enriched?.avatarUrl) {
|
if (enriched?.avatarUrl) {
|
||||||
contact.avatarUrl = enriched.avatarUrl
|
contact.avatarUrl = enriched.avatarUrl
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -19,6 +19,7 @@ interface ExportOptions {
|
|||||||
exportMedia: boolean
|
exportMedia: boolean
|
||||||
exportImages: boolean
|
exportImages: boolean
|
||||||
exportVoices: boolean
|
exportVoices: boolean
|
||||||
|
exportVideos: boolean
|
||||||
exportEmojis: boolean
|
exportEmojis: boolean
|
||||||
exportVoiceAsText: boolean
|
exportVoiceAsText: boolean
|
||||||
excelCompactColumns: boolean
|
excelCompactColumns: boolean
|
||||||
@@ -65,6 +66,7 @@ function ExportPage() {
|
|||||||
exportMedia: false,
|
exportMedia: false,
|
||||||
exportImages: true,
|
exportImages: true,
|
||||||
exportVoices: true,
|
exportVoices: true,
|
||||||
|
exportVideos: true,
|
||||||
exportEmojis: true,
|
exportEmojis: true,
|
||||||
exportVoiceAsText: true,
|
exportVoiceAsText: true,
|
||||||
excelCompactColumns: true,
|
excelCompactColumns: true,
|
||||||
@@ -187,7 +189,7 @@ function ExportPage() {
|
|||||||
}, [loadSessions])
|
}, [loadSessions])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
|
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
|
||||||
setExportProgress({
|
setExportProgress({
|
||||||
current: payload.current,
|
current: payload.current,
|
||||||
total: payload.total,
|
total: payload.total,
|
||||||
@@ -257,6 +259,7 @@ function ExportPage() {
|
|||||||
exportMedia: true,
|
exportMedia: true,
|
||||||
exportImages: true,
|
exportImages: true,
|
||||||
exportVoices: true,
|
exportVoices: true,
|
||||||
|
exportVideos: true,
|
||||||
exportEmojis: true,
|
exportEmojis: true,
|
||||||
exportVoiceAsText: true
|
exportVoiceAsText: true
|
||||||
}
|
}
|
||||||
@@ -286,6 +289,7 @@ function ExportPage() {
|
|||||||
exportMedia: options.exportMedia,
|
exportMedia: options.exportMedia,
|
||||||
exportImages: options.exportMedia && options.exportImages,
|
exportImages: options.exportMedia && options.exportImages,
|
||||||
exportVoices: options.exportMedia && options.exportVoices,
|
exportVoices: options.exportMedia && options.exportVoices,
|
||||||
|
exportVideos: options.exportMedia && options.exportVideos,
|
||||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
||||||
excelCompactColumns: options.excelCompactColumns,
|
excelCompactColumns: options.excelCompactColumns,
|
||||||
@@ -609,7 +613,7 @@ function ExportPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
<h3>媒体文件</h3>
|
<h3>媒体文件</h3>
|
||||||
<p className="setting-subtitle">导出图片/语音/表情并在记录内写入相对路径</p>
|
<p className="setting-subtitle">导出图片/语音/视频/表情并在记录内写入相对路径</p>
|
||||||
<div className="media-options-card">
|
<div className="media-options-card">
|
||||||
<div className="media-switch-row">
|
<div className="media-switch-row">
|
||||||
<div className="media-switch-info">
|
<div className="media-switch-info">
|
||||||
@@ -661,7 +665,7 @@ function ExportPage() {
|
|||||||
<label className="media-checkbox-row">
|
<label className="media-checkbox-row">
|
||||||
<div className="media-checkbox-info">
|
<div className="media-checkbox-info">
|
||||||
<span className="media-checkbox-title">语音转文字</span>
|
<span className="media-checkbox-title">语音转文字</span>
|
||||||
<span className="media-checkbox-desc">将语音消息转换为文字导出(不导出语音文件)</span>
|
<span className="media-checkbox-desc">将语音消息转换为文字导出</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -672,6 +676,21 @@ function ExportPage() {
|
|||||||
|
|
||||||
<div className="media-option-divider"></div>
|
<div className="media-option-divider"></div>
|
||||||
|
|
||||||
|
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||||
|
<div className="media-checkbox-info">
|
||||||
|
<span className="media-checkbox-title">视频</span>
|
||||||
|
<span className="media-checkbox-desc">直接复制视频文件到导出目录</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.exportVideos}
|
||||||
|
disabled={!options.exportMedia}
|
||||||
|
onChange={e => setOptions({ ...options, exportVideos: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="media-option-divider"></div>
|
||||||
|
|
||||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||||
<div className="media-checkbox-info">
|
<div className="media-checkbox-info">
|
||||||
<span className="media-checkbox-title">表情</span>
|
<span className="media-checkbox-title">表情</span>
|
||||||
|
|||||||
@@ -333,7 +333,7 @@
|
|||||||
.group-avatar {
|
.group-avatar {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 50%;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
@@ -346,11 +346,11 @@
|
|||||||
.avatar-placeholder {
|
.avatar-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
background: var(--bg-tertiary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@
|
|||||||
.skeleton-avatar {
|
.skeleton-avatar {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 50%;
|
border-radius: 8px;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
animation: pulse 1.5s infinite;
|
animation: pulse 1.5s infinite;
|
||||||
}
|
}
|
||||||
@@ -500,7 +500,7 @@
|
|||||||
.group-avatar.large {
|
.group-avatar.large {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 auto 16px;
|
margin: 0 auto 16px;
|
||||||
|
|
||||||
@@ -513,11 +513,11 @@
|
|||||||
.avatar-placeholder {
|
.avatar-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
background: var(--bg-tertiary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +656,32 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-body {
|
.content-body {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
|
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import DateRangePicker from '../components/DateRangePicker'
|
import DateRangePicker from '../components/DateRangePicker'
|
||||||
@@ -39,6 +39,7 @@ function GroupAnalyticsPage() {
|
|||||||
const [activeHours, setActiveHours] = useState<Record<number, number>>({})
|
const [activeHours, setActiveHours] = useState<Record<number, number>>({})
|
||||||
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
|
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
|
||||||
const [functionLoading, setFunctionLoading] = useState(false)
|
const [functionLoading, setFunctionLoading] = useState(false)
|
||||||
|
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||||
|
|
||||||
// 成员详情弹框
|
// 成员详情弹框
|
||||||
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
||||||
@@ -181,6 +182,10 @@ function GroupAnalyticsPage() {
|
|||||||
return num.toLocaleString()
|
return num.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeFileName = (name: string) => {
|
||||||
|
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
|
||||||
|
}
|
||||||
|
|
||||||
const getHourlyOption = () => {
|
const getHourlyOption = () => {
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||||
const data = hours.map(h => activeHours[h] || 0)
|
const data = hours.map(h => activeHours[h] || 0)
|
||||||
@@ -252,6 +257,35 @@ function GroupAnalyticsPage() {
|
|||||||
setCopiedField(null)
|
setCopiedField(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportMembers = async () => {
|
||||||
|
if (!selectedGroup || isExportingMembers) return
|
||||||
|
setIsExportingMembers(true)
|
||||||
|
try {
|
||||||
|
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||||
|
const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_群成员列表`)
|
||||||
|
const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
|
||||||
|
const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx`
|
||||||
|
const saveResult = await window.electronAPI.dialog.saveFile({
|
||||||
|
title: '导出群成员列表',
|
||||||
|
defaultPath,
|
||||||
|
filters: [{ name: 'Excel', extensions: ['xlsx'] }]
|
||||||
|
})
|
||||||
|
if (!saveResult || saveResult.canceled || !saveResult.filePath) return
|
||||||
|
|
||||||
|
const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath)
|
||||||
|
if (result.success) {
|
||||||
|
alert(`导出成功,共 ${result.count ?? members.length} 人`)
|
||||||
|
} else {
|
||||||
|
alert(`导出失败:${result.error || '未知错误'}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('导出群成员失败:', e)
|
||||||
|
alert(`导出失败:${String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setIsExportingMembers(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCopy = async (text: string, field: string) => {
|
const handleCopy = async (text: string, field: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -423,6 +457,12 @@ function GroupAnalyticsPage() {
|
|||||||
onRangeComplete={handleDateRangeComplete}
|
onRangeComplete={handleDateRangeComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{selectedFunction === 'members' && (
|
||||||
|
<button className="export-btn" onClick={handleExportMembers} disabled={functionLoading || isExportingMembers}>
|
||||||
|
{isExportingMembers ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
|
||||||
|
<span>导出成员</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
|
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
|
||||||
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
|
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -62,9 +62,11 @@ function SettingsPage() {
|
|||||||
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
||||||
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
||||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||||
|
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
|
||||||
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
|
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
const [logEnabled, setLogEnabled] = useState(false)
|
const [logEnabled, setLogEnabled] = useState(false)
|
||||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||||
@@ -96,6 +98,7 @@ function SettingsPage() {
|
|||||||
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||||
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
||||||
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
||||||
|
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||||
|
|
||||||
// 安全设置 state
|
// 安全设置 state
|
||||||
const [authEnabled, setAuthEnabled] = useState(false)
|
const [authEnabled, setAuthEnabled] = useState(false)
|
||||||
@@ -125,6 +128,9 @@ function SettingsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
loadAppVersion()
|
loadAppVersion()
|
||||||
|
return () => {
|
||||||
|
Object.values(saveTimersRef.current).forEach((timer) => clearTimeout(timer))
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 点击外部关闭下拉框
|
// 点击外部关闭下拉框
|
||||||
@@ -140,16 +146,19 @@ function SettingsPage() {
|
|||||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
}
|
}
|
||||||
|
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
|
||||||
|
setShowExportConcurrencySelect(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
|
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
setDbKeyStatus(payload.message)
|
||||||
})
|
})
|
||||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => {
|
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
|
||||||
setImageKeyStatus(payload.message)
|
setImageKeyStatus(payload.message)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
@@ -261,7 +270,7 @@ function SettingsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
|
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
|
||||||
if (typeof payload.percent === 'number') {
|
if (typeof payload.percent === 'number') {
|
||||||
setWhisperDownloadProgress(payload.percent)
|
setWhisperDownloadProgress(payload.percent)
|
||||||
}
|
}
|
||||||
@@ -334,6 +343,12 @@ function SettingsPage() {
|
|||||||
imageAesKey: imageAesKey || ''
|
imageAesKey: imageAesKey || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const buildKeysFromInputs = (overrides?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string }): WxidKeys => ({
|
||||||
|
decryptKey: overrides?.decryptKey ?? decryptKey ?? '',
|
||||||
|
imageXorKey: parseImageXorKey(overrides?.imageXorKey ?? imageXorKey),
|
||||||
|
imageAesKey: overrides?.imageAesKey ?? imageAesKey ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
|
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
|
||||||
decryptKey: wxidConfig?.decryptKey || '',
|
decryptKey: wxidConfig?.decryptKey || '',
|
||||||
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
|
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
|
||||||
@@ -358,7 +373,7 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const applyWxidSelection = async (
|
const applyWxidSelection = async (
|
||||||
selectedWxid: string,
|
selectedWxid: string,
|
||||||
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string }
|
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string; keysOverride?: WxidKeys }
|
||||||
) => {
|
) => {
|
||||||
if (!selectedWxid) return
|
if (!selectedWxid) return
|
||||||
|
|
||||||
@@ -374,9 +389,9 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preferCurrentKeys = options?.preferCurrentKeys ?? false
|
const preferCurrentKeys = options?.preferCurrentKeys ?? false
|
||||||
const keys = preferCurrentKeys
|
const keys = options?.keysOverride ?? (preferCurrentKeys
|
||||||
? buildKeysFromState()
|
? buildKeysFromState()
|
||||||
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))
|
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid)))
|
||||||
|
|
||||||
setWxid(selectedWxid)
|
setWxid(selectedWxid)
|
||||||
applyKeysToState(keys)
|
applyKeysToState(keys)
|
||||||
@@ -444,7 +459,9 @@ function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
|
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
|
||||||
if (!result.canceled && result.filePaths.length > 0) {
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
setDbPath(result.filePaths[0])
|
const selectedPath = result.filePaths[0]
|
||||||
|
setDbPath(selectedPath)
|
||||||
|
await configService.setDbPath(selectedPath)
|
||||||
showMessage('已选择数据库目录', true)
|
showMessage('已选择数据库目录', true)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -454,7 +471,7 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const handleScanWxid = async (
|
const handleScanWxid = async (
|
||||||
silent = false,
|
silent = false,
|
||||||
options?: { preferCurrentKeys?: boolean; showDialog?: boolean }
|
options?: { preferCurrentKeys?: boolean; showDialog?: boolean; keysOverride?: WxidKeys }
|
||||||
) => {
|
) => {
|
||||||
if (!dbPath) {
|
if (!dbPath) {
|
||||||
if (!silent) showMessage('请先选择数据库目录', false)
|
if (!silent) showMessage('请先选择数据库目录', false)
|
||||||
@@ -468,7 +485,8 @@ function SettingsPage() {
|
|||||||
await applyWxidSelection(wxids[0].wxid, {
|
await applyWxidSelection(wxids[0].wxid, {
|
||||||
preferCurrentKeys: options?.preferCurrentKeys ?? false,
|
preferCurrentKeys: options?.preferCurrentKeys ?? false,
|
||||||
showToast: !silent,
|
showToast: !silent,
|
||||||
toastText: `已检测到账号:${wxids[0].wxid}`
|
toastText: `已检测到账号:${wxids[0].wxid}`,
|
||||||
|
keysOverride: options?.keysOverride
|
||||||
})
|
})
|
||||||
} else if (wxids.length > 1 && allowDialog) {
|
} else if (wxids.length > 1 && allowDialog) {
|
||||||
setShowWxidSelect(true)
|
setShowWxidSelect(true)
|
||||||
@@ -488,7 +506,9 @@ function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
||||||
if (!result.canceled && result.filePaths.length > 0) {
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
setCachePath(result.filePaths[0])
|
const selectedPath = result.filePaths[0]
|
||||||
|
setCachePath(selectedPath)
|
||||||
|
await configService.setCachePath(selectedPath)
|
||||||
showMessage('已选择缓存目录', true)
|
showMessage('已选择缓存目录', true)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -554,7 +574,9 @@ function SettingsPage() {
|
|||||||
setDecryptKey(result.key)
|
setDecryptKey(result.key)
|
||||||
setDbKeyStatus('密钥获取成功')
|
setDbKeyStatus('密钥获取成功')
|
||||||
showMessage('已自动获取解密密钥', true)
|
showMessage('已自动获取解密密钥', true)
|
||||||
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false })
|
await syncCurrentKeys({ decryptKey: result.key, wxid })
|
||||||
|
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
|
||||||
|
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride })
|
||||||
} else {
|
} else {
|
||||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||||
setIsManualStartPrompt(true)
|
setIsManualStartPrompt(true)
|
||||||
@@ -575,12 +597,25 @@ function SettingsPage() {
|
|||||||
handleAutoGetDbKey()
|
handleAutoGetDbKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to sync current keys to wxid config
|
// Debounce config writes to avoid excessive disk IO
|
||||||
const syncCurrentKeys = async () => {
|
const scheduleConfigSave = (key: string, task: () => Promise<void> | void, delay = 300) => {
|
||||||
const keys = buildKeysFromState()
|
const timers = saveTimersRef.current
|
||||||
|
if (timers[key]) {
|
||||||
|
clearTimeout(timers[key])
|
||||||
|
}
|
||||||
|
timers[key] = setTimeout(() => {
|
||||||
|
Promise.resolve(task()).catch((e) => {
|
||||||
|
console.error('保存配置失败:', e)
|
||||||
|
})
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncCurrentKeys = async (options?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string; wxid?: string }) => {
|
||||||
|
const keys = buildKeysFromInputs(options)
|
||||||
await syncKeysToConfig(keys)
|
await syncKeysToConfig(keys)
|
||||||
if (wxid) {
|
const wxidToUse = options?.wxid ?? wxid
|
||||||
await configService.setWxidConfig(wxid, {
|
if (wxidToUse) {
|
||||||
|
await configService.setWxidConfig(wxidToUse, {
|
||||||
decryptKey: keys.decryptKey,
|
decryptKey: keys.decryptKey,
|
||||||
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
|
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
|
||||||
imageAesKey: keys.imageAesKey
|
imageAesKey: keys.imageAesKey
|
||||||
@@ -804,11 +839,12 @@ function SettingsPage() {
|
|||||||
type={showDecryptKey ? 'text' : 'password'}
|
type={showDecryptKey ? 'text' : 'password'}
|
||||||
placeholder="例如: a1b2c3d4e5f6..."
|
placeholder="例如: a1b2c3d4e5f6..."
|
||||||
value={decryptKey}
|
value={decryptKey}
|
||||||
onChange={(e) => setDecryptKey(e.target.value)}
|
onChange={(e) => {
|
||||||
onBlur={async () => {
|
const value = e.target.value
|
||||||
if (decryptKey && decryptKey.length === 64) {
|
setDecryptKey(value)
|
||||||
await syncCurrentKeys()
|
if (value && value.length === 64) {
|
||||||
// showMessage('解密密钥已保存', true)
|
scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value, wxid }))
|
||||||
|
// showMessage('解密密钥已保存', true)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -839,11 +875,14 @@ function SettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
|
placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
|
||||||
value={dbPath}
|
value={dbPath}
|
||||||
onChange={(e) => setDbPath(e.target.value)}
|
onChange={(e) => {
|
||||||
onBlur={async () => {
|
const value = e.target.value
|
||||||
if (dbPath) {
|
setDbPath(value)
|
||||||
await configService.setDbPath(dbPath)
|
scheduleConfigSave('dbPath', async () => {
|
||||||
}
|
if (value) {
|
||||||
|
await configService.setDbPath(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="btn-row">
|
<div className="btn-row">
|
||||||
@@ -862,12 +901,43 @@ function SettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: wxid_xxxxxx"
|
placeholder="例如: wxid_xxxxxx"
|
||||||
value={wxid}
|
value={wxid}
|
||||||
onChange={(e) => setWxid(e.target.value)}
|
onChange={(e) => {
|
||||||
onBlur={async () => {
|
const value = e.target.value
|
||||||
if (wxid) {
|
const previousWxid = wxid
|
||||||
await configService.setMyWxid(wxid)
|
setWxid(value)
|
||||||
await syncCurrentKeys() // Sync keys to the new wxid entry
|
scheduleConfigSave('wxid', async () => {
|
||||||
}
|
if (previousWxid && previousWxid !== value) {
|
||||||
|
const currentKeys = buildKeysFromState()
|
||||||
|
await configService.setWxidConfig(previousWxid, {
|
||||||
|
decryptKey: currentKeys.decryptKey,
|
||||||
|
imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0,
|
||||||
|
imageAesKey: currentKeys.imageAesKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
await configService.setMyWxid(value)
|
||||||
|
await syncCurrentKeys({ wxid: value }) // Sync keys to the new wxid entry
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && previousWxid !== value) {
|
||||||
|
if (isDbConnected) {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.chat.close()
|
||||||
|
const result = await window.electronAPI.chat.connect()
|
||||||
|
setDbConnected(result.success, dbPath || undefined)
|
||||||
|
if (!result.success && result.error) {
|
||||||
|
showMessage(result.error, false)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`切换账号后重新连接失败: ${e}`, false)
|
||||||
|
setDbConnected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearAnalyticsStoreCache()
|
||||||
|
resetChatStore()
|
||||||
|
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: value } }))
|
||||||
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -881,8 +951,14 @@ function SettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: 0xA4"
|
placeholder="例如: 0xA4"
|
||||||
value={imageXorKey}
|
value={imageXorKey}
|
||||||
onChange={(e) => setImageXorKey(e.target.value)}
|
onChange={(e) => {
|
||||||
onBlur={syncCurrentKeys}
|
const value = e.target.value
|
||||||
|
setImageXorKey(value)
|
||||||
|
const parsed = parseImageXorKey(value)
|
||||||
|
if (value === '' || parsed !== null) {
|
||||||
|
scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value, wxid }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -893,8 +969,11 @@ function SettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="16 位 AES 密钥"
|
placeholder="16 位 AES 密钥"
|
||||||
value={imageAesKey}
|
value={imageAesKey}
|
||||||
onChange={(e) => setImageAesKey(e.target.value)}
|
onChange={(e) => {
|
||||||
onBlur={syncCurrentKeys}
|
const value = e.target.value
|
||||||
|
setImageAesKey(value)
|
||||||
|
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||||
@@ -1009,8 +1088,11 @@ function SettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="留空使用默认目录"
|
placeholder="留空使用默认目录"
|
||||||
value={whisperModelDir}
|
value={whisperModelDir}
|
||||||
onChange={(e) => setWhisperModelDir(e.target.value)}
|
onChange={(e) => {
|
||||||
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
|
const value = e.target.value
|
||||||
|
setWhisperModelDir(value)
|
||||||
|
scheduleConfigSave('whisperModelDir', () => configService.setWhisperModelDir(value))
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="btn-row">
|
<div className="btn-row">
|
||||||
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> 选择目录</button>
|
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> 选择目录</button>
|
||||||
@@ -1064,6 +1146,15 @@ function SettingsPage() {
|
|||||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const exportConcurrencyOptions = [
|
||||||
|
{ value: 1, label: '1' },
|
||||||
|
{ value: 2, label: '2' },
|
||||||
|
{ value: 3, label: '3' },
|
||||||
|
{ value: 4, label: '4' },
|
||||||
|
{ value: 5, label: '5' },
|
||||||
|
{ value: 6, label: '6' }
|
||||||
|
]
|
||||||
|
|
||||||
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
||||||
return options.find((option) => option.value === value)?.label ?? value
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
}
|
}
|
||||||
@@ -1073,6 +1164,7 @@ function SettingsPage() {
|
|||||||
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
||||||
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
||||||
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
||||||
|
const exportConcurrencyLabel = String(exportDefaultConcurrency)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
@@ -1087,6 +1179,7 @@ function SettingsPage() {
|
|||||||
setShowExportFormatSelect(!showExportFormatSelect)
|
setShowExportFormatSelect(!showExportFormatSelect)
|
||||||
setShowExportDateRangeSelect(false)
|
setShowExportDateRangeSelect(false)
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
setShowExportConcurrencySelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="select-value">{exportFormatLabel}</span>
|
<span className="select-value">{exportFormatLabel}</span>
|
||||||
@@ -1126,6 +1219,7 @@ function SettingsPage() {
|
|||||||
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
||||||
setShowExportFormatSelect(false)
|
setShowExportFormatSelect(false)
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
setShowExportConcurrencySelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="select-value">{exportDateRangeLabel}</span>
|
<span className="select-value">{exportDateRangeLabel}</span>
|
||||||
@@ -1210,6 +1304,7 @@ function SettingsPage() {
|
|||||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||||
setShowExportFormatSelect(false)
|
setShowExportFormatSelect(false)
|
||||||
setShowExportDateRangeSelect(false)
|
setShowExportDateRangeSelect(false)
|
||||||
|
setShowExportConcurrencySelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||||
@@ -1239,6 +1334,45 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>导出并发数</label>
|
||||||
|
<span className="form-hint">导出多个会话时的最大并发(1~6)</span>
|
||||||
|
<div className="select-field" ref={exportConcurrencyDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportConcurrencySelect(!showExportConcurrencySelect)
|
||||||
|
setShowExportFormatSelect(false)
|
||||||
|
setShowExportDateRangeSelect(false)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{exportConcurrencyLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showExportConcurrencySelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportConcurrencyOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportDefaultConcurrency === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultConcurrency(option.value)
|
||||||
|
await configService.setExportDefaultConcurrency(option.value)
|
||||||
|
showMessage(`已将导出并发数设为 ${option.value}`, true)
|
||||||
|
setShowExportConcurrencySelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1252,14 +1386,23 @@ function SettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="留空使用默认目录"
|
placeholder="留空使用默认目录"
|
||||||
value={cachePath}
|
value={cachePath}
|
||||||
onChange={(e) => setCachePath(e.target.value)}
|
onChange={(e) => {
|
||||||
onBlur={async () => {
|
const value = e.target.value
|
||||||
await configService.setCachePath(cachePath)
|
setCachePath(value)
|
||||||
|
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="btn-row">
|
<div className="btn-row">
|
||||||
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
||||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
setCachePath('')
|
||||||
|
await configService.setCachePath('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} /> 恢复默认
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ export default function SnsPage() {
|
|||||||
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
|
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingIds = new Set(currentPosts.map(p => p.id));
|
const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id));
|
||||||
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id));
|
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
|
||||||
|
|
||||||
if (uniqueNewer.length > 0) {
|
if (uniqueNewer.length > 0) {
|
||||||
setPosts(prev => [...uniqueNewer, ...prev]);
|
setPosts(prev => [...uniqueNewer, ...prev]);
|
||||||
@@ -253,7 +253,7 @@ export default function SnsPage() {
|
|||||||
}))
|
}))
|
||||||
setContacts(initialContacts)
|
setContacts(initialContacts)
|
||||||
|
|
||||||
const usernames = initialContacts.map(c => c.username)
|
const usernames = initialContacts.map((c: { username: string }) => c.username)
|
||||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||||
if (enriched.success && enriched.contacts) {
|
if (enriched.success && enriched.contacts) {
|
||||||
setContacts(prev => prev.map(c => {
|
setContacts(prev => prev.map(c => {
|
||||||
|
|||||||
@@ -435,6 +435,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wxid-select {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-option {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-time {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.field-with-toggle {
|
.field-with-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -750,4 +802,4 @@
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
import { dialog } from '../services/ipc'
|
import { dialog } from '../services/ipc'
|
||||||
@@ -35,6 +35,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
const [wxid, setWxid] = useState('')
|
const [wxid, setWxid] = useState('')
|
||||||
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
|
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
|
||||||
|
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||||
|
const wxidSelectRef = useRef<HTMLDivElement>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [isDetectingPath, setIsDetectingPath] = useState(false)
|
const [isDetectingPath, setIsDetectingPath] = useState(false)
|
||||||
@@ -106,10 +108,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
setDbKeyStatus(payload.message)
|
||||||
})
|
})
|
||||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => {
|
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
|
||||||
setImageKeyStatus(payload.message)
|
setImageKeyStatus(payload.message)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
@@ -127,8 +129,22 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWxidOptions([])
|
setWxidOptions([])
|
||||||
setWxid('')
|
setWxid('')
|
||||||
|
setShowWxidSelect(false)
|
||||||
}, [dbPath])
|
}, [dbPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!showWxidSelect) return
|
||||||
|
const target = event.target as Node
|
||||||
|
if (wxidSelectRef.current && !wxidSelectRef.current.contains(target)) {
|
||||||
|
setShowWxidSelect(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showWxidSelect])
|
||||||
|
|
||||||
const currentStep = steps[stepIndex]
|
const currentStep = steps[stepIndex]
|
||||||
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
|
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
|
||||||
const showWindowControls = standalone
|
const showWindowControls = standalone
|
||||||
@@ -217,6 +233,28 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScanWxidCandidates = async () => {
|
||||||
|
if (!dbPath) {
|
||||||
|
setError('请先选择数据库目录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isScanningWxid) return
|
||||||
|
setIsScanningWxid(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const wxids = await window.electronAPI.dbPath.scanWxidCandidates(dbPath)
|
||||||
|
setWxidOptions(wxids)
|
||||||
|
setShowWxidSelect(true)
|
||||||
|
if (!wxids.length) {
|
||||||
|
setError('未检测到可用的账号目录,请检查路径')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`扫描失败: ${e}`)
|
||||||
|
} finally {
|
||||||
|
setIsScanningWxid(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAutoGetDbKey = async () => {
|
const handleAutoGetDbKey = async () => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
@@ -556,14 +594,35 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
{currentStep.id === 'key' && (
|
{currentStep.id === 'key' && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="field-label">微信账号 (Wxid)</label>
|
<label className="field-label">微信账号 (Wxid)</label>
|
||||||
<input
|
<div className="wxid-select" ref={wxidSelectRef}>
|
||||||
type="text"
|
<input
|
||||||
className="field-input"
|
type="text"
|
||||||
placeholder="等待获取..."
|
className="field-input"
|
||||||
value={wxid}
|
placeholder="点击选择..."
|
||||||
readOnly
|
value={wxid}
|
||||||
onChange={(e) => setWxid(e.target.value)}
|
readOnly
|
||||||
/>
|
onClick={handleScanWxidCandidates}
|
||||||
|
onChange={(e) => setWxid(e.target.value)}
|
||||||
|
/>
|
||||||
|
{showWxidSelect && wxidOptions.length > 0 && (
|
||||||
|
<div className="wxid-dropdown">
|
||||||
|
{wxidOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.wxid}
|
||||||
|
type="button"
|
||||||
|
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setWxid(opt.wxid)
|
||||||
|
setShowWxidSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="wxid-name">{opt.wxid}</span>
|
||||||
|
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="field-label mt-4">解密密钥</label>
|
<label className="field-label mt-4">解密密钥</label>
|
||||||
<div className="field-with-toggle">
|
<div className="field-with-toggle">
|
||||||
@@ -733,4 +792,3 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default WelcomePage
|
export default WelcomePage
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface AppState {
|
|||||||
setShowUpdateDialog: (show: boolean) => void
|
setShowUpdateDialog: (show: boolean) => void
|
||||||
setUpdateError: (error: string | null) => void
|
setUpdateError: (error: string | null) => void
|
||||||
|
|
||||||
|
// 锁定状态
|
||||||
|
isLocked: boolean
|
||||||
|
setLocked: (locked: boolean) => void
|
||||||
|
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
myWxid: null,
|
myWxid: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
loadingText: '',
|
loadingText: '',
|
||||||
|
isLocked: false,
|
||||||
|
|
||||||
// 更新状态初始化
|
// 更新状态初始化
|
||||||
updateInfo: null,
|
updateInfo: null,
|
||||||
@@ -62,6 +67,8 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
loadingText: text ?? ''
|
loadingText: text ?? ''
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setLocked: (locked) => set({ isLocked: locked }),
|
||||||
|
|
||||||
setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }),
|
setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }),
|
||||||
setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }),
|
setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }),
|
||||||
setDownloadProgress: (progress) => set({ downloadProgress: progress }),
|
setDownloadProgress: (progress) => set({ downloadProgress: progress }),
|
||||||
@@ -74,6 +81,7 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
myWxid: null,
|
myWxid: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
loadingText: '',
|
loadingText: '',
|
||||||
|
isLocked: false,
|
||||||
updateInfo: null,
|
updateInfo: null,
|
||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
downloadProgress: { percent: 0 },
|
downloadProgress: { percent: 0 },
|
||||||
|
|||||||
6
src/types/electron.d.ts
vendored
6
src/types/electron.d.ts
vendored
@@ -42,6 +42,7 @@ export interface ElectronAPI {
|
|||||||
dbPath: {
|
dbPath: {
|
||||||
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
|
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||||
scanWxids: (rootPath: string) => Promise<WxidInfo[]>
|
scanWxids: (rootPath: string) => Promise<WxidInfo[]>
|
||||||
|
scanWxidCandidates: (rootPath: string) => Promise<WxidInfo[]>
|
||||||
getDefault: () => Promise<string>
|
getDefault: () => Promise<string>
|
||||||
}
|
}
|
||||||
wcdb: {
|
wcdb: {
|
||||||
@@ -232,6 +233,11 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
count?: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
annualReport: {
|
annualReport: {
|
||||||
getAvailableYears: () => Promise<{
|
getAvailableYears: () => Promise<{
|
||||||
|
|||||||
13
src/vite-env.d.ts
vendored
13
src/vite-env.d.ts
vendored
@@ -1 +1,14 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
electronAPI: {
|
||||||
|
// ... other methods ...
|
||||||
|
auth: {
|
||||||
|
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
}
|
||||||
|
// For brevity, using 'any' for other parts or properly importing types if available.
|
||||||
|
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
|
||||||
|
// or import a shared type definition.
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user