feat: map transfer/hb status labels into export and chat cards

This commit is contained in:
wuyouMaster
2026-09-24 19:01:15 +08:00
parent 95b3ba6121
commit 88ee3f19bb
10 changed files with 555 additions and 12 deletions
+28 -4
View File
@@ -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 '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
'<div class="structured-title">' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '</div>' +
'<div class="structured-description">' +
displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) +
'</div></div>'
'<div class="structured-title">' + displayText(title) + '</div>' +
'<div class="structured-description">' + displayText(description) + '</div>' +
(footerBits.filter(Boolean).length
? '<div class="structured-footer">' + displayText(footerBits.filter(Boolean).join(' · ')) + '</div>'
: '') +
'</div>'
}
const renderVoipContent = (data) => {
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
+103 -5
View File
@@ -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
}
}
/** 只读解析 `<wcpayinfo>`(转账 / 红包展示字段;不做支付)。 */
function parseWcpayInfo(content: string): TransferPaymentInfo & RedPacketPaymentInfo {
const block = /<wcpayinfo>([\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]
}
}
+39 -1
View File
@@ -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 }
| {
@@ -158,7 +158,22 @@ function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }
}
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): 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 (
<div className="transfer-message">
<div className="transfer-kicker">微信转账</div>
<div className="transfer-title">{title || '微信转账'}</div>
<div className="transfer-meta">{meta || des || '转账消息'}</div>
{pay.transferId ? <div className="transfer-footer">单号 {pay.transferId}</div> : null}
</div>
)
}
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 (
<div
@@ -266,7 +283,7 @@ function RedPacketBubble({
</span>
<span className="red-packet-copy">
<strong>{data.title}</strong>
<small>{data.description || '恭喜发财,大吉大利'}</small>
<small>{statusText ? `${blurb} · ${statusText}` : blurb}</small>
</span>
</div>
<div className="red-packet-footer">微信红包</div>
+37
View File
@@ -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;
+107
View File
@@ -0,0 +1,107 @@
/**
* 转账 / 红包状态文案(只读展示)。
* 文案对齐微信 UI 簇(待收款/已收款/…、待领取/已领取/…)。
* 数字枚举为推断对齐;未知值原样回退。
*/
const TRANSFER_STATUS_TEXT: Record<string, string> = {
'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<string, string> = {
'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<string, string>,
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<string, string> = {
'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
+39
View File
@@ -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 = {