mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(chat): 支持位置消息与小程序卡片解析展示
- 新增位置消息解析,补充经纬度、地点名和地址字段 - 修复小程序分享 type 识别,避免嵌套 type 干扰 - 聊天页新增位置卡片展示,并补充小程序卡片样式 - 导出、搜索和会话预览同步支持位置消息 - 补充位置导出与小程序解析测试
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772793179663" class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2488" xmlns:xlink="http://www.w3.org/1999/xlink" width="200.1953125" height="200"><path d="M740.672 37.504c156.352 0 283.52 115.584 283.52 258.496 0 44.416-13.056 87.872-36.608 127.04-35.648 57.216-92.672 99.584-161.664 119.744a161.408 161.408 0 0 1-45.184 7.36 52.8 52.8 0 0 1-53.76-52.928c0-29.76 23.68-52.864 53.76-52.864 2.112 0 6.528 0 11.904-2.048 46.336-12.8 82.944-39.168 103.424-74.24 13.952-22.144 20.48-46.72 20.48-72.064 0-83.84-78.72-152.512-174.72-152.512a197.76 197.76 0 0 0-94.72 24.32c-50.816 28.544-80.896 76.16-80.896 128.192v443.904c0 89.984-50.752 172.672-134.848 219.328-45.184 25.408-96 38.272-147.712 38.272-156.288 0-283.52-115.648-283.52-258.56 0-44.352 13.12-87.872 36.608-127.04 35.648-57.216 92.736-99.584 161.664-119.68 19.328-5.312 32.384-7.36 45.184-7.36 30.272 0 53.824 23.36 53.824 52.864a52.8 52.8 0 0 1-53.76 52.928c-2.176 0-6.592 0-11.904 2.048-46.4 13.76-82.944 40.32-103.424 74.176-14.016 22.208-20.48 46.72-20.48 72.128 0 83.84 78.72 152.448 175.616 152.448a197.76 197.76 0 0 0 94.784-24.256c50.752-28.608 80.832-76.224 80.832-128.192V296.192c0-89.984 50.752-172.608 134.848-219.328a283.52 283.52 0 0 1 146.752-39.36z" fill="#6467f0" p-id="2489"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div
|
||||
class="wechat-location-card-wrap"
|
||||
:class="isSent ? 'wechat-location-card-wrap--sent' : 'wechat-location-card-wrap--received'"
|
||||
>
|
||||
<div
|
||||
class="wechat-location-card"
|
||||
:class="{ 'wechat-location-card--sent': isSent }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openLocation"
|
||||
@keydown.enter.prevent="openLocation"
|
||||
@keydown.space.prevent="openLocation"
|
||||
>
|
||||
<div class="wechat-location-card__text">
|
||||
<div class="wechat-location-card__title">{{ primaryText }}</div>
|
||||
<div v-if="secondaryText" class="wechat-location-card__subtitle">{{ secondaryText }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wechat-location-card__map" :class="{ 'wechat-location-card__map--placeholder': !mapTileUrl }">
|
||||
<img
|
||||
v-if="mapTileUrl"
|
||||
:src="mapTileUrl"
|
||||
alt="地图预览"
|
||||
class="wechat-location-card__map-image"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
<div class="wechat-location-card__map-overlay"></div>
|
||||
<div class="wechat-location-card__pin" :style="markerStyle" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 22s7-5.82 7-12a7 7 0 1 0-14 0c0 6.18 7 12 7 12Z" fill="#22c55e" />
|
||||
<circle cx="12" cy="10" r="3.2" fill="#ffffff" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
message: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const TILE_SIZE = 256
|
||||
const MAP_ZOOM = 15
|
||||
|
||||
const cleanText = (value) => String(value || '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
const num = Number.parseFloat(String(value ?? '').trim())
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const latitude = computed(() => {
|
||||
const num = toFiniteNumber(props.message?.locationLat)
|
||||
return num != null && Math.abs(num) <= 90 ? num : null
|
||||
})
|
||||
|
||||
const longitude = computed(() => {
|
||||
const num = toFiniteNumber(props.message?.locationLng)
|
||||
return num != null && Math.abs(num) <= 180 ? num : null
|
||||
})
|
||||
|
||||
const primaryText = computed(() => {
|
||||
return cleanText(
|
||||
props.message?.locationPoiname
|
||||
|| props.message?.title
|
||||
|| props.message?.content
|
||||
|| '位置'
|
||||
) || '位置'
|
||||
})
|
||||
|
||||
const secondaryText = computed(() => {
|
||||
const label = cleanText(props.message?.locationLabel)
|
||||
return label && label !== primaryText.value ? label : ''
|
||||
})
|
||||
|
||||
const isSent = computed(() => !!props.message?.isSent)
|
||||
|
||||
const mapTileMeta = computed(() => {
|
||||
const lat = latitude.value
|
||||
const lng = longitude.value
|
||||
if (lat == null || lng == null) return null
|
||||
|
||||
const scale = Math.pow(2, MAP_ZOOM)
|
||||
const worldX = ((lng + 180) / 360) * scale * TILE_SIZE
|
||||
const latRad = (lat * Math.PI) / 180
|
||||
const worldY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * scale * TILE_SIZE
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
const offsetX = worldX - tileX * TILE_SIZE
|
||||
const offsetY = worldY - tileY * TILE_SIZE
|
||||
|
||||
return {
|
||||
tileX,
|
||||
tileY,
|
||||
left: `${(offsetX / TILE_SIZE) * 100}%`,
|
||||
top: `${(offsetY / TILE_SIZE) * 100}%`,
|
||||
}
|
||||
})
|
||||
|
||||
const mapTileUrl = computed(() => {
|
||||
const meta = mapTileMeta.value
|
||||
if (!meta) return ''
|
||||
return `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${meta.tileX}&y=${meta.tileY}&z=${MAP_ZOOM}`
|
||||
})
|
||||
|
||||
const markerStyle = computed(() => {
|
||||
const meta = mapTileMeta.value
|
||||
return {
|
||||
left: meta?.left || '50%',
|
||||
top: meta?.top || '50%',
|
||||
}
|
||||
})
|
||||
|
||||
const mapLink = computed(() => {
|
||||
const name = encodeURIComponent(primaryText.value || secondaryText.value || '位置')
|
||||
const lat = latitude.value
|
||||
const lng = longitude.value
|
||||
if (lat != null && lng != null) {
|
||||
return `https://uri.amap.com/marker?position=${lng},${lat}&name=${name}`
|
||||
}
|
||||
if (name) return `https://uri.amap.com/search?keyword=${name}`
|
||||
return ''
|
||||
})
|
||||
|
||||
const openLocation = () => {
|
||||
if (!process.client) return
|
||||
const href = mapLink.value
|
||||
if (!href) return
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wechat-location-card-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--received::before,
|
||||
.wechat-location-card-wrap--sent::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--received::before {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--sent::after {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.wechat-location-card {
|
||||
width: 208px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--message-radius);
|
||||
border: none;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.wechat-location-card--sent {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wechat-location-card__text {
|
||||
padding: 10px 12px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wechat-location-card--sent .wechat-location-card__text {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wechat-location-card__title {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wechat-location-card__subtitle {
|
||||
margin-top: 4px;
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wechat-location-card--sent .wechat-location-card__subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.wechat-location-card__map {
|
||||
position: relative;
|
||||
height: 98px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(0deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.3)),
|
||||
linear-gradient(135deg, #d7eef5 0%, #f6f8fb 45%, #ece7cf 100%);
|
||||
}
|
||||
|
||||
.wechat-location-card__map--placeholder::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(90deg, rgba(255,255,255,0.65) 0 8%, transparent 8% 34%, rgba(255,255,255,0.65) 34% 42%, transparent 42% 100%),
|
||||
linear-gradient(0deg, rgba(255,255,255,0.7) 0 10%, transparent 10% 38%, rgba(255,255,255,0.7) 38% 46%, transparent 46% 100%);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.wechat-location-card__map-image,
|
||||
.wechat-location-card__map-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.wechat-location-card__map-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wechat-location-card__map-overlay {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0) 38%, rgba(17,24,39,0.06) 100%);
|
||||
}
|
||||
|
||||
.wechat-location-card__pin {
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
transform: translate(-50%, -92%);
|
||||
filter: drop-shadow(0 4px 8px rgba(34, 197, 94, 0.28));
|
||||
}
|
||||
|
||||
.wechat-location-card__pin svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -83,6 +83,10 @@
|
||||
[语音]
|
||||
</div>
|
||||
|
||||
<div v-else-if="renderType === 'location'" class="max-w-sm">
|
||||
<ChatLocationCard :message="message" />
|
||||
</div>
|
||||
|
||||
<!-- 默认文本消息 -->
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -551,6 +551,7 @@
|
||||
:preview="message.preview"
|
||||
:fromAvatar="message.fromAvatar"
|
||||
:from="message.from"
|
||||
:linkType="message.linkType"
|
||||
:isSent="message.isSent"
|
||||
:variant="message.linkCardVariant || 'default'"
|
||||
/>
|
||||
@@ -814,6 +815,9 @@
|
||||
<span>微信红包</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'location'" class="max-w-sm">
|
||||
<ChatLocationCard :message="message" />
|
||||
</div>
|
||||
<!-- 文本消息 -->
|
||||
<div v-else-if="message.renderType === 'text'"
|
||||
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
@@ -1490,12 +1494,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, rec, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
|
||||
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
|
||||
</div>
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -1602,12 +1606,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, win, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
|
||||
<div v-if="win.content || win.preview" class="wechat-link-summary">
|
||||
<div v-if="win.content" class="wechat-link-desc">{{ win.content }}</div>
|
||||
</div>
|
||||
<div v-if="win.preview" class="wechat-link-thumb">
|
||||
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
|
||||
<div v-if="win.preview" class="wechat-link-thumb">
|
||||
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -1768,12 +1772,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, rec, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
|
||||
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
|
||||
</div>
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -2465,6 +2469,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
|
||||
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
|
||||
import zipIconUrl from '~/assets/images/wechat/zip.png'
|
||||
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
|
||||
import wordIconUrl from '~/assets/images/wechat/word.png'
|
||||
@@ -5953,7 +5958,7 @@ const loadSessionsForSelectedAccount = async () => {
|
||||
id: s.id,
|
||||
name: s.name || s.username || s.id,
|
||||
avatar: s.avatar || null,
|
||||
lastMessage: s.lastMessage || '',
|
||||
lastMessage: normalizeSessionPreview(s.lastMessage || ''),
|
||||
lastMessageTime: s.lastMessageTime || '',
|
||||
unreadCount: s.unreadCount || 0,
|
||||
isGroup: !!s.isGroup,
|
||||
@@ -6039,7 +6044,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
|
||||
id: s.id,
|
||||
name: s.name || s.username || s.id,
|
||||
avatar: s.avatar || null,
|
||||
lastMessage: s.lastMessage || '',
|
||||
lastMessage: normalizeSessionPreview(s.lastMessage || ''),
|
||||
lastMessageTime: s.lastMessageTime || '',
|
||||
unreadCount: s.unreadCount || 0,
|
||||
isGroup: !!s.isGroup,
|
||||
@@ -6308,6 +6313,10 @@ const normalizeMessage = (msg) => {
|
||||
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
|
||||
voiceUrl: normalizedVoiceUrl || '',
|
||||
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
|
||||
locationLat: msg.locationLat ?? null,
|
||||
locationLng: msg.locationLng ?? null,
|
||||
locationPoiname: String(msg.locationPoiname || '').trim(),
|
||||
locationLabel: String(msg.locationLabel || '').trim(),
|
||||
preview: normalizedLinkPreviewUrl || '',
|
||||
linkType: String(msg.linkType || '').trim(),
|
||||
linkStyle: String(msg.linkStyle || '').trim(),
|
||||
@@ -6409,6 +6418,14 @@ const closeTopFloatingWindow = () => {
|
||||
if (top?.id) closeFloatingWindow(top.id)
|
||||
}
|
||||
|
||||
const normalizeSessionPreview = (value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]')
|
||||
if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]')
|
||||
return text
|
||||
}
|
||||
|
||||
const openFloatingWindow = (payload) => {
|
||||
if (!process.client) return null
|
||||
const w0 = Number(payload?.width || 0) > 0 ? Number(payload.width) : 560
|
||||
@@ -7843,13 +7860,15 @@ const onMessageScroll = async () => {
|
||||
const LinkCard = defineComponent({
|
||||
name: 'LinkCard',
|
||||
props: {
|
||||
href: { type: String, required: true },
|
||||
href: { type: String, default: '' },
|
||||
heading: { type: String, default: '' },
|
||||
abstract: { type: String, default: '' },
|
||||
preview: { type: String, default: '' },
|
||||
fromAvatar: { type: String, default: '' },
|
||||
from: { type: String, default: '' },
|
||||
linkType: { type: String, default: '' },
|
||||
isSent: { type: Boolean, default: false },
|
||||
badge: { type: String, default: '' },
|
||||
variant: { type: String, default: 'default' }
|
||||
},
|
||||
setup(props) {
|
||||
@@ -7863,7 +7882,9 @@ const LinkCard = defineComponent({
|
||||
// Fallback: when the appmsg XML doesn't provide sourcedisplayname/appname,
|
||||
// show the host so the footer row still matches WeChat's fixed card layout.
|
||||
try {
|
||||
const host = new URL(String(props.href || '')).hostname
|
||||
const href = String(props.href || '').trim()
|
||||
if (!/^https?:\/\//i.test(href)) return ''
|
||||
const host = new URL(href).hostname
|
||||
return String(host || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
@@ -7872,6 +7893,9 @@ const LinkCard = defineComponent({
|
||||
|
||||
return () => {
|
||||
const fromText = getFromText()
|
||||
const href = String(props.href || '').trim()
|
||||
const canNavigate = /^https?:\/\//i.test(href)
|
||||
const badgeText = String(props.badge || '').trim()
|
||||
// WeChat link cards show a small avatar next to the source text. We don't
|
||||
// always have a real image URL, so fall back to the first glyph.
|
||||
const fromAvatarText = (() => {
|
||||
@@ -7879,7 +7903,9 @@ const LinkCard = defineComponent({
|
||||
return t ? (Array.from(t)[0] || '') : ''
|
||||
})()
|
||||
const fromAvatarUrl = String(props.fromAvatar || '').trim()
|
||||
const isCoverVariant = String(props.variant || '').trim() === 'cover'
|
||||
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
|
||||
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
|
||||
const Tag = canNavigate ? 'a' : 'div'
|
||||
|
||||
// Props may change when switching accounts/chats; reset load state per URL.
|
||||
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
|
||||
@@ -7896,6 +7922,12 @@ const LinkCard = defineComponent({
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const miniProgramAvatarStyle = fromAvatarImgOk.value
|
||||
? {
|
||||
background: '#fff',
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const onFromAvatarLoad = () => {
|
||||
fromAvatarImgOk.value = true
|
||||
fromAvatarImgError.value = false
|
||||
@@ -7918,17 +7950,17 @@ const LinkCard = defineComponent({
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-cover-from-name' }, fromText || '\u200B')
|
||||
])
|
||||
h('div', { class: 'wechat-link-cover-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-cover-badge' }, badgeText) : null,
|
||||
].filter(Boolean))
|
||||
|
||||
return h(
|
||||
'a',
|
||||
Tag,
|
||||
{
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card-cover',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
@@ -7958,19 +7990,91 @@ const LinkCard = defineComponent({
|
||||
}),
|
||||
fromRow,
|
||||
]) : fromRow,
|
||||
h('div', { class: 'wechat-link-cover-title' }, props.heading || props.href)
|
||||
h('div', { class: 'wechat-link-cover-title' }, props.heading || href)
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
const headingText = String(props.heading || href || '').trim()
|
||||
let abstractText = String(props.abstract || '').trim()
|
||||
if (abstractText && headingText && abstractText === headingText) abstractText = ''
|
||||
|
||||
if (isMiniProgram) {
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
'wechat-link-card--mini-program',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '210px',
|
||||
minWidth: '210px',
|
||||
maxWidth: '210px',
|
||||
maxHeight: '270px',
|
||||
height: '270px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-mini-body' }, [
|
||||
h('div', { class: 'wechat-link-mini-header' }, [
|
||||
h('div', { class: 'wechat-link-mini-header-avatar', style: miniProgramAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg ? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-header-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-mini-header-name' }, fromText || '\u200B')
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-title' }, headingText || abstractText || href),
|
||||
h('div', { class: ['wechat-link-mini-preview', !props.preview ? 'wechat-link-mini-preview--empty' : ''].filter(Boolean).join(' ') }, [
|
||||
props.preview ? h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '小程序预览',
|
||||
class: 'wechat-link-mini-preview-img',
|
||||
referrerpolicy: 'no-referrer'
|
||||
}) : null
|
||||
].filter(Boolean))
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-footer' }, [
|
||||
h('img', {
|
||||
src: miniProgramIconUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-footer-icon',
|
||||
'aria-hidden': 'true'
|
||||
}),
|
||||
h('span', { class: 'wechat-link-mini-footer-text' }, '小程序')
|
||||
])
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
'a',
|
||||
Tag,
|
||||
{
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
@@ -7995,13 +8099,15 @@ const LinkCard = defineComponent({
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-content' }, [
|
||||
h('div', { class: 'wechat-link-info' }, [
|
||||
h('div', { class: 'wechat-link-title' }, props.heading || props.href),
|
||||
props.abstract ? h('div', { class: 'wechat-link-desc' }, props.abstract) : null
|
||||
].filter(Boolean)),
|
||||
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
|
||||
]) : null
|
||||
h('div', { class: 'wechat-link-title' }, headingText || href),
|
||||
(abstractText || props.preview)
|
||||
? h('div', { class: 'wechat-link-summary' }, [
|
||||
abstractText ? h('div', { class: 'wechat-link-desc' }, abstractText) : null,
|
||||
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
|
||||
]) : null
|
||||
].filter(Boolean))
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from' }, [
|
||||
h('div', { class: 'wechat-link-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
@@ -8015,8 +8121,9 @@ const LinkCard = defineComponent({
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
|
||||
])
|
||||
h('div', { class: 'wechat-link-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-badge' }, badgeText) : null
|
||||
].filter(Boolean))
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
@@ -8026,6 +8133,35 @@ const LinkCard = defineComponent({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* LinkCard:小程序标记与无 URL 降级 */
|
||||
::deep(.wechat-link-badge) {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
font-size: 11px;
|
||||
color: #b2b2b2;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-cover-badge) {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(243, 243, 243, 0.92);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-card.wechat-link-card--disabled),
|
||||
::deep(.wechat-link-card-cover.wechat-link-card--disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-card.wechat-link-card--disabled:hover),
|
||||
::deep(.wechat-link-card-cover.wechat-link-card--disabled:hover) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -8775,21 +8911,18 @@ const LinkCard = defineComponent({
|
||||
|
||||
:deep(.wechat-link-content) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
/* Keep a small breathing room above the footer divider. */
|
||||
padding: 8px 10px 6px;
|
||||
padding: 10px 10px 8px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-info) {
|
||||
:deep(.wechat-link-summary) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-title) {
|
||||
@@ -8806,24 +8939,24 @@ const LinkCard = defineComponent({
|
||||
:deep(.wechat-link-desc) {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb) {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
/* Center the thumbnail in the content area (WeChat desktop style). */
|
||||
align-self: center;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb-img) {
|
||||
@@ -8833,6 +8966,127 @@ const LinkCard = defineComponent({
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-card--mini-program) {
|
||||
max-height: 270px;
|
||||
height: 270px;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-avatar) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #14c15f;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-avatar-img) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-name) {
|
||||
font-size: 13px;
|
||||
color: #7d7d7d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-title) {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #1a1a1a;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview--empty) {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview-img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer) {
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer-icon) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer-text) {
|
||||
font-size: 10px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from) {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
@@ -9057,3 +9311,4 @@ const LinkCard = defineComponent({
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ from .chat_helpers import (
|
||||
_load_latest_message_previews,
|
||||
_lookup_resource_md5,
|
||||
_parse_app_message,
|
||||
_parse_location_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_pick_display_name,
|
||||
@@ -3378,6 +3379,10 @@ def _parse_message_for_export(
|
||||
file_md5 = ""
|
||||
transfer_id = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -3437,6 +3442,14 @@ def _parse_message_for_export(
|
||||
quote_voice_length = str(parsed.get("quoteVoiceLength") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "location")
|
||||
content_text = str(parsed.get("content") or "[Location]")
|
||||
location_lat = parsed.get("locationLat")
|
||||
location_lng = parsed.get("locationLng")
|
||||
location_poiname = str(parsed.get("locationPoiname") or "")
|
||||
location_label = str(parsed.get("locationLabel") or "")
|
||||
elif local_type == 3:
|
||||
render_type = "image"
|
||||
def add_md5(v: Any) -> None:
|
||||
@@ -3708,6 +3721,10 @@ def _parse_message_for_export(
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"voipType": voip_type,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -712,6 +712,68 @@ def _extract_xml_tag_or_attr(xml_text: str, name: str) -> str:
|
||||
return _extract_xml_attr(xml_text, name)
|
||||
|
||||
|
||||
def _parse_location_message(text: str) -> dict[str, Any]:
|
||||
raw = html.unescape(str(text or "").strip())
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
candidate = _strip_cdata(str(value or "").strip())
|
||||
if not candidate:
|
||||
return ""
|
||||
candidate = html.unescape(candidate)
|
||||
candidate = re.sub(r"\s+", " ", candidate).strip()
|
||||
return candidate
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
s = str(value or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
num = float(s)
|
||||
except Exception:
|
||||
return None
|
||||
if not (-180.0 <= num <= 180.0):
|
||||
return None
|
||||
return num
|
||||
|
||||
poiname = _clean(
|
||||
_extract_xml_tag_or_attr(raw, "poiname")
|
||||
or _extract_xml_tag_or_attr(raw, "poiName")
|
||||
or _extract_xml_tag_or_attr(raw, "name")
|
||||
)
|
||||
label = _clean(
|
||||
_extract_xml_tag_or_attr(raw, "label")
|
||||
or _extract_xml_tag_or_attr(raw, "labelname")
|
||||
or _extract_xml_tag_or_attr(raw, "address")
|
||||
)
|
||||
|
||||
lat = _to_float(
|
||||
_extract_xml_tag_or_attr(raw, "x")
|
||||
or _extract_xml_tag_or_attr(raw, "latitude")
|
||||
or _extract_xml_tag_or_attr(raw, "lat")
|
||||
)
|
||||
lng = _to_float(
|
||||
_extract_xml_tag_or_attr(raw, "y")
|
||||
or _extract_xml_tag_or_attr(raw, "longitude")
|
||||
or _extract_xml_tag_or_attr(raw, "lng")
|
||||
or _extract_xml_tag_or_attr(raw, "lon")
|
||||
)
|
||||
|
||||
if lat is not None and not (-90.0 <= lat <= 90.0):
|
||||
lat = None
|
||||
if lng is not None and not (-180.0 <= lng <= 180.0):
|
||||
lng = None
|
||||
|
||||
title = poiname or label or "位置"
|
||||
return {
|
||||
"renderType": "location",
|
||||
"content": title or "[Location]",
|
||||
"locationLat": lat,
|
||||
"locationLng": lng,
|
||||
"locationPoiname": poiname,
|
||||
"locationLabel": label,
|
||||
}
|
||||
|
||||
|
||||
def _parse_system_message_content(raw_text: str) -> str:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
@@ -941,11 +1003,40 @@ def _parse_quote_message(text: str) -> str:
|
||||
|
||||
|
||||
def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
app_type_raw = _extract_xml_tag_text(text, "type")
|
||||
try:
|
||||
app_type = int(str(app_type_raw or "0").strip() or "0")
|
||||
except Exception:
|
||||
app_type = 0
|
||||
def _extract_appmsg_type(xml_text: str) -> int:
|
||||
"""提取 <appmsg> 直系子节点的 <type>,避免被 refermsg/recorditem/weappinfo 等嵌套块里的 <type> 干扰。"""
|
||||
|
||||
probe = str(xml_text or "")
|
||||
try:
|
||||
m = re.search(r"<appmsg\b[^>]*>(.*?)</appmsg>", probe, flags=re.IGNORECASE | re.DOTALL)
|
||||
except Exception:
|
||||
m = None
|
||||
|
||||
if m:
|
||||
inner = str(m.group(1) or "")
|
||||
# 一些嵌套块内部也会出现 <type>,先剔除再提取。
|
||||
try:
|
||||
inner = re.sub(r"(<refermsg\b[^>]*>.*?</refermsg>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
inner = re.sub(r"(<patmsg\b[^>]*>.*?</patmsg>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
inner = re.sub(r"(<recorditem\b[^>]*>.*?</recorditem>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
inner = re.sub(r"(<weappinfo\b[^>]*>.*?</weappinfo>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
inner = re.sub(r"(<wxaappinfo\b[^>]*>.*?</wxaappinfo>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = _extract_xml_tag_text(inner, "type")
|
||||
try:
|
||||
return int(str(t or "0").strip() or "0")
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
t = _extract_xml_tag_text(probe, "type")
|
||||
try:
|
||||
return int(str(t or "0").strip() or "0")
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
app_type = _extract_appmsg_type(text)
|
||||
title = _extract_xml_tag_text(text, "title")
|
||||
des = _extract_xml_tag_text(text, "des")
|
||||
url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
|
||||
@@ -1006,6 +1097,49 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"linkStyle": link_style,
|
||||
}
|
||||
|
||||
if app_type in (33, 36):
|
||||
# 小程序分享(WeChat v4 常见:local_type = 49 + (33<<32) / 49 + (36<<32))
|
||||
# 注:部分 payload 的 <url> 为空;前端会按需渲染为不可点击卡片。
|
||||
weapp_block = _extract_xml_tag_text(text, "weappinfo") or _extract_xml_tag_text(text, "wxaappinfo")
|
||||
weapp_username = _extract_xml_tag_text(weapp_block, "username") if weapp_block else ""
|
||||
weapp_icon = _normalize_xml_url(
|
||||
_extract_xml_tag_or_attr(weapp_block, "weappiconurl") if weapp_block else ""
|
||||
) or _normalize_xml_url(_extract_xml_tag_or_attr(text, "weappiconurl"))
|
||||
|
||||
thumb_url = _normalize_xml_url(
|
||||
_extract_xml_tag_or_attr(text, "thumburl")
|
||||
or _extract_xml_tag_or_attr(text, "cdnthumburl")
|
||||
or _extract_xml_tag_or_attr(text, "coverurl")
|
||||
or _extract_xml_tag_or_attr(text, "cover")
|
||||
or weapp_icon
|
||||
)
|
||||
|
||||
from_display = str(source_display_name or "").strip()
|
||||
if not from_display and weapp_block:
|
||||
from_display = (
|
||||
_extract_xml_tag_text(weapp_block, "nickname")
|
||||
or _extract_xml_tag_text(weapp_block, "appname")
|
||||
or ""
|
||||
)
|
||||
if not from_display:
|
||||
from_display = str(_extract_xml_tag_text(text, "sourcename") or "").strip()
|
||||
|
||||
from_u = str(weapp_username or source_username or "").strip()
|
||||
|
||||
content_text = (des or title or "[Mini Program]").strip() or "[Mini Program]"
|
||||
title_text = (title or des or "").strip()
|
||||
return {
|
||||
"renderType": "link",
|
||||
"content": content_text,
|
||||
"title": title_text or content_text,
|
||||
"url": url or "",
|
||||
"thumbUrl": thumb_url or "",
|
||||
"from": from_display,
|
||||
"fromUsername": from_u,
|
||||
"linkType": "mini_program",
|
||||
"linkStyle": "default",
|
||||
}
|
||||
|
||||
if app_type in (6, 74):
|
||||
file_name = title or ""
|
||||
total_len = _extract_xml_tag_text(text, "totallen")
|
||||
@@ -1303,6 +1437,14 @@ def _build_latest_message_preview(
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
content_text = "[动画表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
location_name = (
|
||||
str(parsed.get("locationPoiname") or "").strip()
|
||||
or str(parsed.get("locationLabel") or "").strip()
|
||||
or str(parsed.get("content") or "").strip()
|
||||
)
|
||||
content_text = f"[位置]{location_name}" if location_name else "[位置]"
|
||||
else:
|
||||
if raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
|
||||
content_text = raw_text
|
||||
@@ -1347,6 +1489,7 @@ def _normalize_session_preview_text(
|
||||
return ""
|
||||
|
||||
text = text.replace("[表情]", "[动画表情]")
|
||||
text = re.sub(r"\[location\]", "[位置]", text, flags=re.IGNORECASE)
|
||||
if (not is_group) or text.startswith("[草稿]"):
|
||||
return text
|
||||
|
||||
@@ -2021,6 +2164,10 @@ def _row_to_search_hit(
|
||||
pay_sub_type = ""
|
||||
transfer_status = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -2075,6 +2222,14 @@ def _row_to_search_hit(
|
||||
elif local_type == 47:
|
||||
render_type = "emoji"
|
||||
content_text = "[表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "location")
|
||||
content_text = str(parsed.get("content") or "[Location]")
|
||||
location_lat = parsed.get("locationLat")
|
||||
location_lng = parsed.get("locationLng")
|
||||
location_poiname = str(parsed.get("locationPoiname") or "")
|
||||
location_label = str(parsed.get("locationLabel") or "")
|
||||
elif local_type == 50:
|
||||
render_type = "voip"
|
||||
try:
|
||||
@@ -2162,4 +2317,8 @@ def _row_to_search_hit(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"voipType": voip_type,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ from ..chat_helpers import (
|
||||
_lookup_resource_md5,
|
||||
_normalize_xml_url,
|
||||
_parse_app_message,
|
||||
_parse_location_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_pick_display_name,
|
||||
@@ -2673,6 +2674,10 @@ def _append_full_messages_from_rows(
|
||||
file_md5 = ""
|
||||
transfer_id = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -2883,6 +2888,14 @@ def _append_full_messages_from_rows(
|
||||
create_time=create_time,
|
||||
)
|
||||
content_text = "[表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "location")
|
||||
content_text = str(parsed.get("content") or "[Location]")
|
||||
location_lat = parsed.get("locationLat")
|
||||
location_lng = parsed.get("locationLng")
|
||||
location_poiname = str(parsed.get("locationPoiname") or "")
|
||||
location_label = str(parsed.get("locationLabel") or "")
|
||||
elif local_type == 50:
|
||||
render_type = "voip"
|
||||
try:
|
||||
@@ -2929,10 +2942,15 @@ def _append_full_messages_from_rows(
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
from_username = str(parsed.get("fromUsername") or from_username)
|
||||
file_size = str(parsed.get("size") or file_size)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
||||
transfer_id = str(parsed.get("transferId") or transfer_id)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
|
||||
quote_type = str(parsed.get("quoteType") or quote_type)
|
||||
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
|
||||
|
||||
if render_type == "transfer":
|
||||
# 如果 transferId 仍为空,尝试从原始 XML 提取
|
||||
@@ -3009,6 +3027,10 @@ def _append_full_messages_from_rows(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
}
|
||||
)
|
||||
@@ -3734,8 +3756,19 @@ def list_chat_sessions(
|
||||
except Exception:
|
||||
last_previews = {}
|
||||
|
||||
def _is_generic_location_preview(value: Any) -> bool:
|
||||
text = re.sub(r"\s+", " ", str(value or "").strip()).strip()
|
||||
if not text:
|
||||
return False
|
||||
lowered = text.lower()
|
||||
return lowered in {"[location]", "[位置]"} or lowered.endswith(": [location]") or lowered.endswith(": [位置]")
|
||||
|
||||
if preview_mode in {"latest", "db"}:
|
||||
targets = usernames if preview_mode == "db" else [u for u in usernames if u and (u not in last_previews)]
|
||||
targets = (
|
||||
usernames
|
||||
if preview_mode == "db"
|
||||
else [u for u in usernames if u and ((u not in last_previews) or _is_generic_location_preview(last_previews.get(u)))]
|
||||
)
|
||||
if targets:
|
||||
legacy = _load_latest_message_previews(account_dir, targets)
|
||||
for u, v in legacy.items():
|
||||
@@ -3830,6 +3863,11 @@ def list_chat_sessions(
|
||||
last_msg_sub_type = 0
|
||||
if last_msg_type == 81604378673 or (last_msg_type == 49 and last_msg_sub_type == 19):
|
||||
last_message = "[聊天记录]"
|
||||
elif last_msg_type == 48:
|
||||
text = re.sub(r"\s+", " ", str(last_message or "").strip()).strip()
|
||||
text = re.sub(r"^\[location\]", "", text, flags=re.IGNORECASE).strip()
|
||||
text = re.sub(r"^\[位置\]", "", text).strip()
|
||||
last_message = f"[位置]{text}" if text else "[位置]"
|
||||
|
||||
last_message = _normalize_session_preview_text(
|
||||
last_message,
|
||||
@@ -4065,6 +4103,10 @@ def _collect_chat_messages(
|
||||
file_md5 = ""
|
||||
transfer_id = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -4251,6 +4293,14 @@ def _collect_chat_messages(
|
||||
create_time=create_time,
|
||||
)
|
||||
content_text = "[表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "location")
|
||||
content_text = str(parsed.get("content") or "[Location]")
|
||||
location_lat = parsed.get("locationLat")
|
||||
location_lng = parsed.get("locationLng")
|
||||
location_poiname = str(parsed.get("locationPoiname") or "")
|
||||
location_label = str(parsed.get("locationLabel") or "")
|
||||
elif local_type == 50:
|
||||
render_type = "voip"
|
||||
try:
|
||||
@@ -4289,6 +4339,7 @@ def _collect_chat_messages(
|
||||
title = str(parsed.get("title") or title)
|
||||
url = str(parsed.get("url") or url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
from_username = str(parsed.get("fromUsername") or from_username)
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
@@ -4302,6 +4353,10 @@ def _collect_chat_messages(
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
||||
transfer_id = str(parsed.get("transferId") or transfer_id)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
|
||||
quote_type = str(parsed.get("quoteType") or quote_type)
|
||||
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
|
||||
|
||||
if render_type == "transfer":
|
||||
# 如果 transferId 仍为空,尝试从原始 XML 提取
|
||||
@@ -4385,6 +4440,10 @@ def _collect_chat_messages(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -132,6 +132,16 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
|
||||
None,
|
||||
),
|
||||
(
|
||||
7,
|
||||
1007,
|
||||
48,
|
||||
7,
|
||||
2,
|
||||
1735689607,
|
||||
'<msg><location x="39.9042" y="116.4074" scale="15" label="北京市东城区东华门街道" poiname="天安门" /></msg>',
|
||||
None,
|
||||
),
|
||||
]
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -357,6 +367,41 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_checked_location_exports_location_fields(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["location"],
|
||||
include_media=False,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, _ = self._load_export_payload(job.zip_path)
|
||||
location_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 48), None)
|
||||
self.assertIsNotNone(location_msg)
|
||||
self.assertEqual(str(location_msg.get("renderType") or ""), "location")
|
||||
self.assertEqual(str(location_msg.get("locationPoiname") or ""), "天安门")
|
||||
self.assertEqual(str(location_msg.get("locationLabel") or ""), "北京市东城区东华门街道")
|
||||
self.assertAlmostEqual(float(location_msg.get("locationLat") or 0), 39.9042, places=4)
|
||||
self.assertAlmostEqual(float(location_msg.get("locationLng") or 0), 116.4074, places=4)
|
||||
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["location"])
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_privacy_mode_never_exports_media(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
@@ -10,6 +10,34 @@ from wechat_decrypt_tool.chat_helpers import _parse_app_message
|
||||
|
||||
|
||||
class TestParseAppMessage(unittest.TestCase):
|
||||
def test_mini_program_type_33_parses_as_link(self):
|
||||
# 小程序分享是 appmsg type=33/36。部分 payload 会在 <weappinfo> 内嵌一个 <type>0</type>,
|
||||
# 并且出现在外层 <type>33</type> 之前,因此解析必须避免被嵌套 <type> 误导。
|
||||
raw_text = (
|
||||
"<msg><appmsg appid='' sdkver='0'>"
|
||||
"<title>锦城苑房源详情分享给你,点击查看哦~</title>"
|
||||
"<des></des>"
|
||||
"<weappinfo>"
|
||||
"<type>0</type>"
|
||||
"<username><![CDATA[gh_xxx@app]]></username>"
|
||||
"<weappiconurl><![CDATA[https://example.com/icon.png]]></weappiconurl>"
|
||||
"</weappinfo>"
|
||||
"<type>33</type>"
|
||||
"<url></url>"
|
||||
"<thumburl>https://example.com/thumb.jpg</thumburl>"
|
||||
"<sourcedisplayname><![CDATA[成都购房通]]></sourcedisplayname>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "link")
|
||||
self.assertEqual(parsed.get("linkType"), "mini_program")
|
||||
self.assertEqual(parsed.get("title"), "锦城苑房源详情分享给你,点击查看哦~")
|
||||
self.assertEqual(parsed.get("from"), "成都购房通")
|
||||
self.assertEqual(parsed.get("fromUsername"), "gh_xxx@app")
|
||||
self.assertEqual(parsed.get("thumbUrl"), "https://example.com/thumb.jpg")
|
||||
|
||||
def test_quote_type_57_nested_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
|
||||
Reference in New Issue
Block a user