diff --git a/README.md b/README.md index 586b166..7600b8f 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,19 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 - HTTP API 接口(供开发者集成) - 查看完整能力清单:[详细功能](#详细功能清单) +## 支持平台与设备 + + +| 平台 | 设备/架构 | 安装包 | +|------|----------|--------| +| Windows | Windows10+、x64(amd64) | `.exe` | +| macOS | Apple Silicon(M 系列,arm64) | `.dmg` | +| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` | + + ## 快速开始 -若你只想使用成品版本,可前往 Release 下载并安装。 +若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。 ## 详细功能清单 diff --git a/electron/services/config.ts b/electron/services/config.ts index 4b8324d..bb6b9f5 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -688,8 +688,16 @@ export class ConfigService { } } + private getUserDataPath(): string { + const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() + if (workerUserDataPath) { + return workerUserDataPath + } + return app?.getPath?.('userData') || process.cwd() + } + getCacheBasePath(): string { - return join(app.getPath('userData'), 'cache') + return join(this.getUserDataPath(), 'cache') } getAll(): Partial { diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index 993e478..c6751fc 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -186,6 +186,33 @@ body { word-break: break-word; } +.quoted-message { + border-left: 3px solid rgba(79, 70, 229, 0.35); + background: rgba(79, 70, 229, 0.06); + border-radius: 12px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message.sent .quoted-message { + background: rgba(37, 99, 235, 0.08); + border-left-color: rgba(37, 99, 235, 0.35); +} + +.quoted-sender { + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.quoted-text { + font-size: 13px; + color: #4b5563; + word-break: break-word; +} + .message-link-card { color: #2563eb; text-decoration: underline; diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts index 935eb49..96f4288 100644 --- a/electron/services/exportHtmlStyles.ts +++ b/electron/services/exportHtmlStyles.ts @@ -186,6 +186,33 @@ body { word-break: break-word; } +.quoted-message { + border-left: 3px solid rgba(79, 70, 229, 0.35); + background: rgba(79, 70, 229, 0.06); + border-radius: 12px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message.sent .quoted-message { + background: rgba(37, 99, 235, 0.08); + border-left-color: rgba(37, 99, 235, 0.35); +} + +.quoted-sender { + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.quoted-text { + font-size: 13px; + color: #4b5563; + word-break: break-word; +} + .message-link-card { color: #2563eb; text-decoration: underline; diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 6929f59..8aa5d9d 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -46,6 +46,8 @@ interface ChatLabMessage { timestamp: number type: number content: string | null + platformMessageId?: string + replyToMessageId?: string chatRecords?: any[] // 嵌套的聊天记录 } @@ -952,6 +954,18 @@ class ExportService { return fallback } + private getRowField(row: Record, keys: string[]): any { + for (const key of keys) { + if (row && Object.prototype.hasOwnProperty.call(row, key)) { + const value = row[key] + if (value !== undefined && value !== null && value !== '') { + return value + } + } + } + return undefined + } + private normalizeUnsignedIntToken(value: unknown): string { const raw = String(value ?? '').trim() if (!raw) return '0' @@ -963,14 +977,14 @@ class ExportService { return String(Math.floor(num)) } - private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown }): string { + private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { const localId = this.normalizeUnsignedIntToken(msg?.localId) const createTime = this.normalizeUnsignedIntToken(msg?.createTime) - const serverId = this.normalizeUnsignedIntToken(msg?.serverId) + const serverId = this.normalizeUnsignedIntToken(msg?.serverIdRaw ?? msg?.serverId) return `${localId}:${createTime}:${serverId}` } - private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown }): string { + private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { const localType = this.normalizeUnsignedIntToken(msg?.localType) return `${localType}_${this.getStableMessageKey(msg)}` } @@ -1620,7 +1634,13 @@ class ExportService { if (type === '6') return title ? `[文件] ${title}` : '[文件]' if (type === '19') return this.formatForwardChatRecordContent(normalizedContent) if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (type === '57') return title || '[引用消息]' + if (type === '57') { + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } + return title || '[引用消息]' + } if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' return title ? `[链接] ${title}` : '[链接]' } @@ -1629,6 +1649,10 @@ class ExportService { case 266287972401: return this.cleanSystemMessage(content) // 拍一拍 case 244813135921: { // 引用消息 + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } const title = this.extractXmlValue(content, 'title') return title || '[引用消息]' } @@ -1662,7 +1686,13 @@ class ExportService { if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent) if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (xmlType === '57') return title || '[引用消息]' + if (xmlType === '57') { + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } + return title || '[引用消息]' + } if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title @@ -1787,6 +1817,10 @@ class ExportService { return `[小程序]${appName}` } if (subType === 57) { + const quoteDisplay = this.extractQuotedReplyDisplay(safeContent) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } return title || '[引用消息]' } if (title) { @@ -1798,6 +1832,161 @@ class ExportService { return '[其他消息]' } + private formatQuotedReferencePreview(content: string, type?: string): string { + const safeContent = content || '' + const referType = Number.parseInt(String(type || ''), 10) + if (!Number.isFinite(referType)) { + const sanitized = this.sanitizeQuotedContent(safeContent) + return sanitized || '[消息]' + } + + if (referType === 49) { + const normalized = this.normalizeAppMessageContent(safeContent) + const title = + this.extractXmlValue(normalized, 'title') || + this.extractXmlValue(normalized, 'filename') || + this.extractXmlValue(normalized, 'appname') + if (title) return this.stripSenderPrefix(title) + + const subTypeRaw = this.extractAppMessageType(normalized) + const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 + if (subType === 6) return '[文件]' + if (subType === 19) return '[聊天记录]' + if (subType === 33 || subType === 36) return '[小程序]' + return '[链接]' + } + + return this.formatPlainExportContent(safeContent, referType, { exportVoiceAsText: false }) || '[消息]' + } + + private resolveQuotedSenderUsername(fromusr?: string, chatusr?: string): string { + const normalizedChatUsr = String(chatusr || '').trim() + const normalizedFromUsr = String(fromusr || '').trim() + + if (normalizedChatUsr) { + return normalizedChatUsr + } + + if (normalizedFromUsr.endsWith('@chatroom')) { + return '' + } + + return normalizedFromUsr + } + + private buildQuotedReplyText(display: { + replyText: string + quotedSender?: string + quotedPreview: string + }): string { + const quoteLabel = display.quotedSender + ? `${display.quotedSender}:${display.quotedPreview}` + : display.quotedPreview + if (display.replyText) { + return `${display.replyText}[引用 ${quoteLabel}]` + } + return `[引用 ${quoteLabel}]` + } + + private extractQuotedReplyDisplay(content: string): { + replyText: string + quotedSender?: string + quotedPreview: string + } | null { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return null + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const quoteInfo = this.parseQuoteMessage(normalized) + const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') + const quotedPreview = this.formatQuotedReferencePreview( + this.extractXmlValue(referMsgXml, 'content'), + this.extractXmlValue(referMsgXml, 'type') + ) + + if (!replyText && !quotedPreview) { + return null + } + + return { + replyText, + quotedSender: quoteInfo.sender || undefined, + quotedPreview: quotedPreview || '[消息]' + } + } catch { + return null + } + } + + private isQuotedReplyMessage(localType: number, content: string): boolean { + if (localType === 244813135921) return true + const normalized = this.normalizeAppMessageContent(content || '') + if (!(localType === 49 || normalized.includes(''))) { + return false + } + const subType = this.extractAppMessageType(normalized) + return subType === '57' || normalized.includes('') + } + + private async resolveQuotedReplyDisplayWithNames(args: { + content: string + isGroup: boolean + displayNamePreference: ExportOptions['displayNamePreference'] + getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> + groupNicknamesMap: Map + cleanedMyWxid: string + rawMyWxid?: string + myDisplayName?: string + }): Promise<{ + replyText: string + quotedSender?: string + quotedPreview: string + } | null> { + const base = this.extractQuotedReplyDisplay(args.content) + if (!base) return null + if (base.quotedSender) return base + + const normalized = this.normalizeAppMessageContent(args.content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return base + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const quotedSenderUsername = this.resolveQuotedSenderUsername( + this.extractXmlValue(referMsgXml, 'fromusr'), + this.extractXmlValue(referMsgXml, 'chatusr') + ) + if (!quotedSenderUsername) { + return base + } + + const isQuotedSelf = this.isSameWxid(quotedSenderUsername, args.cleanedMyWxid) + const fallbackDisplayName = isQuotedSelf + ? (args.myDisplayName || quotedSenderUsername) + : quotedSenderUsername + + const profile = await this.resolveExportDisplayProfile( + quotedSenderUsername, + args.displayNamePreference, + args.getContact, + args.groupNicknamesMap, + fallbackDisplayName, + isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : [] + ) + + return { + ...base, + quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender + } + } + private parseDurationSeconds(value: string): number | null { const numeric = Number(value) if (!Number.isFinite(numeric) || numeric <= 0) return null @@ -2462,6 +2651,32 @@ class ExportService { } } + private extractChatLabReplyToMessageId(content: string): string | undefined { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return undefined + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const replyToMessageIdRaw = this.normalizeUnsignedIntToken(this.extractXmlValue(referMsgXml, 'svrid')) + return replyToMessageIdRaw !== '0' ? replyToMessageIdRaw : undefined + } catch { + return undefined + } + } + + private getExportPlatformMessageId(msg: { serverIdRaw?: unknown; serverId?: unknown }): string | undefined { + const value = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) + return value !== '0' ? value : undefined + } + + private getExportReplyToMessageId(content: string): string | undefined { + return this.extractChatLabReplyToMessageId(content) + } + private extractArkmeAppMessageMeta(content: string, localType: number): Record | null { if (!content) return null @@ -3507,6 +3722,13 @@ class ExportService { 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id' ], 0) + const rawServerIdValue = this.getRowField(row, [ + 'server_id', 'serverId', 'ServerId', + 'msg_server_id', 'msgServerId', 'MsgServerId', + 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', + 'WCDB_CT_server_id' + ]) + const serverIdRaw = this.normalizeUnsignedIntToken(rawServerIdValue) const serverId = this.getIntFromRow(row, [ 'server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', @@ -3598,6 +3820,7 @@ class ExportService { rows.push({ localId, serverId, + serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined, createTime, localType, content, @@ -4440,6 +4663,16 @@ class ExportService { content: content } + const platformMessageId = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) + if (platformMessageId !== '0') { + message.platformMessageId = platformMessageId + } + + const replyToMessageId = this.extractChatLabReplyToMessageId(msg.content) + if (replyToMessageId) { + message.replyToMessageId = replyToMessageId + } + // 如果有聊天记录,添加为嵌套字段 if (msg.chatRecordList && msg.chatRecordList.length > 0) { const chatRecords: any[] = [] @@ -4895,6 +5128,20 @@ class ExportService { ) } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + content = this.buildQuotedReplyText(quotedReplyDisplay) + } + // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername const contact = senderWxid @@ -4938,6 +5185,12 @@ class ExportService { senderAvatarKey: msg.senderUsername } + const platformMessageId = this.getExportPlatformMessageId(msg) + if (platformMessageId) msgObj.platformMessageId = platformMessageId + + const replyToMessageId = this.getExportReplyToMessageId(msg.content) + if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId + const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) if (appMsgMeta) { if ( @@ -4947,6 +5200,10 @@ class ExportService { Object.assign(msgObj, appMsgMeta) } } + if (quotedReplyDisplay) { + if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender + if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview + } if (options.format === 'arkme-json') { const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType) @@ -5144,6 +5401,8 @@ class ExportService { senderID, source: message.source } + if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId + if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId if (message.locationLat != null) compactMessage.locationLat = message.locationLat if (message.locationLng != null) compactMessage.locationLng = message.locationLng if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname @@ -5781,6 +6040,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + // 调试日志 if (msg.localType === 3 || msg.localType === 47) { } @@ -6028,6 +6301,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + appendRow(useCompactColumns ? [ i + 1, @@ -6381,6 +6668,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + let senderRole: string let senderWxid: string let senderNickname: string @@ -6522,7 +6823,7 @@ class ExportService { control, collectProgressReporter ) - const totalMessages = collected.rows.length + let totalMessages = collected.rows.length if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -6550,7 +6851,13 @@ class ExportService { ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map() - const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + const sortedMessages = collected.rows + .sort((a, b) => a.createTime - b.createTime) + .filter((msg) => !this.isQuotedReplyMessage(msg.localType, msg.content || '')) + totalMessages = sortedMessages.length + if (totalMessages === 0) { + return { success: false, error: '该会话在指定时间范围内没有可导出的消息' } + } const voiceMessages = options.exportVoiceAsText ? sortedMessages.filter(msg => msg.localType === 34) @@ -6742,10 +7049,11 @@ class ExportService { msg.isSend ) || '') const src = this.getWeCloneSource(msg, typeName, mediaItem) + const platformMessageId = this.getExportPlatformMessageId(msg) || '' const row = [ i + 1, - i + 1, + platformMessageId, typeName, msg.isSend ? 1 : 0, talker, @@ -6945,6 +7253,7 @@ class ExportService { if (collected.rows.length === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } + const totalMessages = collected.rows.length const senderUsernames = new Set() let senderScanIndex = 0 @@ -6987,6 +7296,7 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -7219,8 +7529,18 @@ class ExportService { const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) - let textContent = this.formatHtmlMessageText( + let textContent = quotedReplyDisplay?.replyText || this.formatHtmlMessageText( msg.content, msg.localType, cleanedMyWxid, @@ -7251,7 +7571,7 @@ class ExportService { } } - const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType) + const linkCard = quotedReplyDisplay ? null : this.extractHtmlLinkCard(msg.content, msg.localType) let mediaHtml = '' if (mediaItem?.kind === 'image') { @@ -7267,25 +7587,40 @@ class ExportService { mediaHtml = `` } - const textHtml = linkCard - ? `` - : (textContent - ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` - : '') + const textHtml = quotedReplyDisplay + ? (() => { + const quotedSenderHtml = quotedReplyDisplay.quotedSender + ? `
${this.escapeHtml(quotedReplyDisplay.quotedSender)}
` + : '' + const quotedPreviewHtml = `
${this.renderTextWithEmoji(quotedReplyDisplay.quotedPreview).replace(/\r?\n/g, '
')}
` + const replyTextHtml = textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '' + return `
${quotedSenderHtml}${quotedPreviewHtml}
${replyTextHtml}` + })() + : (linkCard + ? `` + : (textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '')) const senderNameHtml = isGroup ? `
${this.escapeHtml(resolvedSenderName)}
` : '' const timeHtml = `
${this.escapeHtml(timeText)}
` const messageBody = `${timeHtml}${senderNameHtml}
${mediaHtml}${textHtml}
` + const platformMessageId = this.getExportPlatformMessageId(msg) + const replyToMessageId = this.getExportReplyToMessageId(msg.content) // Compact JSON object - const itemObj = { + const itemObj: Record = { i: i + 1, // index t: msg.createTime, // timestamp s: isSenderMe ? 1 : 0, // isSend a: avatarHtml, // avatar HTML b: messageBody // body HTML } + if (platformMessageId) itemObj.p = platformMessageId + if (replyToMessageId) itemObj.r = replyToMessageId writeBuf.push(JSON.stringify(itemObj)) @@ -7333,8 +7668,10 @@ class ExportService { // Render Item Function const renderItem = (item, index) => { const isSenderMe = item.s === 1; + const platformIdAttr = item.p ? \` data-platform-message-id="\${item.p}"\` : ''; + const replyToAttr = item.r ? \` data-reply-to-message-id="\${item.r}"\` : ''; return \` -
+
\${item.a}
diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 7b95996..163106c 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -1226,7 +1226,7 @@ class HttpService { * 映射 Type 49 子类型 */ private mapType49(msg: Message): number { - const xmlType = msg.xmlType + const xmlType = this.resolveType49Subtype(msg) switch (xmlType) { case '5': // 链接 @@ -1250,10 +1250,97 @@ class HttpService { } } + private extractType49Subtype(rawContent: string): string { + const content = String(rawContent || '') + if (!content) return '' + + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) { + return typeMatch[1].replace(//g, '').trim() + } + } + + const fallbackMatch = /([\s\S]*?)<\/type>/i.exec(content) + if (fallbackMatch) { + return fallbackMatch[1].replace(//g, '').trim() + } + + return '' + } + + private resolveType49Subtype(msg: Message): string { + const xmlType = String(msg.xmlType || '').trim() + if (xmlType) return xmlType + + const extractedType = this.extractType49Subtype(msg.rawContent) + if (extractedType) return extractedType + + switch (msg.appMsgKind) { + case 'official-link': + case 'link': + return '5' + case 'file': + return '6' + case 'chat-record': + return '19' + case 'miniapp': + return '33' + case 'quote': + return '57' + case 'transfer': + return '2000' + case 'red-packet': + return '2001' + case 'music': + return '3' + default: + if (msg.linkUrl) return '5' + if (msg.fileName) return '6' + return '' + } + } + + private getType49Content(msg: Message): string { + const subtype = this.resolveType49Subtype(msg) + const title = msg.linkTitle || msg.fileName || '' + + switch (subtype) { + case '5': + case '49': + return title ? `[链接] ${title}` : '[链接]' + case '6': + return title ? `[文件] ${title}` : '[文件]' + case '19': + return title ? `[聊天记录] ${title}` : '[聊天记录]' + case '33': + case '36': + return title ? `[小程序] ${title}` : '[小程序]' + case '57': + return msg.parsedContent || title || '[引用消息]' + case '2000': + return title ? `[转账] ${title}` : '[转账]' + case '2001': + return title ? `[红包] ${title}` : '[红包]' + case '3': + return title ? `[音乐] ${title}` : '[音乐]' + default: + return msg.parsedContent || title || '[消息]' + } + } + /** * 获取消息内容 */ private getMessageContent(msg: Message): string | null { + if (msg.localType === 49) { + return this.getType49Content(msg) + } + // 优先使用已解析的内容 if (msg.parsedContent) { return msg.parsedContent @@ -1276,7 +1363,7 @@ class HttpService { case 48: return '[位置]' case 49: - return msg.linkTitle || msg.fileName || '[消息]' + return this.getType49Content(msg) default: return msg.rawContent || null } diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index af33b75..79f9cdc 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -262,6 +262,7 @@ export class KeyServiceMac { ): Promise { const helperPath = this.getHelperPath() const waitMs = Math.max(timeoutMs, 30_000) + const timeoutSec = Math.ceil(waitMs / 1000) + 30 const pid = await this.getWeChatPid() onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0) // 最佳努力清理同路径残留 helper(普通权限) @@ -378,12 +379,22 @@ export class KeyServiceMac { ): Promise { const helperPath = this.getHelperPath() const waitMs = Math.max(timeoutMs, 30_000) + const timeoutSec = Math.ceil(waitMs / 1000) + 30 const pid = await this.getWeChatPid() // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败 + // 通过 try/on error 回传详细错误,避免只看到 "Command failed" const scriptLines = [ `set helperPath to ${JSON.stringify(helperPath)}`, - `set cmd to quoted form of helperPath & " ${pid} ${waitMs} 2>&1"`, - 'do shell script cmd with administrator privileges' + `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + `set timeoutSec to ${timeoutSec}`, + 'try', + 'with timeout of timeoutSec seconds', + 'set outText to do shell script cmd with administrator privileges', + 'end timeout', + 'return "WF_OK::" & outText', + 'on error errMsg number errNum partial result pr', + 'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)', + 'end try' ] onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0) @@ -400,6 +411,16 @@ export class KeyServiceMac { const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean) if (!lines.length) throw new Error('elevated helper returned empty output') + const joined = lines.join('\n') + + if (joined.startsWith('WF_ERR::')) { + const parts = joined.split('::') + const errNum = parts[1] || 'unknown' + const errMsg = parts[2] || 'unknown' + const partial = parts.slice(3).join('::') + throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`) + } + const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined // 从所有行里提取所有 JSON 对象(同一行可能有多个拼接),找含 key/result 的那个 const extractJsonObjects = (s: string): any[] => { @@ -411,7 +432,7 @@ export class KeyServiceMac { } return results } - const fullOutput = lines.join('\n') + const fullOutput = normalizedOutput const allJson = extractJsonObjects(fullOutput) // 优先找 success=true && key 字段 const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string') diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index 9bb27c4..3907662 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -13,13 +13,14 @@ width: min(480px, calc(100vw - 32px)); max-height: calc(100vh - 64px); overflow-y: auto; - border-radius: 12px; + border-radius: 16px; border: 1px solid var(--border-color); background: var(--bg-secondary-solid, var(--bg-primary)); - padding: 12px; + padding: 14px; display: flex; flex-direction: column; gap: 10px; + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16); } .export-date-range-dialog-header { @@ -83,8 +84,8 @@ } .export-date-range-mode-banner { - border-radius: 8px; - padding: 6px 8px; + border-radius: 10px; + padding: 7px 10px; font-size: 11px; line-height: 1.4; border: 1px solid var(--border-color); @@ -98,47 +99,92 @@ } } -.export-date-range-calendar-grid { +.export-date-range-boundary-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.export-date-range-boundary-card { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + padding: 8px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } + + .boundary-label { + font-size: 11px; + color: var(--text-secondary); + } +} + +.export-date-range-selection-hint { + font-size: 11px; + color: var(--text-secondary); + padding: 0 2px; +} + .export-date-range-calendar-panel { border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - padding: 7px; + border-radius: 12px; + background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary); + padding: 10px; + + &.single { + width: 100%; + } } .export-date-range-calendar-panel-header { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; gap: 8px; } .export-date-range-calendar-date-label { display: flex; flex-direction: column; - gap: 2px; + gap: 3px; span { font-size: 11px; color: var(--text-secondary); } + + strong { + font-size: 13px; + color: var(--text-primary); + } } .export-date-range-date-input { width: 100%; min-width: 0; - border-radius: 6px; + border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); - height: 24px; - padding: 0 7px; - font-size: 11px; + height: 30px; + padding: 0 9px; + font-size: 12px; + + &:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } &.invalid { border-color: #e84d4d; @@ -149,28 +195,36 @@ .export-date-range-calendar-nav { display: inline-flex; align-items: center; - gap: 4px; + gap: 6px; font-size: 11px; color: var(--text-primary); button { - width: 20px; - height: 20px; - border-radius: 5px; + width: 28px; + height: 28px; + border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); cursor: pointer; padding: 0; line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } } } .export-date-range-calendar-weekdays { - margin-top: 6px; + margin-top: 10px; display: grid; grid-template-columns: repeat(7, 1fr); - gap: 2px; + gap: 4px; span { text-align: center; @@ -180,32 +234,61 @@ } .export-date-range-calendar-days { - margin-top: 4px; + margin-top: 6px; display: grid; grid-template-columns: repeat(7, 1fr); - gap: 2px; + gap: 4px; } .export-date-range-calendar-day { border: 1px solid transparent; - border-radius: 6px; - min-height: 20px; + border-radius: 10px; + min-height: 34px; background: var(--bg-primary); color: var(--text-primary); - font-size: 10px; + font-size: 12px; cursor: pointer; padding: 0; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.28); + transform: translateY(-1px); + } + + &:disabled:hover { + border-color: transparent; + transform: none; + } &.outside { color: var(--text-quaternary); - opacity: 0.75; + opacity: 0.72; } - &.selected { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.14); + &.disabled { + cursor: not-allowed; + opacity: 0.35; + transform: none; + border-color: transparent; + } + + &.in-range { + background: rgba(var(--primary-rgb), 0.1); color: var(--primary); + } + + &.range-start, + &.range-end { + border-color: var(--primary); + background: var(--primary); + color: #fff; font-weight: 600; + opacity: 1; + } + + &.active-boundary { + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22); } } @@ -247,8 +330,8 @@ } } -@media (max-width: 860px) { - .export-date-range-calendar-grid { +@media (max-width: 640px) { + .export-date-range-boundary-row { grid-template-columns: 1fr; } } diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index e6695f1..8a49fdd 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' -import { Check, X } from 'lucide-react' +import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' import { EXPORT_DATE_RANGE_PRESETS, WEEKDAY_SHORT_LABELS, @@ -25,29 +25,78 @@ interface ExportDateRangeDialogProps { open: boolean value: ExportDateRangeSelection title?: string + minDate?: Date | null + maxDate?: Date | null onClose: () => void onConfirm: (value: ExportDateRangeSelection) => void } +type ActiveBoundary = 'start' | 'end' + interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { - startPanelMonth: Date - endPanelMonth: Date + panelMonth: Date } -const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({ - ...cloneExportDateRangeSelection(value), - startPanelMonth: toMonthStart(value.dateRange.start), - endPanelMonth: toMonthStart(value.dateRange.end) -}) +const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => { + if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null + if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null + const normalizedMin = startOfDay(minDate) + const normalizedMax = endOfDay(maxDate) + if (normalizedMin.getTime() > normalizedMax.getTime()) return null + return { + minDate: normalizedMin, + maxDate: normalizedMax + } +} + +const clampSelectionToBounds = ( + value: ExportDateRangeSelection, + minDate?: Date | null, + maxDate?: Date | null +): ExportDateRangeSelection => { + const bounds = resolveBounds(minDate, maxDate) + if (!bounds) return cloneExportDateRangeSelection(value) + + const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start) + const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end) + const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate + const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime() + + return { + preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset), + useAllTime: value.useAllTime, + dateRange: { + start: nextStart, + end: nextEnd + } + } +} + +const buildDialogDraft = ( + value: ExportDateRangeSelection, + minDate?: Date | null, + maxDate?: Date | null +): ExportDateRangeDialogDraft => { + const nextValue = clampSelectionToBounds(value, minDate, maxDate) + return { + ...nextValue, + panelMonth: toMonthStart(nextValue.dateRange.start) + } +} export function ExportDateRangeDialog({ open, value, title = '时间范围设置', + minDate, + maxDate, onClose, onConfirm }: ExportDateRangeDialogProps) { - const [draft, setDraft] = useState(() => buildDialogDraft(value)) + const [draft, setDraft] = useState(() => buildDialogDraft(value, minDate, maxDate)) + const [activeBoundary, setActiveBoundary] = useState('start') const [dateInput, setDateInput] = useState({ start: formatDateInputValue(value.dateRange.start), end: formatDateInputValue(value.dateRange.end) @@ -56,14 +105,15 @@ export function ExportDateRangeDialog({ useEffect(() => { if (!open) return - const nextDraft = buildDialogDraft(value) + const nextDraft = buildDialogDraft(value, minDate, maxDate) setDraft(nextDraft) + setActiveBoundary('start') setDateInput({ start: formatDateInputValue(nextDraft.dateRange.start), end: formatDateInputValue(nextDraft.dateRange.end) }) setDateInputError({ start: false, end: false }) - }, [open, value]) + }, [maxDate, minDate, open, value]) useEffect(() => { if (!open) return @@ -74,33 +124,24 @@ export function ExportDateRangeDialog({ setDateInputError({ start: false, end: false }) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) - const applyPreset = useCallback((preset: Exclude) => { - if (preset === 'all') { - const previewRange = createDefaultDateRange() - setDraft(prev => ({ - ...prev, - preset, - useAllTime: true, - dateRange: previewRange, - startPanelMonth: toMonthStart(previewRange.start), - endPanelMonth: toMonthStart(previewRange.end) - })) - return - } - - const range = createDateRangeByPreset(preset) - setDraft(prev => ({ - ...prev, - preset, - useAllTime: false, - dateRange: range, - startPanelMonth: toMonthStart(range.start), - endPanelMonth: toMonthStart(range.end) - })) - }, []) - - const updateDraftStart = useCallback((targetDate: Date) => { + const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate]) + const clampStartDate = useCallback((targetDate: Date) => { const start = startOfDay(targetDate) + if (!bounds) return start + if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate + if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate) + return start + }, [bounds]) + const clampEndDate = useCallback((targetDate: Date) => { + const end = endOfDay(targetDate) + if (!bounds) return end + if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate) + if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate + return end + }, [bounds]) + + const setRangeStart = useCallback((targetDate: Date) => { + const start = clampStartDate(targetDate) setDraft(prev => { const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end return { @@ -111,16 +152,15 @@ export function ExportDateRangeDialog({ start, end: nextEnd }, - startPanelMonth: toMonthStart(start), - endPanelMonth: toMonthStart(nextEnd) + panelMonth: toMonthStart(start) } }) - }, []) + }, [clampStartDate]) - const updateDraftEnd = useCallback((targetDate: Date) => { - const end = endOfDay(targetDate) + const setRangeEnd = useCallback((targetDate: Date) => { + const end = clampEndDate(targetDate) setDraft(prev => { - const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start + const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start const nextEnd = end < nextStart ? endOfDay(nextStart) : end return { ...prev, @@ -130,11 +170,41 @@ export function ExportDateRangeDialog({ start: nextStart, end: nextEnd }, - startPanelMonth: toMonthStart(nextStart), - endPanelMonth: toMonthStart(nextEnd) + panelMonth: toMonthStart(targetDate) } }) - }, []) + }, [clampEndDate, clampStartDate]) + + const applyPreset = useCallback((preset: Exclude) => { + if (preset === 'all') { + const previewRange = bounds + ? { start: bounds.minDate, end: bounds.maxDate } + : createDefaultDateRange() + setDraft(prev => ({ + ...prev, + preset, + useAllTime: true, + dateRange: previewRange, + panelMonth: toMonthStart(previewRange.start) + })) + setActiveBoundary('start') + return + } + + const range = clampSelectionToBounds({ + preset, + useAllTime: false, + dateRange: createDateRangeByPreset(preset) + }, minDate, maxDate).dateRange + setDraft(prev => ({ + ...prev, + preset, + useAllTime: false, + dateRange: range, + panelMonth: toMonthStart(range.start) + })) + setActiveBoundary('start') + }, [bounds, maxDate, minDate]) const commitStartFromInput = useCallback(() => { const parsed = parseDateInputValue(dateInput.start) @@ -143,8 +213,8 @@ export function ExportDateRangeDialog({ return } setDateInputError(prev => ({ ...prev, start: false })) - updateDraftStart(parsed) - }, [dateInput.start, updateDraftStart]) + setRangeStart(parsed) + }, [dateInput.start, setRangeStart]) const commitEndFromInput = useCallback(() => { const parsed = parseDateInputValue(dateInput.end) @@ -153,29 +223,81 @@ export function ExportDateRangeDialog({ return } setDateInputError(prev => ({ ...prev, end: false })) - updateDraftEnd(parsed) - }, [dateInput.end, updateDraftEnd]) + setRangeEnd(parsed) + }, [dateInput.end, setRangeEnd]) - const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => { - setDraft(prev => ( - panel === 'start' - ? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) } - : { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) } - )) + const shiftPanelMonth = useCallback((delta: number) => { + setDraft(prev => ({ + ...prev, + panelMonth: addMonths(prev.panelMonth, delta) + })) }, []) + const handleCalendarSelect = useCallback((targetDate: Date) => { + if (activeBoundary === 'start') { + setRangeStart(targetDate) + setActiveBoundary('end') + return + } + + setDraft(prev => { + const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start + const pickedStart = startOfDay(targetDate) + const nextStart = pickedStart <= start ? pickedStart : start + const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate) + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start: nextStart, + end: nextEnd + }, + panelMonth: toMonthStart(targetDate) + } + }) + setActiveBoundary('start') + }, [activeBoundary, setRangeEnd, setRangeStart]) + const isRangeModeActive = !draft.useAllTime const modeText = isRangeModeActive ? '当前导出模式:按时间范围导出' - : '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)' + : '当前导出模式:全部时间导出,选择下方日期会切换为自定义时间范围' const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => { if (preset === 'all') return draft.useAllTime return !draft.useAllTime && draft.preset === preset }, [draft]) - const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth]) - const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth]) + const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth]) + const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null + const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null + const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime() + const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime() + + const isStartSelected = useCallback((date: Date) => ( + !draft.useAllTime && isSameDay(date, draft.dateRange.start) + ), [draft]) + + const isEndSelected = useCallback((date: Date) => ( + !draft.useAllTime && isSameDay(date, draft.dateRange.end) + ), [draft]) + + const isDateInRange = useCallback((date: Date) => ( + !draft.useAllTime && + startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() && + startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime() + ), [draft]) + + const isDateSelectable = useCallback((date: Date) => { + if (!bounds) return true + const target = startOfDay(date).getTime() + return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime() + }, [bounds]) + + const hintText = draft.useAllTime + ? '选择开始或结束日期后,会自动切换为自定义时间范围' + : (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期') if (!open) return null @@ -215,112 +337,115 @@ export function ExportDateRangeDialog({ {modeText}
-
-
-
-
- 起始日期 - { - const nextValue = event.target.value - setDateInput(prev => ({ ...prev, start: nextValue })) - if (dateInputError.start) { - setDateInputError(prev => ({ ...prev, start: false })) - } - }} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - commitStartFromInput() - }} - onBlur={commitStartFromInput} - /> -
-
- - {formatCalendarMonthTitle(draft.startPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {startPanelCells.map((cell) => { - const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start) - return ( - - ) - })} -
-
- -
-
-
- 截止日期 - { - const nextValue = event.target.value - setDateInput(prev => ({ ...prev, end: nextValue })) - if (dateInputError.end) { - setDateInputError(prev => ({ ...prev, end: false })) - } - }} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - commitEndFromInput() - }} - onBlur={commitEndFromInput} - /> -
-
- - {formatCalendarMonthTitle(draft.endPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {endPanelCells.map((cell) => { - const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end) - return ( - - ) - })} -
-
+
+
setActiveBoundary('start')} + > + 开始 + { + const nextValue = event.target.value + setDateInput(prev => ({ ...prev, start: nextValue })) + if (dateInputError.start) { + setDateInputError(prev => ({ ...prev, start: false })) + } + }} + onFocus={() => setActiveBoundary('start')} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + commitStartFromInput() + }} + onBlur={commitStartFromInput} + /> +
+
setActiveBoundary('end')} + > + 结束 + { + const nextValue = event.target.value + setDateInput(prev => ({ ...prev, end: nextValue })) + if (dateInputError.end) { + setDateInputError(prev => ({ ...prev, end: false })) + } + }} + onFocus={() => setActiveBoundary('end')} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + commitEndFromInput() + }} + onBlur={commitEndFromInput} + /> +
+
{hintText}
+ +
+
+
+ 选择日期范围 + {formatCalendarMonthTitle(draft.panelMonth)} +
+
+ + +
+
+
+ {WEEKDAY_SHORT_LABELS.map(label => ( + {label} + ))} +
+
+ {calendarCells.map((cell) => { + const startSelected = isStartSelected(cell.date) + const endSelected = isEndSelected(cell.date) + const inRange = isDateInRange(cell.date) + const selectable = isDateSelectable(cell.date) + return ( + + ) + })} +
+
+
@@ -7840,6 +8041,8 @@ function ExportPage() { { setTimeRangeSelection(nextSelection) diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index fb8f009..62c83aa 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -176,6 +176,8 @@ export default function SnsPage() { const selectedContactUsernamesRef = useRef(selectedContactUsernames) const cacheScopeKeyRef = useRef('') const snsUserPostCountsCacheScopeKeyRef = useRef('') + const activeContactsLoadTaskIdRef = useRef(null) + const activeContactsCountTaskIdRef = useRef(null) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const pendingResetFeedRef = useRef(false) const contactsLoadTokenRef = useRef(0) @@ -750,6 +752,12 @@ export default function SnsPage() { window.clearTimeout(contactsCountBatchTimerRef.current) contactsCountBatchTimerRef.current = null } + if (activeContactsCountTaskIdRef.current) { + finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', { + detail: '已停止后续联系人朋友圈条数补算' + }) + activeContactsCountTaskIdRef.current = null + } if (resetProgress) { setContactsCountProgress({ resolved: 0, @@ -814,31 +822,56 @@ export default function SnsPage() { cancelable: true }) + activeContactsCountTaskIdRef.current = taskId let normalizedCounts: Record = {} try { const result = await window.electronAPI.sns.getUserPostCounts() if (isBackgroundTaskCancelRequested(taskId)) { + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,当前计数查询结束后不再继续分批写入' }) return } - if (runToken !== contactsCountHydrationTokenRef.current) return + if (runToken !== contactsCountHydrationTokenRef.current) { + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期' + }) + return + } if (result.success && result.counts) { - normalizedCounts = Object.fromEntries( - Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)]) - ) + normalizedCounts = pendingTargets.reduce>((acc, username) => { + acc[username] = normalizePostCount(result.counts?.[username]) + return acc + }, {}) void (async () => { try { const scopeKey = await ensureSnsUserPostCountsCacheScopeKey() - await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) + const currentCache = await configService.getExportSnsUserPostCountsCache(scopeKey) + await configService.setExportSnsUserPostCountsCache(scopeKey, { + ...(currentCache?.counts || {}), + ...normalizedCounts + }) } catch (cacheError) { console.error('Failed to persist SNS user post counts cache:', cacheError) } })() + } else { + normalizedCounts = pendingTargets.reduce>((acc, username) => { + acc[username] = 0 + return acc + }, {}) } } catch (error) { console.error('Failed to load contact post counts:', error) + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } finishBackgroundTask(taskId, 'failed', { detail: String(error) }) @@ -848,8 +881,19 @@ export default function SnsPage() { let resolved = preResolved let cursor = 0 const applyBatch = () => { - if (runToken !== contactsCountHydrationTokenRef.current) return + if (runToken !== contactsCountHydrationTokenRef.current) { + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期' + }) + return + } if (isBackgroundTaskCancelRequested(taskId)) { + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } finishBackgroundTask(taskId, 'canceled', { detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}` }) @@ -870,6 +914,9 @@ export default function SnsPage() { running: false }) contactsCountBatchTimerRef.current = null + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } finishBackgroundTask(taskId, 'completed', { detail: '联系人朋友圈条数补算完成', progressText: `${totalTargets}/${totalTargets}` @@ -910,6 +957,18 @@ export default function SnsPage() { contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS) } else { contactsCountBatchTimerRef.current = null + setContactsCountProgress({ + resolved: totalTargets, + total: totalTargets, + running: false + }) + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'completed', { + detail: '鑱旂郴浜烘湅鍙嬪湀鏉℃暟琛ョ畻瀹屾垚', + progressText: `${totalTargets}/${totalTargets}` + }) } } @@ -918,6 +977,12 @@ export default function SnsPage() { // Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序) const loadContacts = useCallback(async () => { + if (activeContactsLoadTaskIdRef.current) { + finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', { + detail: '新一轮联系人列表加载已开始,旧任务已取消' + }) + activeContactsLoadTaskIdRef.current = null + } const requestToken = ++contactsLoadTokenRef.current const taskId = registerBackgroundTask({ sourcePage: 'sns', @@ -926,6 +991,7 @@ export default function SnsPage() { progressText: '初始化', cancelable: true }) + activeContactsLoadTaskIdRef.current = taskId stopContactsCountHydration(true) setContactsLoading(true) try { @@ -955,7 +1021,15 @@ export default function SnsPage() { } }) - if (requestToken !== contactsLoadTokenRef.current) return + if (requestToken !== contactsLoadTokenRef.current) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人列表加载已过期' + }) + return + } if (cachedContacts.length > 0) { const cachedContactsSorted = sortContactsForRanking(cachedContacts) setContacts(cachedContactsSorted) @@ -977,6 +1051,9 @@ export default function SnsPage() { window.electronAPI.chat.getSessions() ]) if (isBackgroundTaskCancelRequested(taskId)) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,当前联系人查询结束后未继续补齐' }) @@ -1021,7 +1098,15 @@ export default function SnsPage() { } let contactsList = sortContactsForRanking(Array.from(contactMap.values())) - if (requestToken !== contactsLoadTokenRef.current) return + if (requestToken !== contactsLoadTokenRef.current) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人列表加载已过期' + }) + return + } setContacts(contactsList) const readyUsernames = new Set( contactsList @@ -1043,6 +1128,9 @@ export default function SnsPage() { }) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) if (isBackgroundTaskCancelRequested(taskId)) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,联系人补齐未继续写入' }) @@ -1058,7 +1146,15 @@ export default function SnsPage() { avatarUrl: extra.avatarUrl || contact.avatarUrl } }) - if (requestToken !== contactsLoadTokenRef.current) return + if (requestToken !== contactsLoadTokenRef.current) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人列表加载已过期' + }) + return + } setContacts((prev) => { const prevMap = new Map(prev.map((contact) => [contact.username, contact])) const merged = contactsList.map((contact) => { @@ -1074,18 +1170,35 @@ export default function SnsPage() { }) } } + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } finishBackgroundTask(taskId, 'completed', { detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`, progressText: `${contactsList.length} 人` }) } catch (error) { - if (requestToken !== contactsLoadTokenRef.current) return + if (requestToken !== contactsLoadTokenRef.current) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人列表加载已过期' + }) + return + } console.error('Failed to load contacts:', error) stopContactsCountHydration(true) + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } finishBackgroundTask(taskId, 'failed', { detail: String(error) }) } finally { + if (activeContactsLoadTaskIdRef.current === taskId && requestToken !== contactsLoadTokenRef.current) { + activeContactsLoadTaskIdRef.current = null + } if (requestToken === contactsLoadTokenRef.current) { setContactsLoading(false) } @@ -1185,6 +1298,18 @@ export default function SnsPage() { window.clearTimeout(contactsCountBatchTimerRef.current) contactsCountBatchTimerRef.current = null } + if (activeContactsCountTaskIdRef.current) { + finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', { + detail: '已离开朋友圈页,联系人朋友圈条数补算已取消' + }) + activeContactsCountTaskIdRef.current = null + } + if (activeContactsLoadTaskIdRef.current) { + finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', { + detail: '已离开朋友圈页,联系人列表加载已取消' + }) + activeContactsLoadTaskIdRef.current = null + } } }, []) diff --git a/src/services/config.ts b/src/services/config.ts index ee85acd..7d57bff 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -580,6 +580,8 @@ export interface ExportSessionContentMetricCacheEntry { imageMessages?: number videoMessages?: number emojiMessages?: number + firstTimestamp?: number + lastTimestamp?: number } export interface ExportSessionContentMetricCacheItem { @@ -742,6 +744,12 @@ export async function getExportSessionContentMetricCache(scopeKey: string): Prom if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) { metric.emojiMessages = Math.floor(source.emojiMessages) } + if (typeof source.firstTimestamp === 'number' && Number.isFinite(source.firstTimestamp) && source.firstTimestamp > 0) { + metric.firstTimestamp = Math.floor(source.firstTimestamp) + } + if (typeof source.lastTimestamp === 'number' && Number.isFinite(source.lastTimestamp) && source.lastTimestamp > 0) { + metric.lastTimestamp = Math.floor(source.lastTimestamp) + } if (Object.keys(metric).length === 0) continue metrics[sessionId] = metric } @@ -781,6 +789,12 @@ export async function setExportSessionContentMetricCache( if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) { metric.emojiMessages = Math.floor(rawMetric.emojiMessages) } + if (typeof rawMetric.firstTimestamp === 'number' && Number.isFinite(rawMetric.firstTimestamp) && rawMetric.firstTimestamp > 0) { + metric.firstTimestamp = Math.floor(rawMetric.firstTimestamp) + } + if (typeof rawMetric.lastTimestamp === 'number' && Number.isFinite(rawMetric.lastTimestamp) && rawMetric.lastTimestamp > 0) { + metric.lastTimestamp = Math.floor(rawMetric.lastTimestamp) + } if (Object.keys(metric).length === 0) continue normalized[sessionId] = metric }