mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
@@ -906,6 +906,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()
|
||||||
|
|||||||
@@ -183,7 +183,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)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 年度报告
|
// 年度报告
|
||||||
|
|||||||
@@ -142,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
|
||||||
@@ -1740,9 +1746,9 @@ 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, {
|
||||||
@@ -1956,8 +1962,8 @@ 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, {
|
||||||
@@ -2348,8 +2354,8 @@ 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, {
|
||||||
@@ -2653,8 +2659,8 @@ 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, {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -189,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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -155,10 +155,10 @@ function SettingsPage() {
|
|||||||
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
|
}, [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 () => {
|
||||||
@@ -270,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)
|
||||||
}
|
}
|
||||||
@@ -373,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
|
||||||
|
|
||||||
@@ -389,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)
|
||||||
@@ -471,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)
|
||||||
@@ -485,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)
|
||||||
@@ -573,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)
|
||||||
@@ -840,7 +843,7 @@ function SettingsPage() {
|
|||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
setDecryptKey(value)
|
setDecryptKey(value)
|
||||||
if (value && value.length === 64) {
|
if (value && value.length === 64) {
|
||||||
scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value }))
|
scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value, wxid }))
|
||||||
// showMessage('解密密钥已保存', true)
|
// showMessage('解密密钥已保存', true)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -900,12 +903,40 @@ function SettingsPage() {
|
|||||||
value={wxid}
|
value={wxid}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
|
const previousWxid = wxid
|
||||||
setWxid(value)
|
setWxid(value)
|
||||||
scheduleConfigSave('wxid', async () => {
|
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) {
|
if (value) {
|
||||||
await configService.setMyWxid(value)
|
await configService.setMyWxid(value)
|
||||||
await syncCurrentKeys({ wxid: value }) // Sync keys to the new wxid entry
|
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 } }))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -925,7 +956,7 @@ function SettingsPage() {
|
|||||||
setImageXorKey(value)
|
setImageXorKey(value)
|
||||||
const parsed = parseImageXorKey(value)
|
const parsed = parseImageXorKey(value)
|
||||||
if (value === '' || parsed !== null) {
|
if (value === '' || parsed !== null) {
|
||||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value }))
|
scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value, wxid }))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -941,7 +972,7 @@ function SettingsPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
setImageAesKey(value)
|
setImageAesKey(value)
|
||||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: 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}>
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -106,10 +106,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 () => {
|
||||||
|
|||||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -232,6 +232,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<{
|
||||||
|
|||||||
Reference in New Issue
Block a user