mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
Merge branch 'nanin/develop' into develop
This commit is contained in:
+1166
-59
File diff suppressed because it is too large
Load Diff
+662
-110
File diff suppressed because it is too large
Load Diff
+14
-7
@@ -1231,14 +1231,21 @@ app.whenReady().then(async () => {
|
||||
return stickerService.resolveSticker(cdnUrl, md5)
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
|
||||
if (!videoAssetService) {
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||
videoAssetService = new VideoAssetService(client)
|
||||
ipcMain.handle(
|
||||
'db:getVideo',
|
||||
async (
|
||||
_,
|
||||
hashes: string[],
|
||||
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
||||
) => {
|
||||
if (!videoAssetService) {
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||
videoAssetService = new VideoAssetService(client)
|
||||
}
|
||||
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [], options)
|
||||
}
|
||||
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
|
||||
})
|
||||
)
|
||||
|
||||
// -------- Settings & API service --------
|
||||
|
||||
|
||||
@@ -430,9 +430,14 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
|
||||
const isFileMessage = appMsgType === '6' || appMsgType === '74'
|
||||
if (appMsgType === '19') {
|
||||
return parseForwardBundle(content)
|
||||
}
|
||||
if (!isFileMessage && /<recorditem\b|<dataitem\b/i.test(content)) {
|
||||
const forwardBundle = parseForwardBundle(content)
|
||||
if (forwardBundle.items.length > 0) return forwardBundle
|
||||
}
|
||||
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||
const sticker = parseStickerMessage(content)
|
||||
if (sticker.type === 'sticker') return sticker
|
||||
|
||||
@@ -449,6 +449,23 @@ export async function listMessagesAsync(
|
||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||
}
|
||||
|
||||
export async function listMessagesForExport(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
|
||||
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime)
|
||||
console.log(
|
||||
`[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}`
|
||||
)
|
||||
return mergedMessages
|
||||
}
|
||||
|
||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
if (!dbRef) return null
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
|
||||
@@ -40,12 +40,13 @@ let archivePath = ''
|
||||
let writeTimer: NodeJS.Timeout | null = null
|
||||
let writeQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function messageIdentity(message: Message): string {
|
||||
export function messageIdentity(message: Message): string {
|
||||
if (message.recoveredFromRecallJournal) {
|
||||
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
|
||||
}
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.serverId) return `server:${message.serverId}`
|
||||
const serverId = String(message.serverId || '').trim()
|
||||
if (serverId && serverId !== '0') return `server:${serverId}`
|
||||
if (message.localId) return `local:${message.localId}:${message.createTime || 0}`
|
||||
if (message.id) return `id:${message.id}`
|
||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||
}
|
||||
|
||||
@@ -8,14 +8,39 @@ type VideoAsset = {
|
||||
posterPath?: string
|
||||
}
|
||||
|
||||
export type VideoResolveOptions = {
|
||||
createTime?: number
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
type ImageDimensions = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type Mp4Box = {
|
||||
type: string
|
||||
size: number
|
||||
contentOffset: number
|
||||
}
|
||||
|
||||
export class VideoAssetService {
|
||||
private readonly urlTokens = new Map<string, string>()
|
||||
private readonly fileTokens = new Map<string, string>()
|
||||
private readonly monthAssets = new Map<string, VideoAsset[]>()
|
||||
private readonly fileHashes = new Map<string, Promise<string | undefined>>()
|
||||
private readonly videoDurations = new Map<string, number | undefined>()
|
||||
private readonly imageDimensions = new Map<string, ImageDimensions | undefined>()
|
||||
private index: Map<string, VideoAsset> | null = null
|
||||
|
||||
constructor(private readonly client: Wcdb4Client) {}
|
||||
|
||||
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
|
||||
async resolve(
|
||||
hashes: string[],
|
||||
options: VideoResolveOptions = {}
|
||||
): Promise<{ success: boolean; url?: string; poster?: string; error?: string }> {
|
||||
const candidates = Array.from(
|
||||
new Set(
|
||||
hashes
|
||||
@@ -53,6 +78,15 @@ export class VideoAssetService {
|
||||
poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await this.resolveFromLocalMetadata(candidates, options)
|
||||
if (fallback) {
|
||||
return {
|
||||
success: true,
|
||||
url: this.createLocalMediaUrl(fallback.filePath),
|
||||
poster: fallback.posterPath ? this.createLocalMediaUrl(fallback.posterPath) : undefined
|
||||
}
|
||||
}
|
||||
return { success: false, error: '本地未找到该视频文件' }
|
||||
}
|
||||
|
||||
@@ -105,22 +139,206 @@ export class VideoAssetService {
|
||||
for (const month of fs.readdirSync(root)) {
|
||||
const monthPath = path.join(root, month)
|
||||
if (!fs.statSync(monthPath).isDirectory()) continue
|
||||
const monthly = new Map<string, VideoAsset>()
|
||||
for (const name of fs.readdirSync(monthPath)) {
|
||||
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
|
||||
const videoMatch = /^([a-f0-9]{32})(?:(_raw))?\.mp4$/i.exec(name)
|
||||
const posterMatch = /^([a-f0-9]{32})(?:(_raw))?(?:_thumb)?\.jpg$/i.exec(name)
|
||||
const match = videoMatch || posterMatch
|
||||
if (!match) continue
|
||||
const key = `${match[1].toLowerCase()}${match[2] || ''}`
|
||||
const fullPath = path.join(monthPath, name)
|
||||
const existing = result.get(key) || { filePath: '' }
|
||||
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
|
||||
const existing = monthly.get(key) || { filePath: '' }
|
||||
if (videoMatch) existing.filePath = fullPath
|
||||
else if (!existing.posterPath) existing.posterPath = fullPath
|
||||
result.set(key, existing)
|
||||
monthly.set(key, existing)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, asset] of result) {
|
||||
if (!asset.filePath) result.delete(key)
|
||||
const assets: VideoAsset[] = []
|
||||
for (const [key, asset] of monthly) {
|
||||
if (!asset.filePath) continue
|
||||
result.set(key, asset)
|
||||
assets.push(asset)
|
||||
}
|
||||
this.monthAssets.set(month, assets)
|
||||
}
|
||||
this.index = result
|
||||
return result
|
||||
}
|
||||
|
||||
private async resolveFromLocalMetadata(
|
||||
hashes: string[],
|
||||
options: VideoResolveOptions
|
||||
): Promise<VideoAsset | undefined> {
|
||||
const month = this.monthForCreateTime(options.createTime)
|
||||
if (!month) return undefined
|
||||
|
||||
this.getIndex()
|
||||
const assets = this.monthAssets.get(month) || []
|
||||
if (assets.length === 0) return undefined
|
||||
|
||||
let narrowed = assets
|
||||
let appliedCriteria = 0
|
||||
const width = Number(options.width)
|
||||
const height = Number(options.height)
|
||||
if (width > 0 && height > 0) {
|
||||
const matches = narrowed.filter((asset) => {
|
||||
const dimensions = asset.posterPath ? this.readImageDimensions(asset.posterPath) : undefined
|
||||
return dimensions?.width === width && dimensions.height === height
|
||||
})
|
||||
if (matches.length > 0) {
|
||||
narrowed = matches
|
||||
appliedCriteria += 1
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Number(options.duration)
|
||||
if (duration > 0) {
|
||||
const matches = narrowed.filter((asset) => {
|
||||
const actual = this.readMp4Duration(asset.filePath)
|
||||
return actual !== undefined && Math.abs(actual - duration) <= 1.5
|
||||
})
|
||||
if (matches.length > 0) {
|
||||
narrowed = matches
|
||||
appliedCriteria += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (appliedCriteria >= 2 && narrowed.length === 1) return narrowed[0]
|
||||
|
||||
const hashPool = narrowed.length > 0 ? narrowed : assets
|
||||
const contentMatches: VideoAsset[] = []
|
||||
for (const asset of hashPool) {
|
||||
const contentHash = await this.hashFile(asset.filePath)
|
||||
if (contentHash && hashes.includes(contentHash)) contentMatches.push(asset)
|
||||
}
|
||||
return contentMatches.length === 1 ? contentMatches[0] : undefined
|
||||
}
|
||||
|
||||
private monthForCreateTime(createTime?: number): string | undefined {
|
||||
const raw = Number(createTime)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return undefined
|
||||
const date = new Date(raw > 10_000_000_000 ? raw : raw * 1000)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
private hashFile(filePath: string): Promise<string | undefined> {
|
||||
const cached = this.fileHashes.get(filePath)
|
||||
if (cached) return cached
|
||||
const pending = new Promise<string | undefined>((resolve) => {
|
||||
const hash = crypto.createHash('md5')
|
||||
const stream = fs.createReadStream(filePath)
|
||||
stream.on('data', (chunk) => hash.update(chunk))
|
||||
stream.on('error', () => resolve(undefined))
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
})
|
||||
this.fileHashes.set(filePath, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
private readImageDimensions(filePath: string): ImageDimensions | undefined {
|
||||
if (this.imageDimensions.has(filePath)) return this.imageDimensions.get(filePath)
|
||||
let dimensions: ImageDimensions | undefined
|
||||
try {
|
||||
const data = fs.readFileSync(filePath)
|
||||
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
|
||||
let offset = 2
|
||||
const startOfFrame = new Set([
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf
|
||||
])
|
||||
while (offset + 8 < data.length) {
|
||||
if (data[offset] !== 0xff) {
|
||||
offset += 1
|
||||
continue
|
||||
}
|
||||
while (offset < data.length && data[offset] === 0xff) offset += 1
|
||||
const marker = data[offset]
|
||||
offset += 1
|
||||
if (marker === 0xd8 || marker === 0x01) continue
|
||||
if (marker === 0xd9 || marker === 0xda || offset + 2 > data.length) break
|
||||
const length = data.readUInt16BE(offset)
|
||||
if (length < 2 || offset + length > data.length) break
|
||||
if (startOfFrame.has(marker) && length >= 7) {
|
||||
dimensions = {
|
||||
height: data.readUInt16BE(offset + 3),
|
||||
width: data.readUInt16BE(offset + 5)
|
||||
}
|
||||
break
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
dimensions = undefined
|
||||
}
|
||||
this.imageDimensions.set(filePath, dimensions)
|
||||
return dimensions
|
||||
}
|
||||
|
||||
private readMp4Duration(filePath: string): number | undefined {
|
||||
if (this.videoDurations.has(filePath)) return this.videoDurations.get(filePath)
|
||||
let duration: number | undefined
|
||||
let descriptor: number | undefined
|
||||
try {
|
||||
descriptor = fs.openSync(filePath, 'r')
|
||||
const fileSize = fs.fstatSync(descriptor).size
|
||||
const moov = this.findMp4Box(descriptor, 0, fileSize, 'moov')
|
||||
const mvhd = moov
|
||||
? this.findMp4Box(descriptor, moov.contentOffset, moov.contentOffset + moov.size, 'mvhd')
|
||||
: undefined
|
||||
if (mvhd) {
|
||||
const header = Buffer.alloc(32)
|
||||
const bytesRead = fs.readSync(descriptor, header, 0, header.length, mvhd.contentOffset)
|
||||
const version = header[0]
|
||||
if (version === 0 && bytesRead >= 20) {
|
||||
const timescale = header.readUInt32BE(12)
|
||||
const ticks = header.readUInt32BE(16)
|
||||
if (timescale > 0) duration = ticks / timescale
|
||||
} else if (version === 1 && bytesRead >= 32) {
|
||||
const timescale = header.readUInt32BE(20)
|
||||
const ticks = Number(header.readBigUInt64BE(24))
|
||||
if (timescale > 0 && Number.isSafeInteger(ticks)) duration = ticks / timescale
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
duration = undefined
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor)
|
||||
}
|
||||
this.videoDurations.set(filePath, duration)
|
||||
return duration
|
||||
}
|
||||
|
||||
private findMp4Box(
|
||||
descriptor: number,
|
||||
start: number,
|
||||
end: number,
|
||||
target: string
|
||||
): Mp4Box | undefined {
|
||||
let offset = start
|
||||
const header = Buffer.alloc(16)
|
||||
while (offset + 8 <= end) {
|
||||
const bytesRead = fs.readSync(descriptor, header, 0, header.length, offset)
|
||||
if (bytesRead < 8) return undefined
|
||||
const size32 = header.readUInt32BE(0)
|
||||
const type = header.toString('ascii', 4, 8)
|
||||
let headerSize = 8
|
||||
let size = size32
|
||||
if (size32 === 1) {
|
||||
if (bytesRead < 16) return undefined
|
||||
const extendedSize = header.readBigUInt64BE(8)
|
||||
if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return undefined
|
||||
size = Number(extendedSize)
|
||||
headerSize = 16
|
||||
} else if (size32 === 0) {
|
||||
size = end - offset
|
||||
}
|
||||
if (size < headerSize || offset + size > end) return undefined
|
||||
if (type === target) {
|
||||
return { type, size: size - headerSize, contentOffset: offset + headerSize }
|
||||
}
|
||||
offset += size
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1286,11 +1286,13 @@ export class Wcdb4Client {
|
||||
const merged = new Map<string, Wcdb4Message>()
|
||||
for (const message of [...recovered, ...current]) {
|
||||
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
|
||||
const identity = message.mesLocalID
|
||||
? `local:${message.mesLocalID}`
|
||||
: message.serverId
|
||||
? `server:${message.serverId}`
|
||||
: `${message.msgCreateTime}:${message.msgContent}`
|
||||
const serverId = String(message.serverId || '').trim()
|
||||
const identity =
|
||||
serverId && serverId !== '0'
|
||||
? `server:${serverId}`
|
||||
: message.mesLocalID
|
||||
? `local:${message.mesLocalID}:${message.msgCreateTime || 0}`
|
||||
: `${message.msgCreateTime}:${message.msgContent}`
|
||||
const key = recoveredRow ? `recovered:${identity}` : identity
|
||||
merged.set(key, message)
|
||||
}
|
||||
|
||||
+48
-3
@@ -34,6 +34,24 @@ export interface GroupMemberInfo {
|
||||
m_nsHeadImgUrl: string
|
||||
}
|
||||
|
||||
function mergeExportMessages(messages: WechatMessage[]): WechatMessage[] {
|
||||
const merged = new Map<string, WechatMessage>()
|
||||
for (const message of messages) {
|
||||
const serverId = String(message.serverId || '')
|
||||
const recovered = Boolean(message.raw?.['_wxe_recovered'] || message['_wxe_recovered'])
|
||||
const identity =
|
||||
serverId && serverId !== '0'
|
||||
? `server:${serverId}`
|
||||
: `local:${message.mesLocalID}:${message.msgCreateTime}`
|
||||
merged.set(recovered ? `recovered:${identity}` : identity, message)
|
||||
}
|
||||
return Array.from(merged.values()).sort(
|
||||
(left, right) =>
|
||||
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
|
||||
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
|
||||
)
|
||||
}
|
||||
|
||||
export class WechatDb {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private chatMd5ToUsername = new Map<string, string>()
|
||||
@@ -170,8 +188,8 @@ export class WechatDb {
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||
...message,
|
||||
...message.raw
|
||||
...message.raw,
|
||||
...message
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -185,7 +203,34 @@ export class WechatDb {
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
|
||||
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||
return messages.map((message) => ({ ...message.raw, ...message }))
|
||||
}
|
||||
|
||||
public async getUserMessagesForExport(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<WechatMessage[]> {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
if (startTime && endTime && startTime > endTime) return []
|
||||
|
||||
// A bounded native cursor can omit rows stored across a message shard boundary.
|
||||
// Scan without bounds first, then apply the requested range in application code.
|
||||
const rows = await this.wcdb4Client.getMessagesAsync(username)
|
||||
const messages = rows
|
||||
.map((message) => ({ ...message.raw, ...message }))
|
||||
.filter((message) => {
|
||||
const createTime = Number(message.msgCreateTime || 0)
|
||||
if (startTime && createTime < startTime) return false
|
||||
if (endTime && createTime > endTime) return false
|
||||
return true
|
||||
})
|
||||
console.log(
|
||||
`[WechatDb] export scan username=${username} raw=${rows.length} filtered=${messages.length} start=${startTime || 0} end=${endTime || 0}`
|
||||
)
|
||||
return mergeExportMessages(messages)
|
||||
}
|
||||
|
||||
public searchAllMessages(keyword: string): string | null {
|
||||
|
||||
Vendored
+2
-1
@@ -234,7 +234,8 @@ declare global {
|
||||
filePath?: string
|
||||
}>
|
||||
getVideo: (
|
||||
hashes: string[]
|
||||
hashes: string[],
|
||||
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
||||
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||
getSticker: (
|
||||
cdnUrl?: string,
|
||||
|
||||
@@ -107,7 +107,10 @@ const api = {
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
||||
getVideo: (
|
||||
hashes: string[],
|
||||
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
||||
) => ipcRenderer.invoke('db:getVideo', hashes, options),
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
|
||||
|
||||
+37
-12
@@ -212,7 +212,32 @@ function App(): React.ReactElement {
|
||||
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
|
||||
return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : []
|
||||
if (!Array.isArray(stored)) return []
|
||||
return stored.slice(0, 20).map(
|
||||
(
|
||||
value: Partial<ExportTaskRecord> & {
|
||||
contactId?: string
|
||||
contactName?: string
|
||||
}
|
||||
) => {
|
||||
const targetIds = Array.isArray(value.targetIds)
|
||||
? value.targetIds
|
||||
: value.contactId
|
||||
? [value.contactId]
|
||||
: []
|
||||
const targetNames = Array.isArray(value.targetNames)
|
||||
? value.targetNames
|
||||
: value.contactName
|
||||
? [value.contactName]
|
||||
: []
|
||||
return {
|
||||
...value,
|
||||
targetIds,
|
||||
targetNames,
|
||||
targetLabel: value.targetLabel || targetNames.join('、') || '聊天导出'
|
||||
} as ExportTaskRecord
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -353,10 +378,14 @@ function App(): React.ReactElement {
|
||||
const handleStartExport = async (
|
||||
request: ExportRequest
|
||||
): Promise<import('../../shared/export').ExportResult> => {
|
||||
const targetNames = request.targets.map((target) => target.name)
|
||||
const targetLabel =
|
||||
targetNames.length > 1 ? `${targetNames[0]} 等 ${targetNames.length} 个聊天` : targetNames[0]
|
||||
const task: ExportTaskRecord = {
|
||||
jobId: request.jobId,
|
||||
contactId: request.userMd5,
|
||||
contactName: request.name,
|
||||
targetIds: request.targets.map((target) => target.userMd5),
|
||||
targetNames,
|
||||
targetLabel,
|
||||
format: request.format,
|
||||
status: 'running',
|
||||
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
|
||||
@@ -1232,16 +1261,14 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const loadExportPreview = async (contact: Contact): Promise<void> => {
|
||||
setSelectedContact(contact)
|
||||
selectedContactMd5Ref.current = contact.md5
|
||||
const loadExportPreviewMessages = async (contact: Contact): Promise<Message[]> => {
|
||||
try {
|
||||
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||
return await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||
limit: EXPORT_PREVIEW_LIMIT
|
||||
})
|
||||
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
|
||||
} catch (error) {
|
||||
console.warn('[Export] preview load failed:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1334,7 +1361,6 @@ function App(): React.ReactElement {
|
||||
const handlePageChange = (page: AppPage): void => {
|
||||
setActivePage(page)
|
||||
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
|
||||
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
|
||||
if (page === 'settings') setSettingsCategory('account-database')
|
||||
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
||||
setReportSourceContact(selectedContact)
|
||||
@@ -1714,11 +1740,10 @@ function App(): React.ReactElement {
|
||||
return (
|
||||
<ExportWorkspace
|
||||
contacts={contacts}
|
||||
selectedContact={selectedContact}
|
||||
previewMessages={messages}
|
||||
initialContact={selectedContact}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
onSelectContact={loadExportPreview}
|
||||
loadPreviewMessages={loadExportPreviewMessages}
|
||||
onOpenSettings={openSettings}
|
||||
exportTasks={exportTasks}
|
||||
onStartExport={handleStartExport}
|
||||
|
||||
@@ -4,14 +4,20 @@ interface VideoBubbleProps {
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
createTime?: number
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function VideoBubble({
|
||||
md5,
|
||||
newMd5,
|
||||
rawMd5,
|
||||
duration
|
||||
createTime,
|
||||
duration,
|
||||
width,
|
||||
height
|
||||
}: VideoBubbleProps): React.ReactElement {
|
||||
const hashes = useMemo(
|
||||
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
|
||||
@@ -22,7 +28,7 @@ export function VideoBubble({
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
window.api
|
||||
.getVideo(hashes)
|
||||
.getVideo(hashes, { createTime, duration, width, height })
|
||||
.then((result) => {
|
||||
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||
})
|
||||
@@ -32,7 +38,7 @@ export function VideoBubble({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hashes])
|
||||
}, [createTime, duration, hashes, height, width])
|
||||
|
||||
if (!media.url) {
|
||||
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||
|
||||
@@ -81,7 +81,10 @@ export function MessageBubble({
|
||||
md5={message.contentData.md5}
|
||||
newMd5={message.contentData.newMd5}
|
||||
rawMd5={message.contentData.rawMd5}
|
||||
createTime={message.createTime}
|
||||
duration={message.contentData.duration}
|
||||
width={message.contentData.width}
|
||||
height={message.contentData.height}
|
||||
/>
|
||||
) : isRichMedia && message.contentData ? (
|
||||
<RichMessageBubble
|
||||
|
||||
@@ -6,6 +6,9 @@ interface ExportContactPanelProps {
|
||||
contacts: Contact[]
|
||||
filteredContacts: Contact[]
|
||||
activeContact: Contact | null
|
||||
selectedContactIds: string[]
|
||||
selectionMode: boolean
|
||||
selectionLimit: number
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
contactFilter: string
|
||||
@@ -13,6 +16,7 @@ interface ExportContactPanelProps {
|
||||
onContactFilterChange: (value: string) => void
|
||||
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
||||
onSelectContact: (contact: Contact) => void
|
||||
onCompleteSelection: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
@@ -20,6 +24,9 @@ export function ExportContactPanel({
|
||||
contacts,
|
||||
filteredContacts,
|
||||
activeContact,
|
||||
selectedContactIds,
|
||||
selectionMode,
|
||||
selectionLimit,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
contactFilter,
|
||||
@@ -27,6 +34,7 @@ export function ExportContactPanel({
|
||||
onContactFilterChange,
|
||||
onContactTypeChange,
|
||||
onSelectContact,
|
||||
onCompleteSelection,
|
||||
onOpenSettings
|
||||
}: ExportContactPanelProps): React.ReactElement {
|
||||
return (
|
||||
@@ -65,15 +73,30 @@ export function ExportContactPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectionMode && (
|
||||
<div className="export-multi-select-bar">
|
||||
<span>
|
||||
已选 {selectedContactIds.length} / {selectionLimit} 个
|
||||
</span>
|
||||
<button type="button" onClick={onCompleteSelection}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="export-contact-list">
|
||||
{filteredContacts.map((contact) => {
|
||||
const name = displayName(contact)
|
||||
const selected = selectedContactIds.includes(contact.md5)
|
||||
const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit
|
||||
return (
|
||||
<button
|
||||
key={contact.md5}
|
||||
type="button"
|
||||
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
|
||||
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
disabled={atLimit}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className="export-contact-avatar">
|
||||
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||
@@ -82,6 +105,11 @@ export function ExportContactPanel({
|
||||
<strong>{name}</strong>
|
||||
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||
</span>
|
||||
{selectionMode && (
|
||||
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
|
||||
{selected ? '✓' : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ExportPreviewPanelProps {
|
||||
previewBytes: number
|
||||
selfInfo: SelfInfo | null
|
||||
progress: ExportJobProgress | null
|
||||
selectedCount: number
|
||||
jobId: string
|
||||
onCancel: (jobId: string) => void
|
||||
onReveal: (path: string) => void
|
||||
@@ -22,6 +23,7 @@ export function ExportPreviewPanel({
|
||||
previewBytes,
|
||||
selfInfo,
|
||||
progress,
|
||||
selectedCount,
|
||||
jobId,
|
||||
onCancel,
|
||||
onReveal
|
||||
@@ -32,7 +34,9 @@ export function ExportPreviewPanel({
|
||||
<>
|
||||
<div className="export-preview-heading">
|
||||
<strong>导出预览</strong>
|
||||
<span>仅预览最近 20 条</span>
|
||||
<span>
|
||||
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="export-message-preview">
|
||||
<div className="export-preview-date">最近消息</div>
|
||||
@@ -50,7 +54,7 @@ export function ExportPreviewPanel({
|
||||
]
|
||||
).map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
key={`${message.exportConversationId || 'single'}:${message.id}`}
|
||||
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
|
||||
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
|
||||
}`}
|
||||
@@ -64,6 +68,9 @@ export function ExportPreviewPanel({
|
||||
</span>
|
||||
<span className="export-preview-bubble">
|
||||
<small>
|
||||
{selectedCount > 1 && message.exportConversationName
|
||||
? `${message.exportConversationName} · `
|
||||
: ''}
|
||||
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
|
||||
{formatPreviewTime(message)}
|
||||
</small>
|
||||
@@ -108,7 +115,11 @@ export function ExportPreviewPanel({
|
||||
<ol>
|
||||
<li className="done">准备导出</li>
|
||||
<li className="current">
|
||||
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
|
||||
{progress?.phase === 'compressing'
|
||||
? '压缩 ZIP'
|
||||
: progress?.phase === 'writing'
|
||||
? '生成档案'
|
||||
: '分批读取聊天记录'}
|
||||
</li>
|
||||
<li>解析消息内容</li>
|
||||
<li>处理媒体资源</li>
|
||||
@@ -118,9 +129,11 @@ export function ExportPreviewPanel({
|
||||
<span style={{ width: `${progress?.percent ?? 0}%` }} />
|
||||
</div>
|
||||
<strong>
|
||||
{progress?.phase === 'writing'
|
||||
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||
{progress?.phase === 'compressing'
|
||||
? `正在压缩资源包... ${progress.percent ?? 0}%`
|
||||
: progress?.phase === 'writing'
|
||||
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||
</strong>
|
||||
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
|
||||
取消导出
|
||||
|
||||
@@ -9,6 +9,25 @@ interface ExportTaskCenterProps {
|
||||
onCancel: (jobId: string) => void
|
||||
}
|
||||
|
||||
const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
|
||||
reading: '读取消息',
|
||||
writing: '导出资源',
|
||||
compressing: '压缩归档',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
failed: '导出失败'
|
||||
}
|
||||
|
||||
const taskDetail = (task: ExportTaskRecord): string | null => {
|
||||
if (task.status === 'completed') {
|
||||
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
|
||||
}
|
||||
if (task.status === 'failed') {
|
||||
return `失败原因:${task.progress.error || '未知错误'}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function ExportTaskCenter({
|
||||
open,
|
||||
taskCount,
|
||||
@@ -22,7 +41,7 @@ export function ExportTaskCenter({
|
||||
const log = [
|
||||
'WechatExplorer 导出任务日志',
|
||||
`时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
|
||||
`会话:${task.contactName}`,
|
||||
`会话:${task.targetLabel}`,
|
||||
`格式:${task.format.toUpperCase()}`,
|
||||
`状态:${task.progress.phase}`,
|
||||
`进度:${task.progress.percent ?? 0}%`,
|
||||
@@ -46,35 +65,41 @@ export function ExportTaskCenter({
|
||||
{tasks.length === 0 ? (
|
||||
<p>暂无导出记录</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<div className="export-task-row" key={task.jobId}>
|
||||
<span>
|
||||
<strong>{task.contactName}</strong>
|
||||
<small>
|
||||
{task.format.toUpperCase()} · {task.progress.phase}
|
||||
</small>
|
||||
{task.progress.error && (
|
||||
<small className="export-task-error" title={task.progress.error}>
|
||||
{task.progress.error}
|
||||
tasks.map((task) => {
|
||||
const detail = taskDetail(task)
|
||||
return (
|
||||
<div className="export-task-row" key={task.jobId}>
|
||||
<span>
|
||||
<strong>{task.targetLabel}</strong>
|
||||
<small>
|
||||
{task.format.toUpperCase()} · {phaseLabels[task.progress.phase]}
|
||||
</small>
|
||||
{detail && (
|
||||
<small
|
||||
className={`export-task-detail ${task.status}`}
|
||||
title={task.status === 'failed' ? detail : undefined}
|
||||
>
|
||||
{detail}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
<span className="export-task-progress">
|
||||
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
|
||||
<b>{task.progress.percent ?? 0}%</b>
|
||||
</span>
|
||||
{task.status === 'running' && (
|
||||
<button type="button" onClick={() => onCancel(task.jobId)}>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<span className="export-task-progress">
|
||||
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
|
||||
<b>{task.progress.percent ?? 0}%</b>
|
||||
</span>
|
||||
{task.status === 'running' && (
|
||||
<button type="button" onClick={() => onCancel(task.jobId)}>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
{task.status === 'failed' && (
|
||||
<button type="button" onClick={() => void copyTaskLog(task)}>
|
||||
{copiedJobId === task.jobId ? '已复制' : '复制日志'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
{task.status === 'failed' && (
|
||||
<button type="button" onClick={() => void copyTaskLog(task)}>
|
||||
{copiedJobId === task.jobId ? '已复制' : '复制日志'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,15 @@ import type { Message } from '../../../../shared/types'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportNameMode
|
||||
ExportNameMode,
|
||||
ExportRequest,
|
||||
ExportTarget
|
||||
} from '../../../../shared/export'
|
||||
import { ExportContactPanel } from './ExportContactPanel'
|
||||
import { ExportPreviewPanel } from './ExportPreviewPanel'
|
||||
import { ExportTaskCenter } from './ExportTaskCenter'
|
||||
import type {
|
||||
Contact,
|
||||
ExportFormat,
|
||||
ExportRange,
|
||||
ExportStatus,
|
||||
@@ -20,24 +23,33 @@ import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
|
||||
|
||||
export function ExportWorkspace({
|
||||
contacts,
|
||||
selectedContact,
|
||||
previewMessages,
|
||||
initialContact,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onSelectContact,
|
||||
loadPreviewMessages,
|
||||
onOpenSettings,
|
||||
exportTasks,
|
||||
onStartExport,
|
||||
onCancelExport
|
||||
}: ExportWorkspaceProps): React.ReactElement {
|
||||
const initialSelection = initialContact || contacts[0] || null
|
||||
const initialContactRef = React.useRef<Contact | null>(initialSelection)
|
||||
const previewLoadingRef = React.useRef(new Set<string>())
|
||||
const [contactFilter, setContactFilter] = useState('')
|
||||
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [selectedContacts, setSelectedContacts] = useState<Contact[]>(() =>
|
||||
initialSelection ? [initialSelection] : []
|
||||
)
|
||||
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
|
||||
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
|
||||
const [range, setRange] = useState<ExportRange>('today')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
||||
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
|
||||
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
|
||||
const [nameMode, setNameMode] = useState<ExportNameMode>(
|
||||
initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
|
||||
)
|
||||
const [includeMedia, setIncludeMedia] = useState(true)
|
||||
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
|
||||
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
|
||||
@@ -52,6 +64,27 @@ export function ExportWorkspace({
|
||||
const [jobId, setJobId] = useState('')
|
||||
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
||||
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
|
||||
const selectionLimit = 5
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedContacts.length > 0) return
|
||||
const candidate = initialContact || contacts[0]
|
||||
if (!candidate) return
|
||||
initialContactRef.current = candidate
|
||||
setSelectedContacts([candidate])
|
||||
setActiveContactId(candidate.md5)
|
||||
}, [contacts, initialContact, selectedContacts.length])
|
||||
|
||||
React.useEffect(() => {
|
||||
for (const contact of selectedContacts) {
|
||||
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
|
||||
previewLoadingRef.current.add(contact.md5)
|
||||
void loadPreviewMessages(contact).then((items) => {
|
||||
previewLoadingRef.current.delete(contact.md5)
|
||||
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
|
||||
})
|
||||
}
|
||||
}, [loadPreviewMessages, previewByContact, selectedContacts])
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
const keyword = contactFilter.trim().toLowerCase()
|
||||
@@ -64,11 +97,35 @@ export function ExportWorkspace({
|
||||
})
|
||||
}, [contactFilter, contactType, contacts])
|
||||
|
||||
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
|
||||
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
|
||||
const activeContact =
|
||||
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
|
||||
selectedContacts[0] ||
|
||||
null
|
||||
const selectedTargetKey = selectedContacts
|
||||
.map((contact) => contact.md5)
|
||||
.sort()
|
||||
.join('|')
|
||||
const currentTask = exportTasks.find(
|
||||
(task) => [...task.targetIds].sort().join('|') === selectedTargetKey
|
||||
)
|
||||
const taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||
const activeName = displayName(activeContact)
|
||||
const preview = previewMessages.slice(-20)
|
||||
const selectedNames = selectedContacts.map(displayName)
|
||||
const selectedLabel =
|
||||
selectedNames.length > 1
|
||||
? `${selectedNames.join('、')} · 共 ${selectedNames.length} 个聊天`
|
||||
: selectedNames[0] || '未选择聊天'
|
||||
const preview = selectedContacts
|
||||
.flatMap((contact) =>
|
||||
(previewByContact[contact.md5] || []).map((message) => ({
|
||||
...message,
|
||||
exportConversationId: contact.md5,
|
||||
exportConversationName: displayName(contact),
|
||||
exportConversationAvatarUrl: contact.avatar
|
||||
}))
|
||||
)
|
||||
.sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0))
|
||||
.slice(-20)
|
||||
const previewMediaCount = preview.filter(
|
||||
(message) =>
|
||||
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
|
||||
@@ -78,79 +135,59 @@ export function ExportWorkspace({
|
||||
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||
0
|
||||
)
|
||||
const outputName = fileName.trim() || `${activeName}_聊天档案`
|
||||
const nameOptions: { value: ExportNameMode; label: string }[] =
|
||||
activeContact?.type === 'group'
|
||||
? [
|
||||
{ value: 'groupNickname', label: '群昵称' },
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
: [
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
|
||||
const nameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
if (activeContact?.type === 'group') {
|
||||
for (const member of groupMembers) {
|
||||
const value =
|
||||
nameMode === 'groupNickname'
|
||||
? member.groupNickname || member.nickname || member.wxid
|
||||
: nameMode === 'remark'
|
||||
? member.remark || member.wechatNickname || member.wxid
|
||||
: member.wechatNickname || member.wxid
|
||||
map[member.wxid] = value
|
||||
}
|
||||
} else if (activeContact) {
|
||||
map[activeContact.m_nsUsrName] =
|
||||
nameMode === 'remark'
|
||||
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
|
||||
: activeContact.wechatNickname || activeContact.m_nsUsrName
|
||||
}
|
||||
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||
return map
|
||||
}, [activeContact, groupMembers, nameMode, selfInfo])
|
||||
|
||||
const avatarUrls = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
if (activeContact?.m_nsUsrName && activeContact.avatar) {
|
||||
map[activeContact.m_nsUsrName] = activeContact.avatar
|
||||
}
|
||||
for (const member of groupMembers) {
|
||||
if (member.avatar) map[member.wxid] = member.avatar
|
||||
}
|
||||
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
|
||||
return map
|
||||
}, [activeContact, groupMembers, selfInfo])
|
||||
const defaultOutputName =
|
||||
selectedContacts.length > 1
|
||||
? `${selectedNames[0]}等${selectedContacts.length}个聊天_合并档案`
|
||||
: `${activeName}_聊天档案`
|
||||
const outputName = fileName.trim() || defaultOutputName
|
||||
const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some(
|
||||
(contact) => contact.type === 'group'
|
||||
)
|
||||
? [
|
||||
{ value: 'groupNickname', label: '群昵称' },
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
: [
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
|
||||
const previewName = (message: Message): string =>
|
||||
(message.senderId && nameMap[message.senderId]) ||
|
||||
message.name ||
|
||||
(message.isSender ? selfInfo?.nickname : undefined) ||
|
||||
(message.isSender ? '我' : '联系人')
|
||||
const previewAvatar = (message: Message): string | undefined =>
|
||||
(message.senderId && avatarUrls[message.senderId]) ||
|
||||
message.img ||
|
||||
(message.isSender ? selfInfo?.avatar : undefined)
|
||||
message.img || (message.isSender ? selfInfo?.avatar : undefined)
|
||||
const previewItems = preview.map((message) => ({
|
||||
...message,
|
||||
name: previewName(message),
|
||||
img: previewAvatar(message)
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||
setGroupMembers([])
|
||||
if (!activeContact || activeContact.type !== 'group') return
|
||||
const timer = window.setTimeout(() => {
|
||||
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
|
||||
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
|
||||
})
|
||||
}, 300)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [activeContact])
|
||||
const handleSelectContact = (contact: Contact): void => {
|
||||
if (!selectionMode) {
|
||||
setSelectedContacts([contact])
|
||||
setActiveContactId(contact.md5)
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
const selected = selectedContacts.some((item) => item.md5 === contact.md5)
|
||||
if (selected) {
|
||||
if (selectedContacts.length === 1) return
|
||||
const next = selectedContacts.filter((item) => item.md5 !== contact.md5)
|
||||
setSelectedContacts(next)
|
||||
if (activeContactId === contact.md5) setActiveContactId(next[0].md5)
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
if (selectedContacts.length >= selectionLimit) return
|
||||
const next = [...selectedContacts, contact]
|
||||
setSelectedContacts(next)
|
||||
setActiveContactId(contact.md5)
|
||||
setFormat('html')
|
||||
setStatus('idle')
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
@@ -173,42 +210,59 @@ export function ExportWorkspace({
|
||||
}
|
||||
|
||||
const handleStart = async (): Promise<void> => {
|
||||
if (!activeContact || status === 'running') return
|
||||
if (!activeContact || selectedContacts.length === 0 || status === 'running') return
|
||||
// Runs only from the export button event; a fresh id is required for each job.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const nextJobId = `export-${Date.now()}`
|
||||
setJobId(nextJobId)
|
||||
setProgress(null)
|
||||
setStatus('running')
|
||||
let exportNameMap = nameMap
|
||||
let exportAvatarUrls = avatarUrls
|
||||
if (activeContact.type === 'group') {
|
||||
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
|
||||
const members = (snapshot?.members || []) as GroupMemberName[]
|
||||
setGroupMembers(members)
|
||||
exportNameMap = { ...nameMap }
|
||||
exportAvatarUrls = { ...avatarUrls }
|
||||
for (const member of members) {
|
||||
exportNameMap[member.wxid] =
|
||||
nameMode === 'groupNickname'
|
||||
? member.groupNickname || member.nickname || member.wxid
|
||||
: nameMode === 'remark'
|
||||
? member.remark || member.wechatNickname || member.wxid
|
||||
: member.wechatNickname || member.wxid
|
||||
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
|
||||
}
|
||||
}
|
||||
const targets: ExportTarget[] = await Promise.all(
|
||||
selectedContacts.map(async (contact) => {
|
||||
const nameMap: Record<string, string> = {}
|
||||
const avatarUrls: Record<string, string> = {}
|
||||
if (contact.type === 'group') {
|
||||
const snapshot = await window.api.getGroupSnapshot(contact.md5)
|
||||
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
|
||||
nameMap[member.wxid] =
|
||||
nameMode === 'groupNickname'
|
||||
? member.groupNickname || member.nickname || member.wxid
|
||||
: nameMode === 'remark'
|
||||
? member.remark || member.wechatNickname || member.wxid
|
||||
: member.wechatNickname || member.wxid
|
||||
if (member.avatar) avatarUrls[member.wxid] = member.avatar
|
||||
}
|
||||
} else {
|
||||
nameMap[contact.m_nsUsrName] =
|
||||
nameMode === 'remark'
|
||||
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
|
||||
: contact.wechatNickname || contact.m_nsUsrName
|
||||
if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar
|
||||
}
|
||||
if (selfInfo?.wxid) {
|
||||
nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
|
||||
}
|
||||
return {
|
||||
userMd5: contact.md5,
|
||||
name: displayName(contact),
|
||||
type: contact.type,
|
||||
avatarUrl: contact.avatar,
|
||||
nameMode,
|
||||
nameMap,
|
||||
avatarUrls
|
||||
}
|
||||
})
|
||||
)
|
||||
const now = new Date()
|
||||
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
|
||||
const startOfRange = days
|
||||
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
|
||||
: null
|
||||
const request = {
|
||||
const request: ExportRequest = {
|
||||
jobId: nextJobId,
|
||||
userMd5: activeContact.md5,
|
||||
name: activeName,
|
||||
format,
|
||||
targets,
|
||||
format: selectedContacts.length > 1 ? 'html' : format,
|
||||
outputName,
|
||||
startTime: startOfRange
|
||||
? Math.floor(startOfRange.getTime() / 1000)
|
||||
@@ -232,9 +286,6 @@ export function ExportWorkspace({
|
||||
fallbackThumbnail,
|
||||
keepMissing,
|
||||
includeAvatars,
|
||||
avatarUrls: exportAvatarUrls,
|
||||
nameMode,
|
||||
nameMap: exportNameMap,
|
||||
zip
|
||||
}
|
||||
const result = await onStartExport(request)
|
||||
@@ -277,6 +328,29 @@ export function ExportWorkspace({
|
||||
)
|
||||
}, [currentTask])
|
||||
|
||||
const resetDefaults = (): void => {
|
||||
const contact = initialContactRef.current || contacts[0] || null
|
||||
setSelectedContacts(contact ? [contact] : [])
|
||||
setActiveContactId(contact?.md5 || '')
|
||||
setSelectionMode(false)
|
||||
setRange('today')
|
||||
setStartDate('')
|
||||
setEndDate('')
|
||||
setSelectedKinds(new Set(['text']))
|
||||
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||
setIncludeMedia(true)
|
||||
setIncludeAvatars(true)
|
||||
setPreferOriginal(true)
|
||||
setFallbackThumbnail(true)
|
||||
setKeepMissing(true)
|
||||
setFormat('csv')
|
||||
setZip(false)
|
||||
setFileName('')
|
||||
setStatus('idle')
|
||||
setJobId('')
|
||||
setProgress(null)
|
||||
}
|
||||
|
||||
const targetPath =
|
||||
format === 'html'
|
||||
? zip
|
||||
@@ -290,13 +364,17 @@ export function ExportWorkspace({
|
||||
contacts={contacts}
|
||||
filteredContacts={filteredContacts}
|
||||
activeContact={activeContact}
|
||||
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
|
||||
selectionMode={selectionMode}
|
||||
selectionLimit={selectionLimit}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
contactFilter={contactFilter}
|
||||
contactType={contactType}
|
||||
onContactFilterChange={setContactFilter}
|
||||
onContactTypeChange={setContactType}
|
||||
onSelectContact={onSelectContact}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCompleteSelection={() => setSelectionMode(false)}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
|
||||
@@ -310,20 +388,28 @@ export function ExportWorkspace({
|
||||
onCancel={(taskJobId) => void onCancelExport(taskJobId)}
|
||||
/>
|
||||
<header className="export-config-header">
|
||||
<span className="export-chat-avatar">
|
||||
{activeContact?.avatar ? (
|
||||
<img src={activeContact.avatar} alt="" />
|
||||
) : (
|
||||
activeName.slice(0, 1)
|
||||
)}
|
||||
<span className="export-chat-avatar-stack" aria-hidden>
|
||||
{selectedContacts.slice(0, 3).map((contact) => (
|
||||
<span className="export-chat-avatar" key={contact.md5}>
|
||||
{contact.avatar ? (
|
||||
<img src={contact.avatar} alt="" />
|
||||
) : (
|
||||
displayName(contact).slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
<span className="export-config-title">
|
||||
<h1>导出设置</h1>
|
||||
<p>
|
||||
{activeName}
|
||||
{activeContact?.type === 'group' ? ' · 群聊' : ''}
|
||||
</p>
|
||||
<p>{selectedLabel}</p>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="export-add-chat-button"
|
||||
onClick={() => setSelectionMode((current) => !current)}
|
||||
>
|
||||
{selectionMode ? '完成选择' : '+ 添加聊天'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="export-section export-format-top">
|
||||
@@ -334,6 +420,7 @@ export function ExportWorkspace({
|
||||
key={value}
|
||||
type="button"
|
||||
className={format === value ? 'active' : ''}
|
||||
disabled={selectedContacts.length > 1 && value !== 'html'}
|
||||
onClick={() => setFormat(value)}
|
||||
>
|
||||
<strong>{formatLabels[value].label}</strong>
|
||||
@@ -342,7 +429,9 @@ export function ExportWorkspace({
|
||||
))}
|
||||
</div>
|
||||
<p className="export-helper-text">
|
||||
CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。
|
||||
{selectedContacts.length > 1
|
||||
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
|
||||
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
|
||||
</p>
|
||||
{format === 'html' && (
|
||||
<>
|
||||
@@ -544,45 +633,6 @@ export function ExportWorkspace({
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="export-format-grid">
|
||||
{formatOrder.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={format === value ? 'active' : ''}
|
||||
onClick={() => setFormat(value)}
|
||||
>
|
||||
<strong>{formatLabels[value].label}</strong>
|
||||
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{format === 'html' && (
|
||||
<div className="export-html-options">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package"
|
||||
checked={!zip}
|
||||
onChange={() => setZip(false)}
|
||||
/>{' '}
|
||||
HTML 资源包(推荐)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package"
|
||||
checked={zip}
|
||||
onChange={() => setZip(true)}
|
||||
/>{' '}
|
||||
HTML 资源包并压缩为 ZIP
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="export-section export-save-section">
|
||||
<h3>保存设置</h3>
|
||||
<label>
|
||||
@@ -590,7 +640,7 @@ export function ExportWorkspace({
|
||||
<input
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
placeholder={`${activeName}_聊天档案`}
|
||||
placeholder={defaultOutputName}
|
||||
/>
|
||||
</label>
|
||||
<div className="export-target-path">
|
||||
@@ -615,7 +665,7 @@ export function ExportWorkspace({
|
||||
: '准备就绪'}
|
||||
</span>
|
||||
<span className="export-target-summary">路径:{targetPath}</span>
|
||||
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
|
||||
<button type="button" className="export-reset-button" onClick={resetDefaults}>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
@@ -636,6 +686,7 @@ export function ExportWorkspace({
|
||||
previewBytes={previewBytes}
|
||||
selfInfo={selfInfo}
|
||||
progress={progress}
|
||||
selectedCount={selectedContacts.length}
|
||||
jobId={jobId}
|
||||
onCancel={(exportJobId) => {
|
||||
void window.api.cancelExport(exportJobId)
|
||||
|
||||
@@ -30,15 +30,21 @@ export interface SelfInfo {
|
||||
|
||||
export interface ExportWorkspaceProps {
|
||||
contacts: Contact[]
|
||||
selectedContact: Contact | null
|
||||
previewMessages: Message[]
|
||||
initialContact: Contact | null
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
onSelectContact: (contact: Contact) => void
|
||||
loadPreviewMessages: (contact: Contact) => Promise<Message[]>
|
||||
onOpenSettings: () => void
|
||||
exportTasks: ExportTaskRecord[]
|
||||
onStartExport: (request: ExportRequest) => Promise<ExportResult>
|
||||
onCancelExport: (jobId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord }
|
||||
export type {
|
||||
Contact,
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
Message,
|
||||
ExportNameMode,
|
||||
ExportTaskRecord
|
||||
}
|
||||
|
||||
@@ -116,6 +116,25 @@
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.export-multi-select-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 16px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
font-size: 12px;
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: 600 12px/18px var(--wxex-font);
|
||||
}
|
||||
}
|
||||
|
||||
.export-contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -137,6 +156,28 @@
|
||||
border-left-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
}
|
||||
|
||||
.export-contact-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--wxex-border-strong);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
|
||||
&.checked {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.export-contact-avatar,
|
||||
@@ -246,6 +287,46 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.export-config-title {
|
||||
min-width: 0;
|
||||
}
|
||||
.export-config-title p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.export-chat-avatar-stack {
|
||||
position: relative;
|
||||
width: 70px;
|
||||
height: 58px;
|
||||
flex: 0 0 70px;
|
||||
|
||||
.export-chat-avatar {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
border: 2px solid var(--wxex-bg-main);
|
||||
}
|
||||
|
||||
.export-chat-avatar:nth-child(2) {
|
||||
left: 12px;
|
||||
top: 8px;
|
||||
}
|
||||
.export-chat-avatar:nth-child(3) {
|
||||
left: 24px;
|
||||
top: 13px;
|
||||
}
|
||||
}
|
||||
.export-add-chat-button {
|
||||
margin-left: auto;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--wxex-brand);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font: 600 12px/18px var(--wxex-font);
|
||||
}
|
||||
.export-chat-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
@@ -259,6 +340,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-format-grid button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.export-section {
|
||||
margin-bottom: 25px;
|
||||
|
||||
@@ -768,6 +854,21 @@
|
||||
color: var(--wxex-danger, #b42318);
|
||||
}
|
||||
|
||||
.export-task-detail {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&.completed {
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
&.failed {
|
||||
color: var(--wxex-danger, #c43d3d);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 5px;
|
||||
|
||||
+15
-8
@@ -14,10 +14,19 @@ export type ExportMessageKind =
|
||||
|
||||
export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname'
|
||||
|
||||
export interface ExportRequest {
|
||||
jobId: string
|
||||
export interface ExportTarget {
|
||||
userMd5: string
|
||||
name: string
|
||||
type: 'user' | 'group'
|
||||
avatarUrl?: string
|
||||
nameMode?: ExportNameMode
|
||||
nameMap?: Record<string, string>
|
||||
avatarUrls?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
jobId: string
|
||||
targets: ExportTarget[]
|
||||
format: ExportFormat
|
||||
outputName: string
|
||||
startTime?: number
|
||||
@@ -29,15 +38,12 @@ export interface ExportRequest {
|
||||
fallbackThumbnail?: boolean
|
||||
keepMissing?: boolean
|
||||
includeAvatars?: boolean
|
||||
avatarUrls?: Record<string, string>
|
||||
nameMode?: ExportNameMode
|
||||
nameMap?: Record<string, string>
|
||||
zip?: boolean
|
||||
}
|
||||
|
||||
export interface ExportJobProgress {
|
||||
jobId: string
|
||||
phase: 'reading' | 'writing' | 'completed' | 'cancelled' | 'failed'
|
||||
phase: 'reading' | 'writing' | 'compressing' | 'completed' | 'cancelled' | 'failed'
|
||||
processed: number
|
||||
total?: number
|
||||
percent?: number
|
||||
@@ -47,8 +53,9 @@ export interface ExportJobProgress {
|
||||
|
||||
export interface ExportTaskRecord {
|
||||
jobId: string
|
||||
contactId: string
|
||||
contactName: string
|
||||
targetIds: string[]
|
||||
targetNames: string[]
|
||||
targetLabel: string
|
||||
format: ExportFormat
|
||||
status: 'running' | 'completed' | 'cancelled' | 'failed'
|
||||
progress: ExportJobProgress
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface Message {
|
||||
exportShowAvatar?: boolean
|
||||
exportMediaError?: string
|
||||
exportAvatarUrl?: string
|
||||
exportConversationId?: string
|
||||
exportConversationName?: string
|
||||
exportConversationAvatarUrl?: string
|
||||
}
|
||||
|
||||
type TextContent = { type: 'text'; content: string }
|
||||
|
||||
Reference in New Issue
Block a user