diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 04af63a..49b0c7d 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -1172,13 +1172,37 @@ const renderExportScript = (name: string): string => ` } const renderPaymentContent = (data, kind) => { const isTransfer = kind === 'transfer' + const pay = (isTransfer ? data.transfer : data.pay) || {} + const amount = pay.amountText + const memo = pay.payMemo + const statusText = isTransfer ? pay.transferStatusText : pay.redPacketStatusText + const meta = [amount, memo ? '备注:' + memo : '', statusText] + .filter(Boolean) + .join(' · ') + const title = isTransfer + ? (data.title || '微信转账') + : (pay.sendTitle || pay.receiveTitle || data.title || '微信红包') + const blurb = + data.description || + data.des || + (isTransfer ? '转账消息' : (pay.sceneText || '恭喜发财,大吉大利')) + // 状态/金额/备注始终单独露出,避免被消息原文 des 盖住。 + const description = meta + ? (blurb && blurb !== meta ? blurb + ' · ' + meta : meta) + : blurb + const footerBits = isTransfer + ? [pay.paySubtype === '3' ? '收款' : pay.paySubtype === '1' || pay.paySubtype === '4' ? '转账' : '', + pay.transferId ? '单号 ' + pay.transferId : ''] + : [pay.hbType ? '类型 ' + pay.hbType : '', pay.sendId ? 'sendid ' + pay.sendId : ''] return '
' + '
' + (isTransfer ? '微信转账' : '微信红包') + '
' + - '
' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '
' + - '
' + - displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) + - '
' + '
' + displayText(title) + '
' + + '
' + displayText(description) + '
' + + (footerBits.filter(Boolean).length + ? '' + : '') + + '' } const renderVoipContent = (data) => { const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话' diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 4be0c35..1c85fc0 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -1,3 +1,5 @@ +import { describeRedPacketStatus, describeTransferStatus } from '../shared/payment-status' + type TextContent = { type: 'text'; content: string } type VoiceContent = { type: 'voice'; duration?: number } type LocationContent = { @@ -22,6 +24,40 @@ type ShareContent = { appname?: string typeVal?: string articles?: ShareArticle[] + transfer?: TransferPaymentInfo +} + +type TransferPaymentInfo = { + paySubtype?: string + amountText?: string + transcationId?: string + transferId?: string + invalidTime?: string + beginTransferTime?: string + effectiveDate?: string + payMemo?: string + receiverUsername?: string + payerUsername?: string + transferStatus?: string + /** `transfer_status` 展示文案(只读)。 */ + transferStatusText?: string +} + +type RedPacketPaymentInfo = { + templateId?: string + receiveTitle?: string + sendTitle?: string + sceneText?: string + senderDes?: string + receiverDes?: string + iconUrl?: string + nativeUrl?: string + sendId?: string + hbType?: string + hbStatus?: string + receiveStatus?: string + /** `hb_status` / `receive_status` 展示文案(只读)。 */ + redPacketStatusText?: string } type ForwardedMessageItem = { messageType: number @@ -51,6 +87,7 @@ type RedPacketContent = { title: string description?: string url?: string + pay?: RedPacketPaymentInfo } type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number } type ImageContent = { @@ -522,12 +559,25 @@ function parseShareMessage(content: string): ParsedContent { } } - if (appMsgType === '2001') { + if (appMsgType === '2001' || /mmpayhb|receivehongbao|wxpay:\/\/c2cbizmessagehandler\/hongbao/i.test(content)) { + const pay = parseWcpayInfo(content) + const url = decodeXmlUrl(extractXmlValue(content, 'url')) || undefined return { type: 'redPacket', - title: extractXmlValue(content, 'title') || '微信红包', - description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利', - url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined + title: + pay.sendTitle || + pay.receiveTitle || + extractXmlValue(content, 'title') || + '微信红包', + description: + extractXmlValue(content, 'des') || + pay.sceneText || + '恭喜发财,大吉大利', + url, + pay: { + ...pay, + sendId: pay.sendId || extractSendIdFromUrl(url) + } } } @@ -557,7 +607,55 @@ function parseShareMessage(content: string): ParsedContent { url, appname, typeVal, - articles: articles.length > 1 ? articles : undefined + articles: articles.length > 1 ? articles : undefined, + transfer: typeVal === '2000' ? parseWcpayInfo(content) : undefined + } +} + +/** 只读解析 ``(转账 / 红包展示字段;不做支付)。 */ +function parseWcpayInfo(content: string): TransferPaymentInfo & RedPacketPaymentInfo { + const block = /([\s\S]*?)<\/wcpayinfo>/i.exec(content)?.[1] || content + const val = (tag: string): string | undefined => { + const raw = extractXmlValue(block, tag) + return raw ? decodeXmlEntities(raw) || undefined : undefined + } + const transferStatus = val('transfer_status') + const hbStatus = val('hb_status') + const receiveStatus = val('receive_status') + return { + paySubtype: val('paysubtype'), + amountText: val('feedesc'), + transcationId: val('transcationid'), + transferId: val('transferid'), + invalidTime: val('invalidtime'), + beginTransferTime: val('begintransfertime'), + effectiveDate: val('effectivedate'), + payMemo: val('pay_memo') || undefined, + receiverUsername: val('receiver_username'), + payerUsername: val('payer_username'), + transferStatus, + transferStatusText: describeTransferStatus(transferStatus), + templateId: val('templateid'), + receiveTitle: val('receivertitle'), + sendTitle: val('sendertitle'), + sceneText: val('scenetext'), + senderDes: val('senderdes'), + receiverDes: val('receiverdes'), + iconUrl: decodeXmlUrl(val('iconurl') || '') || undefined, + nativeUrl: val('nativeurl'), + hbType: val('hb_type'), + hbStatus, + receiveStatus, + redPacketStatusText: describeRedPacketStatus(hbStatus, receiveStatus) + } +} + +function extractSendIdFromUrl(url?: string): string | undefined { + if (!url) return undefined + try { + return new URL(url).searchParams.get('sendid') || undefined + } catch { + return /sendid=(\d+)/i.exec(url)?.[1] } } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 8d6c545..7bec889 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -155,6 +155,37 @@ import type { TextToSpeechSettingsResult } from '../shared/text-to-speech' +export type TransferPaymentInfo = { + paySubtype?: string + amountText?: string + transcationId?: string + transferId?: string + invalidTime?: string + beginTransferTime?: string + effectiveDate?: string + payMemo?: string + receiverUsername?: string + payerUsername?: string + transferStatus?: string + transferStatusText?: string +} + +export type RedPacketPaymentInfo = { + templateId?: string + receiveTitle?: string + sendTitle?: string + sceneText?: string + senderDes?: string + receiverDes?: string + iconUrl?: string + nativeUrl?: string + sendId?: string + hbType?: string + hbStatus?: string + receiveStatus?: string + redPacketStatusText?: string +} + export type ParsedContent = | { type: 'text'; content: string } | { type: 'voice'; duration?: number } @@ -167,6 +198,7 @@ export type ParsedContent = url: string appname?: string typeVal?: string + transfer?: TransferPaymentInfo } | { type: 'miniProgram' @@ -178,7 +210,13 @@ export type ParsedContent = thumbDatName?: string thumbDataUrl?: string } - | { type: 'redPacket'; title: string; description?: string; url?: string } + | { + type: 'redPacket' + title: string + description?: string + url?: string + pay?: RedPacketPaymentInfo + } | { type: 'voip'; duration?: number; status: string; roomType?: number } | { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number } | { diff --git a/src/renderer/src/components/RichMessageBubble.tsx b/src/renderer/src/components/RichMessageBubble.tsx index 1372445..ae95597 100644 --- a/src/renderer/src/components/RichMessageBubble.tsx +++ b/src/renderer/src/components/RichMessageBubble.tsx @@ -158,7 +158,22 @@ function CardBubble({ data }: { data: Extract } } function ShareBubble({ data }: { data: Extract }): JSX.Element { - const { title, des, url, appname, articles } = data + const { title, des, url, appname, articles, transfer, typeVal } = data + + if (String(typeVal) === '2000' || transfer) { + const pay = transfer || {} + const meta = [pay.amountText, pay.payMemo ? `备注:${pay.payMemo}` : '', pay.transferStatusText] + .filter(Boolean) + .join(' · ') + return ( +
+
微信转账
+
{title || '微信转账'}
+
{meta || des || '转账消息'}
+ {pay.transferId ?
单号 {pay.transferId}
: null} +
+ ) + } if (articles?.length) { return ( @@ -254,6 +269,8 @@ function RedPacketBubble({ const handleClick = (): void => { if (data.url) window.open(data.url, '_blank') } + const statusText = data.pay?.redPacketStatusText + const blurb = data.description || data.pay?.sceneText || '恭喜发财,大吉大利' return (
{data.title} - {data.description || '恭喜发财,大吉大利'} + {statusText ? `${blurb} · ${statusText}` : blurb}
微信红包
diff --git a/src/renderer/src/styles/rich-message.scss b/src/renderer/src/styles/rich-message.scss index d8f33ae..e107ee3 100644 --- a/src/renderer/src/styles/rich-message.scss +++ b/src/renderer/src/styles/rich-message.scss @@ -364,6 +364,43 @@ line-height: 16px; } +.transfer-message { + width: min(280px, 48vw); + overflow: hidden; + border: 1px solid var(--wxex-border); + border-radius: 6px; + background: var(--wxex-bg-elevated); + color: var(--wxex-text-primary); + padding: 12px 14px; +} + +.transfer-kicker { + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 18px; +} + +.transfer-title { + margin-top: 2px; + font-size: 15px; + font-weight: 600; + line-height: 22px; +} + +.transfer-meta { + margin-top: 6px; + color: var(--wxex-text-primary); + font-size: 13px; + line-height: 20px; +} + +.transfer-footer { + margin-top: 8px; + color: var(--wxex-text-muted); + font-size: 11px; + line-height: 16px; +} + .red-packet-message { width: min(280px, 48vw); overflow: hidden; diff --git a/src/shared/payment-status.ts b/src/shared/payment-status.ts new file mode 100644 index 0000000..5b797da --- /dev/null +++ b/src/shared/payment-status.ts @@ -0,0 +1,107 @@ +/** + * 转账 / 红包状态文案(只读展示)。 + * 文案对齐微信 UI 簇(待收款/已收款/…、待领取/已领取/…)。 + * 数字枚举为推断对齐;未知值原样回退。 + */ + +const TRANSFER_STATUS_TEXT: Record = { + '0': '待收款', + '1': '已收款,待入账', + '2': '已收款', + '3': '已退还', + '4': '过期未收款,已退还', + '5': '你已退还', + '6': '已过期', + '7': '此笔转账已被撤回', + paid: '已收款', + received: '已收款', + pending: '待收款', + unreceived: '待收款', + refunded: '已退还', + expired: '过期未收款,已退还', + withdrawn: '此笔转账已被撤回', + revoked: '此笔转账已被撤回', + confirmed: '朋友已确认收款', + accepted: '已被接收', + accepting: '已被接收' +} + +const RED_PACKET_STATUS_TEXT: Record = { + '0': '待领取', + '1': '已领取', + '2': '已被领完', + '3': '已过期', + '4': '已被领取', + received: '已领取', + claimed: '已领取', + expired: '已过期', + exhausted: '已被领完', + pending: '待领取', + unclaimed: '待领取' +} + +const TRANSFER_STATUS_KEYWORDS: Array<[RegExp, string]> = [ + [/撤回|withdraw|revoke/i, '此笔转账已被撤回'], + [/过期未收款|expired/i, '过期未收款,已退还'], + [/已退还|退还|refund/i, '已退还'], + [/待入账|pending.?credit/i, '已收款,待入账'], + [/已收款|已收|paid|received/i, '已收款'], + [/已被接收|accepted|accepting/i, '已被接收'], + [/确认收款|confirmed/i, '朋友已确认收款'], + [/待收款|请收款|pending|unreceived/i, '待收款'], + [/已过期|expired/i, '已过期'] +] + +const RED_PACKET_STATUS_KEYWORDS: Array<[RegExp, string]> = [ + [/领完|exhausted/i, '已被领完'], + [/已被领取|已领取|received|claimed/i, '已领取'], + [/过期|expired/i, '已过期'], + [/待领取|未领取|pending|unclaimed/i, '待领取'] +] + +function describeByMap( + raw: string | number | undefined, + table: Record, + keywords: Array<[RegExp, string]> +): string | undefined { + if (raw === undefined || raw === null || raw === '') return undefined + const text = String(raw).trim() + if (!text) return undefined + const direct = table[text.toLowerCase()] || table[text] + if (direct) return direct + for (const [re, label] of keywords) { + if (re.test(text)) return label + } + return text +} + +/** `transfer_status` → 展示文案(未知原样返回)。 */ +export function describeTransferStatus(raw?: string | number): string | undefined { + return describeByMap(raw, TRANSFER_STATUS_TEXT, TRANSFER_STATUS_KEYWORDS) +} + +/** 红包 `hb_status` / `receive_status` → 展示文案。 */ +export function describeRedPacketStatus( + hbStatus?: string | number, + receiveStatus?: string | number +): string | undefined { + return ( + describeByMap(hbStatus, RED_PACKET_STATUS_TEXT, RED_PACKET_STATUS_KEYWORDS) || + describeByMap(receiveStatus, RED_PACKET_STATUS_TEXT, RED_PACKET_STATUS_KEYWORDS) + ) +} + +/** `paysubtype` 仅作辅助标签(真机见过 1/3/4;语义未在 dylib 钉死)。 */ +export function describePaySubtype(raw?: string | number): string | undefined { + if (raw === undefined || raw === null || raw === '') return undefined + const text = String(raw).trim() + const table: Record = { + '1': '转账', + '3': '收款', + '4': '转账' + } + return table[text] || text +} + +export const TRANSFER_STATUS_LABELS = TRANSFER_STATUS_TEXT +export const RED_PACKET_STATUS_LABELS = RED_PACKET_STATUS_TEXT diff --git a/src/shared/types.ts b/src/shared/types.ts index 77df791..16000f2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -97,6 +97,43 @@ type ShareContent = { appname?: string typeVal?: string articles?: ShareArticle[] + /** `wcpayinfo` when typeVal === '2000' (微信转账). Display/export only. */ + transfer?: TransferPaymentInfo +} + +/** 转账 `wcpayinfo` 字段(只读展示;不含支付操作)。 */ +export type TransferPaymentInfo = { + paySubtype?: string + amountText?: string + transcationId?: string + transferId?: string + invalidTime?: string + beginTransferTime?: string + effectiveDate?: string + payMemo?: string + receiverUsername?: string + payerUsername?: string + transferStatus?: string + /** `transfer_status` 展示文案(只读)。 */ + transferStatusText?: string +} + +/** 红包 `wcpayinfo` 字段(只读展示)。 */ +export type RedPacketPaymentInfo = { + templateId?: string + receiveTitle?: string + sendTitle?: string + sceneText?: string + senderDes?: string + receiverDes?: string + iconUrl?: string + nativeUrl?: string + sendId?: string + hbType?: string + hbStatus?: string + receiveStatus?: string + /** 展示文案(只读)。 */ + redPacketStatusText?: string } export type ForwardedMessageItem = { messageType: number @@ -126,6 +163,8 @@ type RedPacketContent = { title: string description?: string url?: string + /** `wcpayinfo` 展示字段(只读)。 */ + pay?: RedPacketPaymentInfo } type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number } type ImageContent = { diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index 876bf43..b68e112 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -457,6 +457,74 @@ describe('export media', () => { expect(html).toContain("if (event.key === 'Escape')") expect(html).toContain('closeLightbox()') }) + + it('always surfaces transfer and red-packet amount, memo and status wording', () => { + const html = renderExportPage('支付可读导出') + const dom = new JSDOM(html, { runScripts: 'outside-only' }) + const messages: Message[] = [ + { + ...messageForArchive('transfer-paid', 'fixture', '支付可读导出', '收到转账2900.00元。', 1), + type: '转账', + contentData: { + type: 'share', + typeVal: '2000', + title: '微信转账', + des: '收到转账2900.00元。', + url: '', + transfer: { + amountText: '¥2900.00', + payMemo: '房租', + transferStatus: '2', + transferStatusText: '已收款', + transferId: '1000050001202609130232821248942', + paySubtype: '3' + } + } + }, + { + ...messageForArchive('hb-claimed', 'fixture', '支付可读导出', '我给你发了一个红包,赶紧去拆!', 2), + type: '微信红包', + contentData: { + type: 'redPacket', + title: '中秋快乐', + description: '我给你发了一个红包,赶紧去拆!', + pay: { + sendTitle: '中秋快乐', + sceneText: '微信红包', + hbStatus: '1', + redPacketStatusText: '已领取', + sendId: '1000039801202609247176036834007', + hbType: '1' + } + } + } + ] + Object.assign(dom.window, { + __WECHAT_EXPORT__: { + version: 1, + sourceId: 'fixture', + name: '支付可读导出', + exportedAt: '2026-09-24T12:00:00.000Z', + messages + } + }) + + dom.window.eval(inlineScriptOf(html)) + + const cards = [...dom.window.document.querySelectorAll('.payment-content')] + expect(cards).toHaveLength(2) + const transfer = cards.find((node) => node.classList.contains('transfer'))! + const redPacket = cards.find((node) => node.classList.contains('red-packet'))! + // 即使消息带 des,金额/备注/状态也不能被盖住。 + expect(transfer.textContent).toContain('¥2900.00') + expect(transfer.textContent).toContain('备注:房租') + expect(transfer.textContent).toContain('已收款') + expect(transfer.textContent).toContain('收到转账2900.00元。') + expect(transfer.textContent).toContain('单号 1000050001202609130232821248942') + expect(redPacket.textContent).toContain('已领取') + expect(redPacket.textContent).toContain('我给你发了一个红包,赶紧去拆!') + dom.window.close() + }) }) function messageForArchive( diff --git a/tests/unit/message-parser.test.ts b/tests/unit/message-parser.test.ts index 7a072f6..abd3b29 100644 --- a/tests/unit/message-parser.test.ts +++ b/tests/unit/message-parser.test.ts @@ -206,4 +206,88 @@ describe('message parser', () => { messageType: 999 }) }) + + it('parses transfer wcpayinfo fields (type 2000)', () => { + // 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写) + const xml = [ + '', + '<![CDATA[微信转账]]>', + '', + '2000', + '', + '', + '3', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '2', + '' + ].join('') + const parsed = parseMessageContent(xml, 49) + expect(parsed).toMatchObject({ + type: 'share', + typeVal: '2000', + title: '微信转账', + transfer: { + paySubtype: '3', + amountText: '¥2900.00', + transcationId: '53010003370089202609134087894875', + transferId: '1000050001202609130232821248942', + invalidTime: '1789348128', + beginTransferTime: '1789261728', + effectiveDate: '1', + payMemo: '房租', + receiverUsername: 'wxid_receiver', + transferStatus: '2', + transferStatusText: '已收款' + } + }) + }) + + it('parses red packet wcpayinfo fields (type 2001 / mmpayhb)', () => { + const xml = [ + '', + '<![CDATA[中秋快乐]]>', + '', + '2001', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '1', + '1', + '2', + '' + ].join('') + const parsed = parseMessageContent(xml, 49) + expect(parsed).toMatchObject({ + type: 'redPacket', + title: '中秋快乐,望重置', + pay: { + templateId: '7a2a165d31da7fce6dd77e05c300028a', + receiveTitle: '恭喜发财,大吉大利', + sendTitle: '中秋快乐,望重置', + sceneText: '微信红包', + senderDes: '查看红包', + receiverDes: '领取红包', + sendId: '1000039801202609247176036834007', + hbType: '1', + hbStatus: '1', + receiveStatus: '2', + redPacketStatusText: '已领取' + } + }) + }) }) diff --git a/tests/unit/payment-status.test.ts b/tests/unit/payment-status.test.ts new file mode 100644 index 0000000..da96f6b --- /dev/null +++ b/tests/unit/payment-status.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + describePaySubtype, + describeRedPacketStatus, + describeTransferStatus +} from '../../src/shared/payment-status' + +describe('payment status labels', () => { + it('maps transfer_status numbers and keywords to UI wording', () => { + expect(describeTransferStatus(2)).toBe('已收款') + expect(describeTransferStatus('3')).toBe('已退还') + expect(describeTransferStatus('paid')).toBe('已收款') + expect(describeTransferStatus('收款方过期未收款,已退还')).toBe('过期未收款,已退还') + expect(describeTransferStatus(undefined)).toBeUndefined() + }) + + it('maps red packet hb_status / receive_status', () => { + expect(describeRedPacketStatus('0', undefined)).toBe('待领取') + expect(describeRedPacketStatus('1', undefined)).toBe('已领取') + expect(describeRedPacketStatus(undefined, '2')).toBe('已被领完') + expect(describeRedPacketStatus('已被领取', undefined)).toBe('已领取') + expect(describeRedPacketStatus(undefined, undefined)).toBeUndefined() + }) + + it('passes through unknown status values', () => { + expect(describeTransferStatus('99-custom')).toBe('99-custom') + expect(describeRedPacketStatus('99', '98')).toBe('99') + expect(describePaySubtype('3')).toBe('收款') + expect(describePaySubtype('9')).toBe('9') + }) +})