Files
WeChatDataAnalysis/frontend/pages/sns.vue
T
2026-04-23 14:51:58 +08:00

2625 lines
106 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="sns-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
<!-- 左侧朋友圈联系人 -->
<div class="w-[280px] flex flex-col min-h-0 border-r border-gray-200 bg-[#EDEDED]" style="background-color: var(--app-shell-bg)">
<div class="p-3">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>
<div class="text-xs text-gray-500">{{ visibleSnsUsers.length }}</div>
</div>
<input
v-model="snsUserQuery"
type="text"
placeholder="搜索"
class="mt-2 w-full px-3 py-2 rounded-md border border-gray-200 bg-white text-sm outline-none focus:ring-2 focus:ring-[#576b95]/30 focus:border-[#576b95]"
/>
<div class="mt-3">
<button
type="button"
class="w-full px-3 py-2.5 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!selectedAccount"
@click="openExportModal"
>
导出朋友圈
</button>
</div>
</div>
<div class="flex-1 overflow-auto min-h-0 bg-white">
<div
class="px-3 py-2 text-sm cursor-pointer flex items-center gap-2 border-b border-gray-100 hover:bg-gray-50"
:class="selectedSnsUser ? 'text-gray-700' : 'bg-gray-50 text-gray-900 font-medium'"
@click="selectSnsUser('')"
>
<div class="w-8 h-8 rounded-md bg-gray-200 flex items-center justify-center text-xs text-gray-500 flex-shrink-0"></div>
<div class="flex-1 min-w-0 truncate">全部</div>
</div>
<div
v-for="u in filteredSnsUsers"
:key="u.username"
class="px-3 py-2 text-sm cursor-pointer flex items-center gap-2 border-b border-gray-100 hover:bg-gray-50"
:class="selectedSnsUser === u.username ? 'bg-gray-50 text-gray-900 font-medium' : 'text-gray-700'"
@click="selectSnsUser(u.username)"
>
<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img
v-if="postAvatarUrl(u.username) && !hasSnsAvatarError(u.username)"
:src="postAvatarUrl(u.username)"
:alt="u.displayName || u.username"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
@error="onSnsAvatarError(u.username)"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
style="background-color: #4B5563"
>
{{ (u.displayName || u.username || '友').charAt(0) }}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="truncate" :class="{ 'privacy-blur': privacyMode }">{{ u.displayName || u.username }}</div>
<div class="text-[11px] text-gray-400 truncate">
<span>{{ u.username }}</span>
<span> · </span>
<!-- `postCount` is computed from the decrypted sqlite snapshot (cache). The timeline API may only return
the visible subset (e.g. privacy setting: "only last 3 days"), so show loaded/cache for the selected user. -->
<template v-if="selectedSnsUser === u.username">
<span>{{ posts.length }}</span>
<span v-if="u.postCount != null">/{{ u.postCount || 0 }}</span>
<span> </span>
</template>
<template v-else>
<span>{{ u.postCount || 0 }} </span>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧朋友圈区域 -->
<div class="flex-1 flex flex-col min-h-0" style="background-color: var(--app-shell-bg)">
<div ref="timelineScrollEl" class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
<div class="max-w-2xl mx-auto px-4 py-4">
<div class="relative w-full mb-12 -mt-4 bg-white">
<div class="h-64 w-full bg-[#333333] relative overflow-hidden group">
<img
v-if="activeCover && activeCover.media && activeCover.media.length > 0"
:src="getSnsMediaUrl(activeCover, activeCover.media[0], 0, activeCover.media[0].url)"
class="w-full h-full object-cover"
alt="朋友圈封面"
/>
<div
v-if="(activeCover && Number(activeCover.createTime || 0)) || (covers && covers.length > 1)"
class="absolute top-3 right-3 z-10 text-[11px] text-white bg-black/40 backdrop-blur-sm px-2 py-1 rounded pointer-events-none"
>
<span v-if="activeCover && Number(activeCover.createTime || 0)">{{ formatCoverTime(activeCover.createTime) }}</span>
<span v-if="covers && covers.length > 1">
<span v-if="activeCover && Number(activeCover.createTime || 0)">&nbsp;·&nbsp;</span>{{ coverIndex + 1 }}/{{ covers.length }}
</span>
</div>
<button
v-if="covers && covers.length > 1"
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 z-10 text-white/90 hover:text-white p-2 rounded-full bg-black/25 hover:bg-black/40 transition-colors"
title="上一张封面"
@click.stop="prevCover"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
v-if="covers && covers.length > 1"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 z-10 text-white/90 hover:text-white p-2 rounded-full bg-black/25 hover:bg-black/40 transition-colors"
title="下一张封面"
@click.stop="nextCover"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<div class="absolute right-4 -bottom-6 flex items-end gap-4">
<div class="text-white font-bold text-xl mb-7 drop-shadow-md">
{{ selfInfo.nickname || '获取中...' }}
</div>
<div class="w-[72px] h-[72px] rounded-lg bg-white p-[2px] shadow-sm">
<img
v-if="selfInfo.wxid"
:src="postAvatarUrl(selfInfo.wxid)"
class="w-full h-full rounded-md object-cover bg-gray-100"
:alt="selfInfo.nickname"
referrerpolicy="no-referrer"
/>
<div v-else class="w-full h-full rounded-md bg-gray-300 flex items-center justify-center text-gray-500 text-xs">
...
</div>
</div>
</div>
</div>
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-4 text-center">{{ error }}</div>
<div v-else-if="isLoading && posts.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="w-8 h-8 border-[3px] border-gray-200 border-t-[#576b95] rounded-full animate-spin"></div>
<div class="mt-4 text-sm text-gray-400">正在前往朋友圈...</div>
</div>
<div v-else-if="posts.length === 0" class="text-sm text-gray-400 py-16 text-center">暂无朋友圈数据</div>
<div v-if="!error && posts.length > 0" class="text-[11px] text-gray-500 mb-2 flex flex-wrap gap-x-3 gap-y-1">
<span v-if="selectedSnsUserInfo">缓存统计{{ selectedSnsUserInfo.postCount || 0 }}</span>
<span v-if="!hasMore && !isLoading">已到末尾</span>
</div>
<div v-if="showSnsCountMismatchHint" class="text-[11px] text-amber-700 mb-3">
提示左侧缓存统计来自解密后的 sns.db当前 timeline 接口只返回可见部分所以会出现
<span class="font-medium">{{ posts.length }}/{{ selectedSnsUserInfo?.postCount || 0 }}</span>
</div>
<div v-for="post in posts" :key="post.id" class="bg-white rounded-sm px-4 py-4 mb-3">
<div class="flex items-start gap-3" @contextmenu.prevent="openPostContextMenu($event, post)">
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img
v-if="postAvatarUrl(post.username)"
:src="postAvatarUrl(post.username)"
:alt="post.displayName || post.username"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
style="background-color: #4B5563"
>
{{ (post.displayName || post.username || '友').charAt(0) }}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-5 text-[#576b95]" :class="{ 'privacy-blur': privacyMode }">
{{ post.displayName || post.username }}
</div>
<div
v-if="post.contentDesc"
class="mt-1 text-sm text-gray-900 leading-6 whitespace-pre-wrap break-words"
:class="{ 'privacy-blur': privacyMode }"
>
<span v-for="(seg, idx) in parseTextWithEmoji(String(post.contentDesc || ''))" :key="idx">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
</span>
</div>
<div v-if="post.type === 3" class="mt-2 w-full" :class="{ 'privacy-blur': privacyMode }">
<a :href="post.contentUrl" target="_blank" class="block w-full bg-[#F7F7F7] p-2 rounded-sm no-underline hover:bg-[#EFEFEF] transition-colors">
<div class="flex items-center gap-3">
<img
v-if="getArticleCardThumbSrc(post)"
:src="getArticleCardThumbSrc(post)"
class="w-12 h-12 object-cover flex-shrink-0 bg-white"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onArticleThumbError(post)"
/>
<div v-else class="w-12 h-12 flex items-center justify-center bg-gray-200 text-gray-400 flex-shrink-0 text-xs">
文章
</div>
<div class="flex-1 min-w-0 flex items-center overflow-hidden h-12">
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{{ post.title }}</div>
</div>
</div>
</a>
</div>
<div v-else-if="post.type === 28 && post.finderFeed && Object.keys(post.finderFeed).length > 0" class="mt-2 w-full max-w-[304px]" :class="{ 'privacy-blur': privacyMode }">
<!-- 浏览器没有看微信视频号的环境暂时不进行跳转 -->
<div class="relative w-full overflow-hidden rounded-sm bg-[#F7F7F7]">
<img
v-if="getFinderFeedThumbSrc(post)"
:src="getFinderFeedThumbSrc(post)"
class="block w-full aspect-square object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
/>
<div v-else class="w-full aspect-square flex items-center justify-center bg-gray-200">
<span class="line-clamp-3 px-4 text-center text-[13px] leading-5 text-gray-500">{{ formatFinderFeedCardText(post) }}</span>
</div>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
</div>
</div>
<div v-else-if="isExternalShareMoment(post)" class="mt-2 w-full" :class="{ 'privacy-blur': privacyMode }">
<a
v-if="getMomentLinkCardUrl(post)"
:href="getMomentLinkCardUrl(post)"
target="_blank"
rel="noopener noreferrer"
class="block w-full bg-[#F7F7F7] p-2 rounded-sm no-underline hover:bg-[#EFEFEF] transition-colors"
>
<div class="flex items-center gap-3">
<img
v-if="getExternalShareCardThumbSrc(post)"
:src="getExternalShareCardThumbSrc(post)"
class="w-12 h-12 object-cover flex-shrink-0 bg-white"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onExternalShareCardThumbError(post)"
/>
<div v-else class="w-12 h-12 flex items-center justify-center bg-gray-200 text-gray-400 flex-shrink-0 text-xs">
{{ formatExternalSharePlaceholder(post) }}
</div>
<div class="flex-1 min-w-0 flex items-center overflow-hidden h-12">
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{{ formatExternalShareCardTitle(post) }}</div>
</div>
</div>
</a>
<div v-else class="block w-full bg-[#F7F7F7] p-2 rounded-sm">
<div class="flex items-center gap-3">
<img
v-if="getExternalShareCardThumbSrc(post)"
:src="getExternalShareCardThumbSrc(post)"
class="w-12 h-12 object-cover flex-shrink-0 bg-white"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onExternalShareCardThumbError(post)"
/>
<div v-else class="w-12 h-12 flex items-center justify-center bg-gray-200 text-gray-400 flex-shrink-0 text-xs">
{{ formatExternalSharePlaceholder(post) }}
</div>
<div class="flex-1 min-w-0 flex items-center overflow-hidden h-12">
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{{ formatExternalShareCardTitle(post) }}</div>
</div>
</div>
</div>
</div>
<div v-else-if="post.media && post.media.length > 0" class="mt-2" :class="{ 'privacy-blur': privacyMode }">
<div v-if="post.media.length === 1" class="max-w-[360px]">
<div
v-if="!hasMediaError(post.id, 0) && getMediaThumbSrc(post, post.media[0], 0)"
class="inline-block cursor-pointer relative group"
@click.stop="onMediaClick(post, post.media[0], 0)"
@mouseenter="onLivePhotoEnter(post.id, 0, post.media[0])"
@mouseleave="onLivePhotoLeave(post.id, 0, post.media[0])"
>
<video
v-if="Number(post.media[0]?.type || 0) === 6"
:src="getSnsRemoteVideoSrc(post, post.media[0])"
:poster="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] max-w-full object-cover"
autoplay
loop
muted
playsinline
@loadeddata="onLocalVideoLoaded(post.id, post.media[0].id)"
@error="onLocalVideoError(post.id, post.media[0].id)"
></video>
<video
v-else-if="isLivePhotoMedia(post.media[0]) && isLivePhotoActive(post.id, 0) && !hasLivePhotoVideoError(post.id, 0)"
ref="livePhotoHoverVideoEl"
:src="getLivePhotoVideoSrc(post, post.media[0], 0)"
:poster="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] max-w-full object-cover pointer-events-none"
autoplay
loop
:muted="livePhotoHoverMuted"
playsinline
@error="onLivePhotoVideoError(post.id, 0)"
></video>
<img
v-else
:src="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, 0)"
/>
<div
v-if="Number(post.media[0]?.type || 0) === 6 && !isLocalVideoLoaded(post.id, post.media[0].id)"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div
v-if="isLivePhotoMedia(post.media[0])"
class="absolute top-2 right-2 bg-black/30 backdrop-blur-sm text-white p-1 rounded-full pointer-events-none z-10 shadow-sm"
>
<LivePhotoIcon :size="16" class="block" />
</div>
<button
v-if="isLivePhotoMedia(post.media[0]) && isLivePhotoActive(post.id, 0) && !hasLivePhotoVideoError(post.id, 0)"
type="button"
class="absolute top-2 right-10 text-white/90 hover:text-white p-1 rounded-full bg-black/30 hover:bg-black/50 transition-colors z-10"
:title="livePhotoHoverMuted ? '开启声音' : '静音'"
@click.stop="toggleLivePhotoHoverMuted"
>
<svg v-if="livePhotoHoverMuted" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5L6 9H2v6h4l5 4V5z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M23 9l-6 6M17 9l6 6" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5L6 9H2v6h4l5 4V5z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.5 8.5a4 4 0 010 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.5 5.5a8 8 0 010 13" />
</svg>
</button>
</div>
<div
v-else
class="w-[240px] h-[180px] rounded-sm bg-gray-100 border border-gray-200 flex items-center justify-center text-xs text-gray-400"
title="图片加载失败"
@click.stop="onMediaClick(post, post.media[0], 0)"
style="cursor: pointer;"
>
图片加载失败
</div>
</div>
<div v-else class="grid grid-cols-3 gap-1 max-w-[360px]">
<div
v-for="(m, idx) in post.media.slice(0, 9)"
:key="idx"
class="w-[116px] h-[116px] rounded-[2px] overflow-hidden bg-gray-100 border border-gray-200 flex items-center justify-center cursor-pointer relative group"
@click.stop="onMediaClick(post, m, idx)"
@mouseenter="onLivePhotoEnter(post.id, idx, m)"
@mouseleave="onLivePhotoLeave(post.id, idx, m)"
>
<video
v-if="!hasMediaError(post.id, idx) && Number(m?.type || 0) === 6"
:src="getSnsRemoteVideoSrc(post, m)"
:poster="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover"
autoplay
loop
muted
playsinline
@loadeddata="onLocalVideoLoaded(post.id, m.id)"
@error="onLocalVideoError(post.id, m.id)"
></video>
<video
v-else-if="isLivePhotoMedia(m) && isLivePhotoActive(post.id, idx) && !hasLivePhotoVideoError(post.id, idx)"
ref="livePhotoHoverVideoEl"
:src="getLivePhotoVideoSrc(post, m, idx)"
:poster="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover pointer-events-none"
autoplay
loop
:muted="livePhotoHoverMuted"
playsinline
@error="onLivePhotoVideoError(post.id, idx)"
></video>
<img
v-else-if="!hasMediaError(post.id, idx) && getMediaThumbSrc(post, m, idx)"
:src="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, idx)"
/>
<!-- 不知道微信朋友圈可不可以发多视频先这样写吧-->
<span v-else class="text-[10px] text-gray-400">图片失败</span>
<div
v-if="Number(m?.type || 0) === 6 && !isLocalVideoLoaded(post.id, m.id)"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-10 h-10 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div
v-if="isLivePhotoMedia(m)"
class="absolute top-1 right-1 bg-black/30 backdrop-blur-sm text-white p-0.5 rounded-full pointer-events-none z-10 shadow-sm"
>
<LivePhotoIcon :size="14" class="block" />
</div>
<button
v-if="isLivePhotoMedia(m) && isLivePhotoActive(post.id, idx) && !hasLivePhotoVideoError(post.id, idx)"
type="button"
class="absolute top-1 right-7 text-white/90 hover:text-white p-0.5 rounded-full bg-black/30 hover:bg-black/50 transition-colors z-10"
:title="livePhotoHoverMuted ? '开启声音' : '静音'"
@click.stop="toggleLivePhotoHoverMuted"
>
<svg v-if="livePhotoHoverMuted" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5L6 9H2v6h4l5 4V5z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M23 9l-6 6M17 9l6 6" />
</svg>
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5L6 9H2v6h4l5 4V5z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.5 8.5a4 4 0 010 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.5 5.5a8 8 0 010 13" />
</svg>
</button>
</div>
</div>
</div>
<div v-if="post.location" class="mt-2 text-xs text-[#576b95] truncate" :class="{ 'privacy-blur': privacyMode }">
{{ post.location }}
</div>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs text-gray-400" :class="{ 'privacy-blur': privacyMode }">{{ formatRelativeTime(post.createTime) }}</span>
<button
v-if="Number(post?.type || 0) === 3 && formatMomentTypeLabel(post)"
type="button"
class="text-xs text-[#576b95] truncate bg-transparent p-0 border-0 hover:underline"
:class="{ 'privacy-blur': privacyMode }"
:title="formatMomentTypeLabel(post)"
@click.stop="onMomentTypeLabelClick(post)"
>{{ formatMomentTypeLabel(post) }}</button>
<span
v-else-if="formatMomentTypeLabel(post)"
class="text-xs text-[#576b95] truncate"
:class="{ 'privacy-blur': privacyMode }"
:title="formatMomentTypeLabel(post)"
>{{ formatMomentTypeLabel(post) }}</span>
</div>
</div>
<!-- 点赞/评论参考 WeFlow 展示 -->
<div
v-if="(post.likes && post.likes.length > 0) || (post.comments && post.comments.length > 0)"
class="mt-2 bg-gray-100 rounded-sm px-2 py-1"
>
<div v-if="post.likes && post.likes.length > 0" class="flex items-start gap-1 text-xs text-[#576b95] leading-5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
class="mt-[3px] mr-[10px] flex-shrink-0 opacity-80"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 1-4.5 2.5C10.5 4 9.26 3 7.5 3A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
<div class="break-words" :class="{ 'privacy-blur': privacyMode }">
{{ formatLikes(post.likes) }}
</div>
</div>
<div v-if="post.likes && post.likes.length > 0 && post.comments && post.comments.length > 0" class="my-1 border-t border-gray-200"></div>
<div v-if="post.comments && post.comments.length > 0" class="space-y-1">
<div v-for="(c, idx) in post.comments" :key="c?.id || idx" class="text-xs leading-5 break-words">
<span class="font-medium text-[#576b95]" :class="{ 'privacy-blur': privacyMode }">
{{ cleanLikeName(c?.nickname || c?.displayName || c?.username || '') || '未知' }}
</span>
<template v-if="cleanLikeName(c?.refNickname || c?.refUsername || c?.refUserName || '')">
<span class="mx-1 text-gray-500">回复</span>
<span class="font-medium text-[#576b95]" :class="{ 'privacy-blur': privacyMode }">
{{ cleanLikeName(c?.refNickname || c?.refUsername || c?.refUserName || '') }}
</span>
</template>
<span class="text-gray-900" :class="{ 'privacy-blur': privacyMode }">:
<span v-for="(seg, sidx) in parseTextWithEmoji(String(c?.content || '').trim())" :key="sidx">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="isLoading && posts.length > 0" class="py-4 flex justify-center items-center">
<div class="w-5 h-5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-if="!hasMore && posts.length > 0" class="py-6 text-center text-xs text-gray-400">
到底了
</div>
</div>
</div>
</div>
<!-- 右键菜单复制 JSON 方便定位问题 -->
<div
v-if="contextMenu.visible"
class="fixed z-50 bg-white border border-gray-200 rounded-md shadow-lg text-sm"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<button class="block w-full text-left px-3 py-2 hover:bg-gray-100" type="button" @click="onCopyPostTextClick">
复制文案
</button>
<button class="block w-full text-left px-3 py-2 hover:bg-gray-100" type="button" @click="onCopyPostJsonClick">
复制朋友圈 JSON
</button>
</div>
<!-- SNS export modal -->
<div v-if="exportModalOpen" class="fixed inset-0 z-[12000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closeExportModal"></div>
<div class="relative w-[880px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 flex items-start gap-3">
<div class="min-w-0">
<div class="text-base font-medium text-gray-900">导出朋友圈离线 ZIP</div>
<div class="mt-1 text-xs text-gray-500 leading-5">
直接勾选要导出的联系人支持搜索批量勾选以及自定义 ZIP 文件名和导出目录
</div>
</div>
<button class="ml-auto text-gray-400 hover:text-gray-700" type="button" @click="closeExportModal">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-5 max-h-[75vh] overflow-y-auto space-y-5">
<div v-if="exportError" class="text-sm text-red-600 whitespace-pre-wrap">{{ exportError }}</div>
<div v-if="exportJob" class="border border-gray-200 rounded-lg bg-gray-50 p-4 space-y-3">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">&#24403;&#21069;&#23548;&#20986;&#20219;&#21153;</div>
<div class="mt-1 text-xs text-gray-500 break-all">ID&#65306;{{ exportJob.exportId || '-' }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="px-2.5 py-1 rounded-full text-xs border border-gray-200 bg-white text-gray-600">
&#29366;&#24577;&#65306;{{ exportStatusText }}
</span>
<span class="px-2.5 py-1 rounded-full text-xs border border-gray-200 bg-white text-gray-600">
{{ exportOverallPercent }}%
</span>
<button
v-if="canCancelSnsExport"
type="button"
class="px-3 py-1.5 rounded-md text-xs border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isExportCancelling"
@click="cancelSnsExportJob"
>
{{ isExportCancelling ? '\u53d6\u6d88\u4e2d\u2026' : '\u53d6\u6d88\u5bfc\u51fa' }}
</button>
<button
v-if="exportJob.status === 'done' && exportJob.exportId && hasWebExportFolder"
type="button"
class="px-3 py-1.5 rounded-md text-xs border border-[#03C160]/20 bg-[#03C160]/10 text-[#027a44] hover:bg-[#03C160]/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="exportSaveBusy"
@click="saveSnsExportToSelectedFolder()"
>
{{ exportSaveBusy ? '\u4fdd\u5b58\u4e2d\u2026' : exportSaveState === 'success' ? '\u91cd\u65b0\u4fdd\u5b58\u5230\u6587\u4ef6\u5939' : '\u4fdd\u5b58\u5230\u5df2\u9009\u6587\u4ef6\u5939' }}
</button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm text-gray-700">
<div>&#21160;&#24577;&#65306;{{ exportJob.progress?.postsExported || 0 }}/{{ exportJob.progress?.postsTotal || 0 }}</div>
<div class="text-gray-500">{{ exportOverallPercent }}%</div>
</div>
<div class="h-2.5 rounded-full bg-white border border-gray-200 overflow-hidden">
<div class="h-full bg-[#03C160] transition-all duration-300" :style="{ width: exportOverallPercent + '%' }"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs text-gray-600">
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
&#32852;&#31995;&#20154;&#65306;{{ exportJob.progress?.usersDone || 0 }}/{{ exportJob.progress?.usersTotal || 0 }}
</div>
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
&#26684;&#24335;&#65306;{{ exportActiveFormatLabel }}
</div>
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
&#24050;&#22797;&#21046;&#23186;&#20307;&#65306;{{ exportJob.progress?.mediaCopied || 0 }}
</div>
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
&#32570;&#22833;&#23186;&#20307;&#65306;{{ exportJob.progress?.mediaMissing || 0 }}
</div>
</div>
<div v-if="exportCurrentTargetLabel" class="space-y-2">
<div class="flex items-center justify-between gap-2 text-sm text-gray-700">
<div class="truncate">
&#24403;&#21069;&#32852;&#31995;&#20154;&#65306;{{ exportCurrentTargetLabel }}
&#65288;{{ exportJob.progress?.currentUserPostsDone || 0 }}/{{ exportJob.progress?.currentUserPostsTotal || 0 }}&#65289;
</div>
<div class="text-gray-500">
<span v-if="exportCurrentPercent != null">{{ exportCurrentPercent }}%</span>
<span v-else></span>
</div>
</div>
<div class="h-2.5 rounded-full bg-white border border-gray-200 overflow-hidden">
<div
v-if="exportCurrentPercent != null"
class="h-full bg-sky-500 transition-all duration-300"
:style="{ width: exportCurrentPercent + '%' }"
></div>
<div v-else class="h-full bg-sky-500/60 animate-pulse" style="width: 30%"></div>
</div>
</div>
<div v-if="isExportCancelling && canCancelSnsExport" class="text-xs text-amber-700">
&#24050;&#21457;&#36865;&#21462;&#28040;&#35831;&#27714;&#65292;&#27491;&#22312;&#31561;&#24453;&#24403;&#21069;&#27493;&#39588;&#32467;&#26463;&#8230;
</div>
<div v-else-if="exportJob.status === 'cancelled'" class="text-xs text-amber-700">
&#23548;&#20986;&#24050;&#21462;&#28040;&#12290;
</div>
<div v-else-if="exportJob.status === 'error' && exportJob.error" class="text-xs text-red-600 whitespace-pre-wrap break-words">
{{ exportJob.error }}
</div>
<div v-if="exportOutputPathText" class="text-xs text-green-600 break-all">
&#24050;&#23548;&#20986;&#21040;&#65306;{{ exportOutputPathText }}
</div>
</div>
<div class="flex flex-wrap items-end gap-4 xl:flex-nowrap">
<div class="min-w-[180px]">
<div class="text-sm font-medium text-gray-900 mb-2">&#23548;&#20986;&#26684;&#24335;</div>
<div class="flex flex-wrap gap-2">
<label
v-for="item in exportFormatOptions"
:key="item.value"
class="px-3 py-2 text-sm rounded-md border cursor-pointer transition-colors"
:class="exportFormat === item.value ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
>
<input v-model="exportFormat" type="radio" :value="item.value" class="hidden" />
<span>{{ item.label }}</span>
</label>
</div>
</div>
<div class="flex-1 min-w-[220px]">
<label class="block text-sm font-medium text-gray-900 mb-2">&#23548;&#20986;&#25991;&#20214;&#21517;&#65288;&#21487;&#36873;&#65289;</label>
<input
v-model="exportFileName"
type="text"
placeholder="&#21487;&#36873;&#65292;&#19981;&#22635;&#21017;&#33258;&#21160;&#29983;&#25104; .zip &#25991;&#20214;&#21517;"
class="w-full px-3 py-2 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
/>
</div>
<div class="flex-[1.4] min-w-[300px]">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="text-sm font-medium text-gray-900">&#23548;&#20986;&#30446;&#24405;</div>
<div class="text-[11px] text-gray-400">{{ exportFolderModeText }}</div>
</div>
<div class="flex items-center gap-2">
<div class="px-3 py-2 rounded-md border border-gray-200 bg-gray-50 text-sm text-gray-600 break-all min-h-[42px] flex items-center min-w-0 flex-1">
{{ exportFolder || '未选择' }}
</div>
<button
type="button"
class="px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
:disabled="exportSaveBusy"
@click="chooseExportFolder"
>
&#36873;&#25321;&#25991;&#20214;&#22841;
</button>
<button
v-if="hasSelectedExportFolder"
type="button"
class="px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
:disabled="exportSaveBusy"
@click="clearExportFolderSelection"
>
&#28165;&#38500;
</button>
</div>
</div>
</div>
<div class="text-[11px] text-gray-500 whitespace-pre-wrap">{{ exportFolderHint }}</div>
<div class="space-y-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-medium text-gray-900">&#36873;&#25321;&#32852;&#31995;&#20154;</div>
<div class="flex flex-wrap items-center gap-2">
<div class="text-xs text-gray-500">&#24050;&#36873; {{ exportSelectedCount }} &#20154;</div>
<button
type="button"
class="px-3 py-1.5 rounded-md text-xs border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
:disabled="!exportFilteredSnsUsers.length"
@click="toggleSelectAllFilteredExportUsers"
>
{{ areAllFilteredExportUsersSelected ? '取消全选当前结果' : '全选当前结果' }}
</button>
<button
type="button"
class="px-3 py-1.5 rounded-md text-xs border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
:disabled="!exportSelectedCount"
@click="clearExportSelectedUsers"
>
&#28165;&#31354;&#24050;&#36873;
</button>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<input
v-model="exportSearchQuery"
type="text"
placeholder="&#25628;&#32034;&#32852;&#31995;&#20154;&#65288;&#21517;&#31216; / username&#65289;"
class="flex-1 px-3 py-2 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
:class="{ 'privacy-blur': privacyMode }"
/>
</div>
<div class="border border-gray-200 rounded-md max-h-72 overflow-y-auto">
<div v-if="!exportFilteredSnsUsers.length" class="px-3 py-8 text-sm text-gray-500 text-center">
&#26410;&#25214;&#21040;&#21487;&#23548;&#20986;&#30340;&#32852;&#31995;&#20154;
</div>
<label
v-for="u in exportFilteredSnsUsers"
:key="u.username"
class="px-3 py-2 border-b border-gray-100 flex items-center gap-2 cursor-pointer transition-colors"
:class="exportSelectedUsernameSet.has(u.username) ? 'bg-[#03C160]/5 hover:bg-[#03C160]/10' : 'hover:bg-gray-50'"
>
<input v-model="exportSelectedUsernames" type="checkbox" :value="u.username" class="cursor-pointer" />
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img
v-if="postAvatarUrl(u.username) && !hasSnsAvatarError(u.username)"
:src="postAvatarUrl(u.username)"
:alt="u.displayName || u.username"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
@error="onSnsAvatarError(u.username)"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
style="background-color: #4B5563"
>
{{ (u.displayName || u.username || '友').charAt(0) }}
</div>
</div>
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
<div class="text-sm text-gray-800 truncate">{{ u.displayName || u.username }}</div>
<div class="text-[11px] text-gray-400 truncate">{{ u.username }} &#183; {{ u.postCount || 0 }} &#26465;</div>
</div>
</label>
</div>
<div class="text-[11px] text-gray-500">
默认按勾选联系人导出如需全部导出直接点全选当前结果即可
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-gray-200 flex items-center justify-between gap-3">
<div class="text-xs text-gray-500">&#24050;&#36873; {{ exportSelectedCount }} &#20154;</div>
<div class="flex gap-2">
<button
v-if="canCancelSnsExport"
type="button"
class="px-4 py-2 rounded-md text-sm border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isExportCancelling"
@click="cancelSnsExportJob"
>
{{ isExportCancelling ? '\u53d6\u6d88\u4e2d\u2026' : '\u53d6\u6d88\u5bfc\u51fa' }}
</button>
<button
type="button"
class="px-4 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors"
@click="closeExportModal"
>
&#21462;&#28040;
</button>
<button
type="button"
class="px-4 py-2 rounded-md text-sm bg-[#03C160] text-white hover:bg-[#02ad56] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!selectedAccount || !exportSelectedCount || isSnsExportBusy"
@click="startSnsExportFromModal"
>
{{ isSnsExportBusy ? '导出中…' : '开始导出' }}
</button>
</div>
</div>
</div>
</div>
<!-- Image preview modal -->
<div
v-if="previewCtx"
class="fixed inset-0 z-[60] bg-black/90 flex items-center justify-center"
@click="closeImagePreview"
>
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
<video
v-if="previewIsVideo"
ref="previewVideoEl"
:key="previewVideoKey"
:src="previewVideoSrc"
:poster="previewVideoPoster"
class="max-w-[90vw] max-h-[70vh] object-contain"
controls
autoplay
playsinline
@error="onPreviewVideoError"
></video>
<video
v-else-if="previewLivePhotoVideoSrc && !previewHasLivePhotoVideoError"
ref="previewLiveVideoEl"
:src="previewLivePhotoVideoSrc"
:poster="previewSrc"
class="max-w-[90vw] max-h-[70vh] object-contain"
autoplay
loop
:muted="previewLivePhotoMuted"
playsinline
@error="onPreviewLivePhotoVideoError"
></video>
<img v-else :src="previewSrc" alt="预览" class="max-w-[90vw] max-h-[70vh] object-contain" />
<div
v-if="previewIsVideo && previewVideoError"
class="mt-3 text-xs text-red-200 whitespace-pre-wrap text-center max-w-[90vw]"
>
{{ previewVideoError }}
</div>
</div>
<button
v-if="previewLivePhotoVideoSrc && !previewHasLivePhotoVideoError"
class="absolute top-4 right-16 text-white/80 hover:text-white p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors"
:title="previewLivePhotoMuted ? '开启声音' : '静音'"
@click.stop="togglePreviewLivePhotoMuted"
>
<svg v-if="previewLivePhotoMuted" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5L6 9H2v6h4l5 4V5z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M23 9l-6 6M17 9l6 6" />
</svg>
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5L6 9H2v6h4l5 4V5z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.5 8.5a4 4 0 010 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.5 5.5a8 8 0 010 13" />
</svg>
</button>
<button
class="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors"
@click.stop="closeImagePreview"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
import { reportServerErrorFromError, reportServerErrorFromResponse } from '~/lib/server-error-logging'
useHead({ title: '朋友圈 - 微信数据分析助手' })
const api = useApi()
const chatAccounts = useChatAccountsStore()
const { selectedAccount } = storeToRefs(chatAccounts)
const privacyStore = usePrivacyStore()
const { privacyMode } = storeToRefs(privacyStore)
const posts = ref([])
// De-dupe across pages to tolerate slight offset drift when the backend filters/omits some rows.
const seenPostIds = new Set()
// NOTE: Backend `/api/sns/timeline` uses SQL OFFSET on the raw timeline rows.
// The UI filters out some rows (e.g. type=7 cover), so `posts.length` must NOT be used as the next OFFSET.
const timelineOffset = ref(0)
const hasMore = ref(true)
// When timeline API reports `hasMore=false` but cached sidebar count indicates more, keep paging.
// If we hit an empty page, stop trying to avoid infinite requests.
const cachePagingExhausted = ref(false)
const timelineScrollEl = ref(null)
const isLoading = ref(false)
const error = ref('')
const snsUseCache = ref(true)
const coverData = ref(null)
const covers = ref([])
const coverIndex = ref(0)
const activeCover = computed(() => {
const list = Array.isArray(covers.value) ? covers.value : []
if (list.length > 0) {
const idx = Math.max(0, Math.min(Number(coverIndex.value) || 0, list.length - 1))
return list[idx] || null
}
return coverData.value
})
const prevCover = () => {
const list = Array.isArray(covers.value) ? covers.value : []
if (list.length <= 1) return
const cur = Number(coverIndex.value) || 0
coverIndex.value = (cur - 1 + list.length) % list.length
}
const nextCover = () => {
const list = Array.isArray(covers.value) ? covers.value : []
if (list.length <= 1) return
const cur = Number(coverIndex.value) || 0
coverIndex.value = (cur + 1) % list.length
}
const formatCoverTime = (tsSeconds) => {
const t = Number(tsSeconds || 0)
if (!t) return ''
const d = new Date(t * 1000)
const pad2 = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
// 左侧朋友圈联系人栏
const snsUsers = ref([])
const snsUserQuery = ref('')
// 空字符串表示“全部”
const selectedSnsUser = ref('')
const snsAvatarErrors = ref({})
const shouldHideSnsUser = (item) => {
const username = String(item?.username || '').trim()
const displayName = String(item?.displayName || '').trim()
const postCount = Number(item?.postCount || 0)
if (!username) return true
if (!Number.isFinite(postCount) || postCount <= 0) return true
return /^v3_/i.test(username) && /@stranger$/i.test(username) && (!displayName || displayName === username)
}
const visibleSnsUsers = computed(() => {
const list = Array.isArray(snsUsers.value) ? snsUsers.value : []
return list.filter((item) => !shouldHideSnsUser(item))
})
const snsAvatarErrorKey = (username) => String(username || '').trim()
const hasSnsAvatarError = (username) => {
const key = snsAvatarErrorKey(username)
return key ? !!snsAvatarErrors.value[key] : false
}
const onSnsAvatarError = (username) => {
const key = snsAvatarErrorKey(username)
if (!key || snsAvatarErrors.value[key]) return
snsAvatarErrors.value = {
...snsAvatarErrors.value,
[key]: true
}
}
const selectedSnsUserInfo = computed(() => {
const uname = String(selectedSnsUser.value || '').trim()
if (!uname) return null
const list = visibleSnsUsers.value
return list.find((u) => String(u?.username || '').trim() === uname) || null
})
const showSnsCountMismatchHint = computed(() => {
const uname = String(selectedSnsUser.value || '').trim()
if (!uname) return false
const cached = Number(selectedSnsUserInfo.value?.postCount || 0) || 0
const shown = Array.isArray(posts.value) ? posts.value.length : 0
return cached > 0 && shown > 0 && !hasMore.value && !isLoading.value && shown < cached
})
const filteredSnsUsers = computed(() => {
const q = String(snsUserQuery.value || '').trim().toLowerCase()
const list = visibleSnsUsers.value
if (!q) return list
return list.filter((u) => {
const uname = String(u?.username || '').toLowerCase()
const dn = String(u?.displayName || '').toLowerCase()
return uname.includes(q) || dn.includes(q)
})
})
const pageSize = 20
const apiBase = useApiBase()
// 朋友圈导出(离线 ZIP
const exportFormat = ref('html')
const exportFormatOptions = [
{ value: 'html', label: 'HTML' },
{ value: 'json', label: 'JSON' },
{ value: 'txt', label: 'TXT' }
]
const exportFolder = ref('')
const exportFolderHandle = ref(null)
const exportSaveBusy = ref(false)
const exportSaveMsg = ref('')
const exportSaveError = ref('')
const exportSaveState = ref('idle')
const exportSaveBytesWritten = ref(0)
const exportSaveBytesTotal = ref(0)
const exportAutoSavedFor = ref('')
const exportJob = ref(null)
const exportError = ref('')
const exportModalOpen = ref(false)
const exportFileName = ref('')
const exportSearchQuery = ref('')
const exportSelectedUsernames = ref([])
const isExportCancelling = ref(false)
let exportEventSource = null
let exportPollTimer = null
const asNumber = (v) => {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0))
const formatBytes = (value) => {
const bytes = Number(value)
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
return `${size.toFixed(digits)} ${units[index]}`
}
const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => {
exportSaveMsg.value = ''
exportSaveError.value = ''
exportSaveState.value = 'idle'
exportSaveBytesWritten.value = 0
exportSaveBytesTotal.value = 0
if (resetAutoSavedFor) exportAutoSavedFor.value = ''
}
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
const isWebDirectoryPickerSupported = () => {
return !!(process.client && typeof window.showDirectoryPicker === 'function')
}
const hasDesktopExportFolder = computed(() => {
return !!(isDesktopExportRuntime() && String(exportFolder.value || '').trim())
})
const hasWebExportFolder = computed(() => {
return !!(!isDesktopExportRuntime() && isWebDirectoryPickerSupported() && exportFolderHandle.value)
})
const hasSelectedExportFolder = computed(() => {
return !!(hasDesktopExportFolder.value || hasWebExportFolder.value)
})
const exportFormatLabel = computed(() => {
return exportFormatOptions.find((item) => item.value === exportFormat.value)?.label || 'HTML'
})
const exportActiveFormat = computed(() => {
const raw = String(exportJob.value?.options?.format || exportFormat.value || 'html').trim().toLowerCase()
return exportFormatOptions.some((item) => item.value === raw) ? raw : 'html'
})
const exportActiveFormatLabel = computed(() => {
return exportFormatOptions.find((item) => item.value === exportActiveFormat.value)?.label || 'HTML'
})
const exportStatusText = computed(() => {
const status = String(exportJob.value?.status || '').trim()
return {
queued: '排队中',
running: '导出中',
done: '已完成',
error: '失败',
cancelled: '已取消'
}[status] || status || '-'
})
const exportOverallPercent = computed(() => {
const status = String(exportJob.value?.status || '').trim()
if (status === 'done') return 100
const progress = exportJob.value?.progress || {}
const postsTotal = asNumber(progress.postsTotal)
const postsDone = asNumber(progress.postsExported)
if (postsTotal > 0) return Math.round(clamp01(postsDone / postsTotal) * 100)
const usersTotal = asNumber(progress.usersTotal)
const usersDone = asNumber(progress.usersDone)
if (usersTotal > 0) return Math.round(clamp01(usersDone / usersTotal) * 100)
return 0
})
const exportCurrentPercent = computed(() => {
const progress = exportJob.value?.progress || {}
const total = asNumber(progress.currentUserPostsTotal)
const done = asNumber(progress.currentUserPostsDone)
if (total <= 0) return null
return Math.round(clamp01(done / total) * 100)
})
const exportCurrentTargetLabel = computed(() => {
const progress = exportJob.value?.progress || {}
return String(progress.currentDisplayName || progress.currentUsername || '').trim()
})
const isSnsExportBusy = computed(() => {
const status = String(exportJob.value?.status || '').trim()
return status === 'queued' || status === 'running'
})
const canCancelSnsExport = computed(() => {
if (!exportJob.value?.exportId) return false
const status = String(exportJob.value?.status || '').trim()
return status === 'queued' || status === 'running'
})
const normalizeExportSelectedUsernames = (list) => {
const validUsernames = new Set(
visibleSnsUsers.value
.map((item) => String(item?.username || '').trim())
.filter(Boolean)
)
const seen = new Set()
return (Array.isArray(list) ? list : []).reduce((acc, item) => {
const username = String(item || '').trim()
if (!username || seen.has(username)) return acc
if (validUsernames.size > 0 && !validUsernames.has(username)) return acc
seen.add(username)
acc.push(username)
return acc
}, [])
}
const exportSelectedUsernameSet = computed(() => {
return new Set(normalizeExportSelectedUsernames(exportSelectedUsernames.value))
})
const exportSelectedCount = computed(() => {
return exportSelectedUsernameSet.value.size
})
const exportFilteredSnsUsers = computed(() => {
const q = String(exportSearchQuery.value || '').trim().toLowerCase()
const list = visibleSnsUsers.value
if (!q) return list
return list.filter((item) => {
const username = String(item?.username || '').toLowerCase()
const displayName = String(item?.displayName || '').toLowerCase()
return username.includes(q) || displayName.includes(q)
})
})
const exportFilteredSnsUsernames = computed(() => {
return exportFilteredSnsUsers.value
.map((item) => String(item?.username || '').trim())
.filter(Boolean)
})
const areAllFilteredExportUsersSelected = computed(() => {
const usernames = exportFilteredSnsUsernames.value
if (!usernames.length) return false
return usernames.every((username) => exportSelectedUsernameSet.value.has(username))
})
const clearExportSelectedUsers = () => {
exportSelectedUsernames.value = []
}
const toggleSelectAllFilteredExportUsers = () => {
const usernames = exportFilteredSnsUsernames.value
if (!usernames.length) return
if (areAllFilteredExportUsersSelected.value) {
const removeSet = new Set(usernames)
exportSelectedUsernames.value = normalizeExportSelectedUsernames(exportSelectedUsernames.value)
.filter((username) => !removeSet.has(username))
return
}
exportSelectedUsernames.value = normalizeExportSelectedUsernames([
...exportSelectedUsernames.value,
...usernames
])
}
const openExportModal = () => {
exportModalOpen.value = true
exportError.value = ''
exportSearchQuery.value = ''
exportFileName.value = ''
exportSelectedUsernames.value = selectedSnsUser.value
? normalizeExportSelectedUsernames([selectedSnsUser.value])
: []
}
const closeExportModal = () => {
exportModalOpen.value = false
exportError.value = ''
exportSearchQuery.value = ''
}
const exportBackendZipPath = computed(() => {
return String(exportJob.value?.zipPath || '').trim()
})
const exportFolderModeText = computed(() => {
if (isDesktopExportRuntime()) return '\u684c\u9762\u7aef\u76ee\u5f55'
if (isWebDirectoryPickerSupported()) return '\u6d4f\u89c8\u5668\u76ee\u5f55'
return '\u9700\u9009\u62e9\u6587\u4ef6\u5939'
})
const exportFolderHint = computed(() => {
if (isDesktopExportRuntime()) {
return hasDesktopExportFolder.value
? '\u4f1a\u50cf\u666e\u901a\u804a\u5929\u8bb0\u5f55\u5bfc\u51fa\u4e00\u6837\uff0c\u5b8c\u6210\u540e\u76f4\u63a5\u5199\u5165\u4e0a\u9762\u7684\u6587\u4ef6\u5939\u3002'
: '\u8bf7\u5148\u9009\u62e9\u6587\u4ef6\u5939\uff0c\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u76f4\u63a5\u5199\u5165\u8be5\u76ee\u5f55\u3002'
}
if (isWebDirectoryPickerSupported()) {
return hasWebExportFolder.value
? '\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u4fdd\u5b58\u5230\u6240\u9009\u6d4f\u89c8\u5668\u76ee\u5f55\u3002'
: '\u8bf7\u5148\u9009\u62e9\u6d4f\u89c8\u5668\u76ee\u5f55\uff0c\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u4fdd\u5b58\u3002'
}
return '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668\u3002'
})
const guessSnsExportZipName = (job) => {
const raw = String(job?.zipPath || '').trim()
if (raw) {
const name = raw.replace(/\\/g, '/').split('/').pop()
if (name && name.toLowerCase().endsWith('.zip')) return name
}
const format = String(job?.options?.format || exportFormat.value || 'html').trim().toLowerCase() || 'html'
const exportId = String(job?.exportId || '').trim() || 'export'
return `wechat_sns_export_${format}_${exportId}.zip`
}
const exportSaveProgressText = computed(() => {
if (exportSaveState.value !== 'saving') return ''
const fileName = guessSnsExportZipName(exportJob.value)
if (exportSaveBytesTotal.value > 0) {
return `\u6b63\u5728\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${fileName}\uff08${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)}\uff09`
}
return `\u6b63\u5728\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${fileName}\uff08${formatBytes(exportSaveBytesWritten.value)}\uff09`
})
const exportOutputPathText = computed(() => {
if (String(exportJob.value?.status || '') !== 'done') return ''
if (hasWebExportFolder.value) return ''
const raw = exportBackendZipPath.value
if (!raw) return ''
if (isDesktopExportRuntime()) return raw
const requestedOutputDir = String(exportJob.value?.options?.outputDir || '').trim()
return requestedOutputDir ? raw : ''
})
const chooseExportFolder = async () => {
exportError.value = ''
resetExportSaveFeedback()
try {
if (!process.client) {
exportError.value = '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55'
return
}
if (isDesktopExportRuntime()) {
const result = await window.wechatDesktop.chooseDirectory({ title: '\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
exportFolder.value = String(result.filePaths[0] || '').trim()
exportFolderHandle.value = null
}
return
}
if (isWebDirectoryPickerSupported()) {
const handle = await window.showDirectoryPicker()
if (handle) {
exportFolderHandle.value = handle
exportFolder.value = `\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${String(handle.name || '\u5df2\u9009\u62e9')}`
}
return
}
exportError.value = '\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668'
} catch (error) {
const message = String(error?.message || '').trim()
if (error?.name === 'AbortError' || message.includes('The user aborted a request')) {
return
}
exportError.value = error?.message || '\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55\u5931\u8d25'
}
}
const clearExportFolderSelection = () => {
exportFolder.value = ''
exportFolderHandle.value = null
resetExportSaveFeedback({ resetAutoSavedFor: true })
}
const getSnsExportDownloadUrl = (exportId) => {
return `${apiBase}/sns/exports/${encodeURIComponent(String(exportId || ''))}/download`
}
const saveSnsExportToSelectedFolder = async (options = {}) => {
const autoSave = !!options?.auto
exportError.value = ''
resetExportSaveFeedback()
if (!process.client || !isWebDirectoryPickerSupported()) {
exportError.value = '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55'
return
}
const handle = exportFolderHandle.value
if (!handle || typeof handle.getFileHandle !== 'function') {
exportError.value = '\u8bf7\u5148\u9009\u62e9\u6d4f\u89c8\u5668\u5bfc\u51fa\u76ee\u5f55'
return
}
const exportId = exportJob.value?.exportId
if (!exportId || String(exportJob.value?.status || '') !== 'done') {
exportError.value = '\u5bfc\u51fa\u4efb\u52a1\u5c1a\u672a\u5b8c\u6210'
return
}
exportSaveBusy.value = true
exportSaveState.value = 'saving'
try {
const response = await fetch(getSnsExportDownloadUrl(exportId))
if (!response.ok) {
await reportServerErrorFromResponse(response, {
method: 'GET',
requestUrl: getSnsExportDownloadUrl(exportId),
message: `\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`,
source: 'sns.exportDownload'
})
throw new Error(`\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`)
}
exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length'))
const fileName = guessSnsExportZipName(exportJob.value)
const fileHandle = await handle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
if (response.body && typeof response.body.getReader === 'function') {
const reader = response.body.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value || !value.byteLength) continue
await writable.write(value)
exportSaveBytesWritten.value += value.byteLength
}
await writable.close()
} catch (error) {
try {
await reader.cancel()
} catch {}
try {
await writable.abort()
} catch {}
throw error
}
} else {
const blob = await response.blob()
exportSaveBytesWritten.value = asNumber(blob.size)
if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value
await writable.write(blob)
await writable.close()
}
exportAutoSavedFor.value = String(exportId)
exportSaveState.value = 'success'
const folderLabel = String(exportFolder.value || '').trim() || '\u5df2\u9009\u76ee\u5f55'
exportSaveMsg.value = autoSave
? `\u6d4f\u89c8\u5668\u76ee\u5f55\u81ea\u52a8\u4fdd\u5b58\u6210\u529f\uff1a${fileName}\n\u4f4d\u7f6e\uff1a${folderLabel}`
: `\u6d4f\u89c8\u5668\u76ee\u5f55\u4fdd\u5b58\u6210\u529f\uff1a${fileName}\n\u4f4d\u7f6e\uff1a${folderLabel}`
} catch (error) {
exportSaveState.value = 'error'
exportSaveError.value = `\u6d4f\u89c8\u5668\u76ee\u5f55\u4fdd\u5b58\u5931\u8d25\uff1a${error?.message || '\u672a\u77e5\u9519\u8bef'}`
} finally {
exportSaveBusy.value = false
}
}
const stopSnsExportPolling = () => {
if (exportEventSource) {
try {
exportEventSource.close()
} catch {}
exportEventSource = null
}
if (exportPollTimer) {
clearInterval(exportPollTimer)
exportPollTimer = null
}
}
const startSnsExportHttpPolling = (exportId) => {
if (!exportId) return
stopSnsExportPolling()
exportPollTimer = setInterval(async () => {
try {
const resp = await api.getSnsExport(exportId)
exportJob.value = resp?.job || exportJob.value
const st = String(exportJob.value?.status || '')
if (st === 'done' || st === 'error' || st === 'cancelled') stopSnsExportPolling()
} catch {
// ignore transient errors
}
}, 1200)
}
const startSnsExportPolling = (exportId) => {
stopSnsExportPolling()
if (!exportId) return
if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') {
const url = `${apiBase}/sns/exports/${encodeURIComponent(String(exportId))}/events`
try {
exportEventSource = new EventSource(url)
exportEventSource.onmessage = (ev) => {
try {
const next = JSON.parse(String(ev.data || '{}'))
exportJob.value = next || exportJob.value
const st = String(exportJob.value?.status || '')
if (st === 'done' || st === 'error' || st === 'cancelled') stopSnsExportPolling()
} catch {}
}
exportEventSource.onerror = () => {
try {
exportEventSource?.close()
} catch {}
exportEventSource = null
if (!exportPollTimer) startSnsExportHttpPolling(exportId)
}
return
} catch {
exportEventSource = null
}
}
startSnsExportHttpPolling(exportId)
}
const ensureSnsExportFolderReady = () => {
if (hasSelectedExportFolder.value) return true
exportError.value = isDesktopExportRuntime() || isWebDirectoryPickerSupported()
? '\u8bf7\u5148\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55'
: '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668'
return false
}
const cancelSnsExportJob = async () => {
const exportId = String(exportJob.value?.exportId || '').trim()
if (!exportId || !canCancelSnsExport.value || isExportCancelling.value) return
exportError.value = ''
isExportCancelling.value = true
try {
await api.cancelSnsExport(exportId)
try {
const resp = await api.getSnsExport(exportId)
exportJob.value = resp?.job || exportJob.value
} catch {
// ignore refresh errors, polling/SSE will continue updating the job
}
} catch (e) {
exportError.value = e?.message || '取消导出任务失败'
isExportCancelling.value = false
}
}
const startSnsExport = async ({ scope, usernames, fileName } = {}) => {
if (!selectedAccount.value) return false
exportError.value = ''
isExportCancelling.value = false
resetExportSaveFeedback({ resetAutoSavedFor: true })
if (!ensureSnsExportFolderReady()) return false
const normalizedScope = String(scope || '').trim() === 'all' ? 'all' : 'selected'
const normalizedUsernames = normalizeExportSelectedUsernames(usernames)
if (normalizedScope === 'selected' && normalizedUsernames.length === 0) {
exportError.value = '请选择至少一个联系人'
return false
}
try {
const resp = await api.createSnsExport({
account: selectedAccount.value,
scope: normalizedScope,
usernames: normalizedUsernames,
format: exportFormat.value,
use_cache: snsUseCache.value ? 1 : 0,
output_dir: hasDesktopExportFolder.value ? String(exportFolder.value || '').trim() : null,
file_name: String(fileName || '').trim() || null
})
exportJob.value = resp?.job || null
const exportId = exportJob.value?.exportId
if (exportId) startSnsExportPolling(exportId)
return true
} catch (e) {
exportError.value = e?.message || '创建导出任务失败'
return false
}
}
const startSnsExportFromModal = async () => {
const usernames = normalizeExportSelectedUsernames(exportSelectedUsernames.value)
if (!usernames.length) {
exportError.value = '请选择至少一个联系人'
return
}
const created = await startSnsExport({
scope: 'selected',
usernames,
fileName: exportFileName.value
})
if (created) exportError.value = ''
}
// Track failed images per-post, per-index to render placeholders instead of broken <img>.
const mediaErrors = ref({})
const mediaErrorKey = (postId, idx) => `${String(postId || '')}:${String(idx || 0)}`
const hasMediaError = (postId, idx) => !!mediaErrors.value[mediaErrorKey(postId, idx)]
const onMediaError = (postId, idx) => {
mediaErrors.value[mediaErrorKey(postId, idx)] = true
}
// Article card thumbnail is best-effort: try SNS media thumb first, then fall back to
// extracting the cover from mp.weixin.qq.com HTML. Track per-post stage so we don't
// keep showing a broken <img>.
const articleThumbStage = ref({}) // postId -> 'proxy' | 'none'
const selfInfo = ref({ wxid: '', nickname: '' })
const loadSelfInfo = async () => {
if (!selectedAccount.value) return
try {
const resp = await $fetch(`${apiBase}/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`)
if (resp && resp.wxid) {
selfInfo.value = resp
}
} catch (e) {
await reportServerErrorFromError(e, {
method: 'GET',
requestUrl: `${apiBase}/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`,
source: 'sns.loadSelfInfo',
apiBase,
})
console.error('获取个人信息失败', e)
}
}
const loadSnsUsers = async () => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) {
snsUsers.value = []
return
}
try {
const resp = await api.listSnsUsers({ account: acc, limit: 5000 })
snsUsers.value = Array.isArray(resp?.items) ? resp.items : []
} catch (e) {
console.error('加载朋友圈联系人失败', e)
snsUsers.value = []
}
}
const selectSnsUser = async (username) => {
const next = String(username || '').trim()
if (selectedSnsUser.value === next) return
selectedSnsUser.value = next
if (previewCtx.value) closeImagePreview()
await loadPosts({ reset: true })
}
const getArticleThumbProxyUrl = (contentUrl) => {
const u = String(contentUrl || '').trim()
if (!u) return ''
return `${apiBase}/sns/article_thumb?url=${encodeURIComponent(u)}`
}
const guessOfficialAccountNameFromTitle = (title) => {
const t = String(title || '').trim()
if (!t) return ''
// Common patterns in Chinese titles: 《公众号名》, 「公众号名」, 【公众号名】
const m = /[《「【](.+?)[》」】]/.exec(t)
if (m && m[1]) return String(m[1]).trim()
return ''
}
const getArticleCardThumbCandidates = (post) => {
const list = Array.isArray(post?.media) ? post.media : []
const mediaSrc = list.length > 0 ? getMediaThumbSrc(post, list[0], 0) : ''
const proxySrc = getArticleThumbProxyUrl(post?.contentUrl)
return { mediaSrc, proxySrc }
}
const getArticleCardThumbSrc = (post) => {
const pid = String(post?.id || '').trim()
const { mediaSrc, proxySrc } = getArticleCardThumbCandidates(post)
const stage = String(articleThumbStage.value[pid] || '').trim()
if (stage === 'proxy') return proxySrc || ''
if (stage === 'none') return ''
return mediaSrc || proxySrc
}
const onArticleThumbError = (post) => {
const pid = String(post?.id || '').trim()
if (!pid) return
const { mediaSrc, proxySrc } = getArticleCardThumbCandidates(post)
const stage = String(articleThumbStage.value[pid] || '').trim()
if (stage === 'proxy') {
articleThumbStage.value[pid] = 'none'
return
}
// Default: try media first (if any), then fall back to proxy.
if (mediaSrc && proxySrc && mediaSrc !== proxySrc) {
articleThumbStage.value[pid] = 'proxy'
} else {
articleThumbStage.value[pid] = 'none'
}
}
const extractMpBizFromUrl = (contentUrl) => {
const u = String(contentUrl || '').trim()
if (!u) return ''
const m = /[?&]__biz=([^&#]+)/.exec(u)
if (!m?.[1]) return ''
try {
return decodeURIComponent(m[1])
} catch {
return String(m[1])
}
}
const getMomentOfficialAccount = (post) => {
const off = (post && typeof post.official === 'object' && post.official) ? post.official : null
const biz = String(off?.biz || extractMpBizFromUrl(post?.contentUrl) || '').trim()
const username = String(off?.username || '').trim()
const displayName = String(off?.displayName || '').trim() || guessOfficialAccountNameFromTitle(post?.title)
const st0 = off?.serviceType
const serviceType = (st0 === undefined || st0 === null || st0 === '') ? null : Number(st0)
return { biz, username, displayName, serviceType }
}
const getFinderFeedThumbSrc = (post) => {
const u = String(post?.finderFeed?.thumbUrl || '').trim()
if (!u) return ''
return getProxyExternalUrl(u)
}
const getMomentLinkCardUrl = (post) => {
const u = String(post?.contentUrl || '').trim()
if (u) return u
const list = Array.isArray(post?.media) ? post.media : []
const m0 = list.length > 0 ? list[0] : null
const u2 = String(m0?.url || '').trim()
return u2
}
const isExternalShareMoment = (post) => {
const t = Number(post?.type || 0)
return t === 42 || t === 5
}
const formatExternalShareUrlLabel = (url) => {
const u = String(url || '').trim()
if (!u) return ''
try {
const parsed = new URL(u)
const host = String(parsed.hostname || '').replace(/^www\\./, '')
const path = String(parsed.pathname || '')
const out = `${host}${path && path !== '/' ? path : ''}`
return out || u
} catch {
return u
}
}
const formatExternalSharePlaceholder = (post) => {
const t = Number(post?.type || 0)
if (t === 42) return '音乐'
return '链接'
}
const formatExternalShareCardTitle = (post) => {
const title = String(post?.title || '').trim()
if (title) return title
const u = String(getMomentLinkCardUrl(post) || '').trim()
if (u) return formatExternalShareUrlLabel(u)
const t = Number(post?.type || 0)
if (t === 42) return '音乐分享'
return '外部分享'
}
const getExternalShareCardThumbSrc = (post) => {
const pid = String(post?.id || '').trim()
if (!pid) return ''
const list = Array.isArray(post?.media) ? post.media : []
const m0 = list.length > 0 ? list[0] : null
if (!m0) return ''
if (hasMediaError(pid, 0)) return ''
return getMediaThumbSrc(post, m0, 0)
}
const onExternalShareCardThumbError = (post) => {
const pid = String(post?.id || '').trim()
if (!pid) return
onMediaError(pid, 0)
}
const formatFinderFeedCardText = (post) => {
const title = String(post?.title || '').trim()
if (title) return title
const desc = String(post?.finderFeed?.desc || '').trim()
if (desc) return desc.replace(/\s+/g, ' ')
const fallback = String(post?.contentDesc || '').trim()
return fallback ? fallback.replace(/\s+/g, ' ') : '视频号'
}
const formatMomentOfficialSource = (post) => {
if (Number(post?.type || 0) !== 3) return ''
const info = getMomentOfficialAccount(post)
// ServiceType: 1=服务号, 0=公众号 (when available). Fallbacks are best-effort.
const prefix = info.serviceType === 1 ? '服务号' : '公众号'
const name = String(info.displayName || '').trim()
return name ? `${prefix}·${name}` : prefix
}
const formatExternalShareSourceLabel = (post) => {
// Prefer DB-provided source name from Moments XML: `<appInfo><appName>...`
const n = String(post?.sourceName || '').trim()
if (n) return n
const url = String(getMomentLinkCardUrl(post) || '').trim()
if (!url) {
return Number(post?.type || 0) === 42 ? '音乐' : '外部分享'
}
return formatExternalShareUrlLabel(url)
}
const formatMomentTypeLabel = (post) => {
const t = Number(post?.type || 0)
if (!t) return ''
if (t === 3) return formatMomentOfficialSource(post)
if (t === 28) {
const name = String(post?.finderFeed?.nickname || '').trim()
return name ? `视频号·${name}` : '视频号'
}
if (isExternalShareMoment(post)) return formatExternalShareSourceLabel(post)
return ''
}
const onMomentTypeLabelClick = (post) => {
if (!process.client) return
const t = Number(post?.type || 0)
if (t !== 3) return
const info = getMomentOfficialAccount(post)
if (info.username) {
navigateTo(`/chat/${encodeURIComponent(info.username)}`)
return
}
// Fallback: open MP profile page by __biz
if (info.biz) {
const url = `https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=${encodeURIComponent(info.biz)}#wechat_redirect`
window.open(url, '_blank', 'noopener,noreferrer')
}
}
// Right-click context menu (copy text / JSON) to help debug SNS parsing issues.
const contextMenu = ref({ visible: false, x: 0, y: 0, post: null })
const closeContextMenu = () => {
contextMenu.value = { visible: false, x: 0, y: 0, post: null }
}
const openPostContextMenu = (e, post) => {
if (!process.client) return
e?.preventDefault?.()
e?.stopPropagation?.()
contextMenu.value = {
visible: true,
x: e?.clientX ?? 0,
y: e?.clientY ?? 0,
post
}
}
const copyTextToClipboard = async (text) => {
if (!process.client) return false
if (typeof text !== 'string') return false
try {
await navigator.clipboard.writeText(text)
return true
} catch {}
try {
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', 'true')
el.style.position = 'fixed'
el.style.left = '-9999px'
el.style.top = '-9999px'
document.body.appendChild(el)
el.select()
const ok = document.execCommand('copy')
document.body.removeChild(el)
return ok
} catch {
return false
}
}
const onCopyPostTextClick = async () => {
if (!process.client) return
const post = contextMenu.value.post
if (!post) return
try {
const text = String(post?.contentDesc || '').trim()
if (!text) {
window.alert('该朋友圈没有可复制的文本')
return
}
const ok = await copyTextToClipboard(text)
if (!ok) window.alert('复制失败:无法写入剪贴板')
} catch (e) {
console.error('复制失败:', e)
window.alert('复制失败')
} finally {
closeContextMenu()
}
}
const onCopyPostJsonClick = async () => {
if (!process.client) return
const post = contextMenu.value.post
if (!post) return
try {
const raw = toRaw(post) || post
const json = JSON.stringify(raw, (_k, v) => (typeof v === 'bigint' ? v.toString() : v), 2)
const ok = await copyTextToClipboard(json)
if (!ok) window.alert('复制失败:无法写入剪贴板')
} catch (e) {
console.error('复制失败:', e)
window.alert('复制失败')
} finally {
closeContextMenu()
}
}
const onScroll = (e) => {
const { scrollTop, clientHeight, scrollHeight } = e.target
if (scrollTop + clientHeight >= scrollHeight - 200) {
if (hasMore.value && !isLoading.value) {
loadPosts({ reset: false })
}
}
}
const postAvatarUrl = (username) => {
const acc = String(selectedAccount.value || '').trim()
const u = String(username || '').trim()
if (!acc || !u) return ''
return `${apiBase}/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(u)}`
}
const cleanLikeName = (v) => String(v ?? '').replace(/\u00A0/g, ' ').trim()
const formatLikes = (likes) => {
const arr = Array.isArray(likes) ? likes : []
const names = arr.map(cleanLikeName).filter(Boolean)
return names.join('、')
}
const normalizeMediaUrl = (u) => {
const raw = String(u || '').trim()
if (!raw) return ''
if (!/^https?:\/\//i.test(raw)) return raw
try {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
}
} catch {}
return raw
}
// WeFlow replaces http->https for SNS CDN URLs; do the same before proxying/fetching.
const upgradeTencentHttps = (u) => {
const raw = String(u || '').trim()
if (!raw) return ''
if (!/^http:\/\//i.test(raw)) return raw
try {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn') || host.endsWith('.tc.qq.com') || host.endsWith('.video.qq.com')) {
return raw.replace(/^http:\/\//i, 'https://')
}
} catch {}
return raw
}
const getSnsMediaUrl = (post, m, idx, rawUrl) => {
const raw = upgradeTencentHttps(String(rawUrl || '').trim())
if (!raw) return ''
const rawLower = raw.toLowerCase()
// If backend already provides a local media endpoint, rewrite it to the effective API base
// (so web builds with a custom API port still work).
if (rawLower.startsWith('/api/')) return `${apiBase}${raw.slice(4)}`
if (rawLower.startsWith('blob:') || rawLower.startsWith('data:')) return raw
// For Moments images/thumbnails, prefer a backend endpoint that can decrypt local cache.
if (/^https?:\/\//i.test(raw)) {
try {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn') || host.endsWith('.tc.qq.com')) {
const acc = String(selectedAccount.value || '').trim()
// Match WeFlow's image pipeline: use a stable URL + key/token and let the
// backend handle cache-first remote fetch/decrypt. Avoid attaching legacy
// local-match metadata to the main image path so browser caching can reuse
// the same request URL for list + preview.
const parts = new URLSearchParams()
if (acc) parts.set('account', acc)
const token = String(m?.token || m?.urlAttrs?.token || m?.thumbAttrs?.token || '').trim()
if (token) parts.set('token', token)
const key = String(m?.key || m?.urlAttrs?.key || m?.thumbAttrs?.key || '').trim()
if (key) parts.set('key', key)
parts.set('use_cache', snsUseCache.value ? '1' : '0')
// When cache is disabled, bust browser caching so backend really downloads+decrypts each time.
if (!snsUseCache.value) parts.set('_t', String(Date.now()))
// Bump this when changing the WeFlow-aligned image pipeline to avoid stale browser caches.
parts.set('v', '10')
parts.set('url', raw)
return `${apiBase}/sns/media?${parts.toString()}`
}
} catch {}
}
return normalizeMediaUrl(raw)
}
const getMediaThumbSrc = (post, m, idx = 0) => {
return getSnsMediaUrl(post, m, idx, m?.thumb || m?.url)
}
const getMediaPreviewSrc = (post, m, idx = 0) => {
// Align with WeFlow: preview reuses the same prepared image source as the grid
// instead of issuing a second "original image" request on click.
return getMediaThumbSrc(post, m, idx)
}
const getSnsVideoUrl = (postId, mediaId) => {
// 本地缓存视频
const acc = String(selectedAccount.value || '').trim()
if (!acc || !postId || !mediaId) return ''
return `${apiBase}/sns/video?account=${encodeURIComponent(acc)}&post_id=${encodeURIComponent(postId)}&media_id=${encodeURIComponent(mediaId)}`
}
const getSnsRemoteVideoSrc = (post, m) => {
// Remote mp4 (download+decrypt on backend; WeFlow compatible).
const acc = String(selectedAccount.value || '').trim()
const rawUrl = upgradeTencentHttps(String(m?.url || '').trim())
if (!acc || !rawUrl) return ''
const token = String(m?.token || m?.urlAttrs?.token || m?.thumbAttrs?.token || '').trim()
const key = String(m?.videoKey || m?.key || m?.urlAttrs?.key || '').trim()
const parts = new URLSearchParams()
parts.set('account', acc)
parts.set('url', rawUrl)
if (token) parts.set('token', token)
if (key) parts.set('key', key)
parts.set('use_cache', snsUseCache.value ? '1' : '0')
// When cache is disabled, bust browser caching so backend really downloads+decrypts each time.
if (!snsUseCache.value) parts.set('_t', String(Date.now()))
parts.set('v', '1')
return `${apiBase}/sns/video_remote?${parts.toString()}`
}
const localVideoStatus = ref({})
const videoStatusKey = (postId, mediaId) => `${String(postId)}:${String(mediaId)}`
const onLocalVideoLoaded = (postId, mediaId) => {
localVideoStatus.value[videoStatusKey(postId, mediaId)] = 'loaded'
}
const onLocalVideoError = (postId, mediaId) => {
localVideoStatus.value[videoStatusKey(postId, mediaId)] = 'error'
}
const isLocalVideoLoaded = (postId, mediaId) => {
return localVideoStatus.value[videoStatusKey(postId, mediaId)] === 'loaded'
}
// 实况(Live Photo):鼠标悬停播放远程解密视频
const activeLivePhotoKey = ref('')
const livePhotoVideoErrors = ref({})
const livePhotoHoverVideoEl = ref(null)
const livePhotoHoverMuted = ref(false)
const livePhotoKey = (postId, idx) => `${String(postId || '')}:${String(idx || 0)}`
const isLivePhotoMedia = (m) => {
const lp = m?.livePhoto
return !!(lp && typeof lp === 'object' && String(lp?.url || '').trim())
}
const isLivePhotoActive = (postId, idx) => activeLivePhotoKey.value === livePhotoKey(postId, idx)
const hasLivePhotoVideoError = (postId, idx) => !!livePhotoVideoErrors.value[livePhotoKey(postId, idx)]
const playLivePhotoHoverVideo = async ({ allowFallbackMute } = { allowFallbackMute: true }) => {
if (!process.client) return
const k = String(activeLivePhotoKey.value || '')
if (!k) return
await nextTick()
if (activeLivePhotoKey.value !== k) return
const el = livePhotoHoverVideoEl.value
if (!el) return
el.muted = !!livePhotoHoverMuted.value
try {
el.volume = livePhotoHoverMuted.value ? 0 : 1
} catch {}
try {
await el.play()
} catch {
if (allowFallbackMute && !livePhotoHoverMuted.value) {
livePhotoHoverMuted.value = true
await nextTick()
if (activeLivePhotoKey.value !== k) return
const el2 = livePhotoHoverVideoEl.value
if (!el2) return
el2.muted = true
try {
el2.volume = 0
} catch {}
try {
await el2.play()
} catch {}
}
}
}
const toggleLivePhotoHoverMuted = () => {
livePhotoHoverMuted.value = !livePhotoHoverMuted.value
void playLivePhotoHoverVideo({ allowFallbackMute: false })
}
const onLivePhotoEnter = (postId, idx, m) => {
if (!isLivePhotoMedia(m)) return
if (hasLivePhotoVideoError(postId, idx)) return
activeLivePhotoKey.value = livePhotoKey(postId, idx)
livePhotoHoverMuted.value = false
void playLivePhotoHoverVideo({ allowFallbackMute: true })
}
const onLivePhotoLeave = (postId, idx, m) => {
if (!isLivePhotoMedia(m)) return
const k = livePhotoKey(postId, idx)
if (activeLivePhotoKey.value === k) activeLivePhotoKey.value = ''
}
const onLivePhotoVideoError = (postId, idx) => {
const k = livePhotoKey(postId, idx)
livePhotoVideoErrors.value[k] = true
if (activeLivePhotoKey.value === k) activeLivePhotoKey.value = ''
}
const getLivePhotoVideoSrc = (post, m, idx = 0) => {
const acc = String(selectedAccount.value || '').trim()
const lp = (m && typeof m === 'object') ? m.livePhoto : null
const rawUrl = upgradeTencentHttps(String(lp?.url || '').trim())
if (!acc || !rawUrl) return ''
const token = String(lp?.token || m?.token || m?.urlAttrs?.token || '').trim()
const key = String(lp?.key || m?.videoKey || '').trim()
const parts = new URLSearchParams()
parts.set('account', acc)
parts.set('url', rawUrl)
if (token) parts.set('token', token)
if (key) parts.set('key', key)
parts.set('use_cache', snsUseCache.value ? '1' : '0')
// When cache is disabled, bust browser caching so backend really downloads+decrypts each time.
if (!snsUseCache.value) parts.set('_t', String(Date.now()))
// Version bump for frontend cache busting when endpoint changes.
parts.set('v', '1')
return `${apiBase}/sns/video_remote?${parts.toString()}`
}
// 图片预览
const previewCtx = ref(null) // { post, media, idx }
const previewSrc = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
})
const previewVideoEl = ref(null)
const previewVideoMode = ref('') // 'local' | 'remote' | 'raw'
const previewVideoError = ref('')
const previewVideoTried = reactive({ local: false, remote: false, raw: false })
const resetPreviewVideo = () => {
previewVideoMode.value = ''
previewVideoError.value = ''
previewVideoTried.local = false
previewVideoTried.remote = false
previewVideoTried.raw = false
}
const previewIsVideo = computed(() => {
const ctx = previewCtx.value
if (!ctx) return false
return Number(ctx.media?.type || 0) === 6
})
const previewVideoPoster = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
if (Number(ctx.media?.type || 0) !== 6) return ''
return getMediaThumbSrc(ctx.post, ctx.media, ctx.idx) || ''
})
const previewVideoSrc = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
if (Number(ctx.media?.type || 0) !== 6) return ''
const local = getSnsVideoUrl(ctx.post?.id, ctx.media?.id)
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
const mode = String(previewVideoMode.value || '').toLowerCase()
if (mode === 'local') return local
if (mode === 'remote') return remote
if (mode === 'raw') return raw
return local || remote || raw || ''
})
const previewVideoKey = computed(() => {
if (!previewIsVideo.value) return ''
return `${String(previewVideoMode.value || '')}:${String(previewVideoSrc.value || '')}`
})
const previewLivePhotoVideoSrc = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
if (!isLivePhotoMedia(ctx.media)) return ''
return getLivePhotoVideoSrc(ctx.post, ctx.media, ctx.idx)
})
const previewLiveVideoEl = ref(null)
const previewLivePhotoMuted = ref(false)
const previewHasLivePhotoVideoError = computed(() => {
const ctx = previewCtx.value
if (!ctx) return false
if (!isLivePhotoMedia(ctx.media)) return false
return hasLivePhotoVideoError(ctx.post?.id, ctx.idx)
})
const playPreviewLiveVideo = async ({ allowFallbackMute } = { allowFallbackMute: true }) => {
if (!process.client) return
await nextTick()
const el = previewLiveVideoEl.value
if (!el) return
el.muted = !!previewLivePhotoMuted.value
try {
el.volume = previewLivePhotoMuted.value ? 0 : 1
} catch {}
try {
// Autoplay with sound may be blocked by browser policies; we fallback to muted playback so preview still animates.
await el.play()
} catch (e) {
if (allowFallbackMute && !previewLivePhotoMuted.value) {
previewLivePhotoMuted.value = true
await nextTick()
const el2 = previewLiveVideoEl.value
if (!el2) return
el2.muted = true
try {
el2.volume = 0
} catch {}
try {
await el2.play()
} catch {}
}
}
}
const togglePreviewLivePhotoMuted = () => {
previewLivePhotoMuted.value = !previewLivePhotoMuted.value
void playPreviewLiveVideo({ allowFallbackMute: false })
}
const onPreviewLivePhotoVideoError = () => {
const ctx = previewCtx.value
if (!ctx) return
onLivePhotoVideoError(ctx.post?.id, ctx.idx)
}
watch(
() => previewLivePhotoVideoSrc.value,
(src) => {
if (!src) return
previewLivePhotoMuted.value = false
void playPreviewLiveVideo({ allowFallbackMute: true })
}
)
const openImagePreview = (post, m, idx = 0) => {
if (!process.client) return
resetPreviewVideo()
// Stop any background hover-playing live photo when opening the preview.
activeLivePhotoKey.value = ''
// Preview is an intentional action; allow retry even if hover playback failed once.
if (isLivePhotoMedia(m)) {
const k = livePhotoKey(post?.id, idx)
if (k) {
try {
delete livePhotoVideoErrors.value[k]
} catch {}
}
}
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
document.body.style.overflow = 'hidden'
}
const openVideoPreview = (post, m, idx = 0) => {
if (!process.client) return
resetPreviewVideo()
activeLivePhotoKey.value = ''
const local = getSnsVideoUrl(post?.id, m?.id)
const remote = getSnsRemoteVideoSrc(post, m)
const raw = upgradeTencentHttps(String(m?.url || '').trim())
if (local) previewVideoMode.value = 'local'
else if (remote) previewVideoMode.value = 'remote'
else if (raw) previewVideoMode.value = 'raw'
else previewVideoError.value = '视频地址缺失。'
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
document.body.style.overflow = 'hidden'
}
const onPreviewVideoError = () => {
const ctx = previewCtx.value
if (!ctx) return
if (Number(ctx.media?.type || 0) !== 6) return
const current = String(previewVideoMode.value || '').toLowerCase()
if (current === 'local') previewVideoTried.local = true
if (current === 'remote') previewVideoTried.remote = true
if (current === 'raw') previewVideoTried.raw = true
// Fallback order: local -> remote -> raw
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
if (!previewVideoTried.remote && remote) {
previewVideoMode.value = 'remote'
return
}
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
if (!previewVideoTried.raw && raw) {
previewVideoMode.value = 'raw'
return
}
previewVideoError.value = '视频加载失败:可能是本地缓存不存在,或远程下载/解密失败。'
}
const closeImagePreview = () => {
if (!process.client) return
previewCtx.value = null
resetPreviewVideo()
document.body.style.overflow = ''
}
const onMediaClick = (post, m, idx = 0) => {
if (!process.client) return
const mt = Number(m?.type || 0)
// 视频点击逻辑
if (mt === 6) {
openVideoPreview(post, m, idx)
return
}
// 图片:打开预览
openImagePreview(post, m, idx)
}
const formatRelativeTime = (tsSeconds) => {
const t = Number(tsSeconds || 0)
if (!t) return ''
const now = Date.now()
const diff = Math.max(0, Math.floor((now - t * 1000) / 1000))
if (diff < 60) return '刚刚'
const mins = Math.floor(diff / 60)
if (mins < 60) return `${mins}分钟前`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}天前`
const months = Math.floor(days / 30)
if (months < 12) return `${months}个月前`
const years = Math.floor(months / 12)
return `${years}年前`
}
const loadAccounts = async () => {
error.value = ''
await chatAccounts.ensureLoaded({ force: true })
if (!selectedAccount.value) {
error.value = chatAccounts.error || '未检测到已解密账号,请先解密数据库。'
}
}
const loadPosts = async ({ reset }) => {
if (!selectedAccount.value) return
if (isLoading.value) return
error.value = ''
isLoading.value = true
try {
if (reset) {
timelineOffset.value = 0
hasMore.value = true
cachePagingExhausted.value = false
seenPostIds.clear()
posts.value = []
if (process.client && timelineScrollEl.value) {
try {
timelineScrollEl.value.scrollTop = 0
} catch {}
}
}
const offset = reset ? 0 : Number(timelineOffset.value || 0)
const resp = await api.listSnsTimeline({
account: selectedAccount.value,
limit: pageSize,
offset,
usernames: selectedSnsUser.value ? [String(selectedSnsUser.value).trim()] : []
})
const items = Array.isArray(resp?.timeline) ? resp.timeline : []
// Advance offset by the number of rows consumed by the backend.
// When `hasMore` is true, the backend definitely scanned at least `limit` raw rows (even if it filtered some out).
// When `hasMore` is false, we're at the end, so advance by the actual returned count.
const limitUsed = Number(resp?.limit || pageSize) || pageSize
timelineOffset.value = offset + (resp?.hasMore ? limitUsed : items.length)
const nextItems = []
for (const p of items) {
if (!p || p.type === 7) continue
const pid = String(p.id || p.tid || '').trim()
if (pid) {
if (seenPostIds.has(pid)) continue
seenPostIds.add(pid)
}
nextItems.push(p)
}
if (reset) {
posts.value = nextItems
coverData.value = resp?.cover || null
const cs = Array.isArray(resp?.covers) ? resp.covers : []
covers.value = cs.length > 0 ? cs : (resp?.cover ? [resp.cover] : [])
coverIndex.value = 0
} else {
posts.value = [...posts.value, ...nextItems]
}
// Keep sidebar count from lagging behind what we've already loaded (useful when sqlite snapshot is incomplete).
const selUname = String(selectedSnsUser.value || '').trim()
if (selUname && Array.isArray(snsUsers.value) && snsUsers.value.length > 0) {
const idx = snsUsers.value.findIndex((u) => String(u?.username || '').trim() === selUname)
if (idx >= 0) {
const cur = Number(snsUsers.value[idx]?.postCount || 0) || 0
if (posts.value.length > cur) {
const nextUsers = [...snsUsers.value]
nextUsers[idx] = { ...nextUsers[idx], postCount: posts.value.length }
snsUsers.value = nextUsers
}
}
}
const backendHasMore = !!resp?.hasMore
if (!backendHasMore && items.length === 0) {
cachePagingExhausted.value = true
}
const cachedTotal = selUname ? (Number(selectedSnsUserInfo.value?.postCount || 0) || 0) : 0
const shown = Array.isArray(posts.value) ? posts.value.length : 0
const allowCachePaging = !cachePagingExhausted.value && cachedTotal > 0 && shown < cachedTotal
hasMore.value = backendHasMore || allowCachePaging
} catch (e) {
error.value = e?.message || '加载朋友圈失败'
} finally {
isLoading.value = false
// Auto-trigger next page when we're already near bottom (e.g. first page too short to scroll,
// or we need to continue paging from cache after WCDB "visible subset" ends).
if (process.client) {
setTimeout(async () => {
try {
await nextTick()
} catch {}
if (error.value) return
if (isLoading.value || !hasMore.value) return
const el = timelineScrollEl.value
if (!el) return
const { scrollTop, clientHeight, scrollHeight } = el
if (scrollTop + clientHeight >= scrollHeight - 200) {
loadPosts({ reset: false })
}
}, 0)
}
}
}
watch(
() => selectedAccount.value,
async (v, oldV) => {
if (v && v !== oldV) {
stopSnsExportPolling()
exportJob.value = null
exportError.value = ''
isExportCancelling.value = false
exportModalOpen.value = false
exportFileName.value = ''
exportSearchQuery.value = ''
exportSelectedUsernames.value = []
resetExportSaveFeedback({ resetAutoSavedFor: true })
snsUserQuery.value = ''
selectedSnsUser.value = ''
snsUsers.value = []
snsAvatarErrors.value = {}
activeLivePhotoKey.value = ''
livePhotoVideoErrors.value = {}
if (previewCtx.value) closeImagePreview()
await loadSelfInfo()
await loadSnsUsers()
await loadPosts({ reset: true })
}
},
{ immediate: true }
)
watch(
() => ({
exportId: String(exportJob.value?.exportId || ''),
status: String(exportJob.value?.status || '')
}),
async ({ exportId, status }) => {
if (!process.client || status !== 'done' || !exportId) return
if (!hasWebExportFolder.value) return
if (exportAutoSavedFor.value === exportId) return
if (exportSaveBusy.value) return
await saveSnsExportToSelectedFolder({ auto: true })
}
)
watch(
() => ({
exportId: String(exportJob.value?.exportId || ''),
status: String(exportJob.value?.status || '')
}),
({ exportId, status }, prev) => {
if (!exportId) {
isExportCancelling.value = false
return
}
if (exportId !== String(prev?.exportId || '')) {
isExportCancelling.value = false
return
}
if (status !== 'queued' && status !== 'running') {
isExportCancelling.value = false
}
}
)
onMounted(async () => {
privacyStore.init()
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
await loadAccounts()
})
const onGlobalClick = () => {
if (contextMenu.value.visible) closeContextMenu()
}
const onGlobalKeyDown = (e) => {
if (!process.client) return
if (String(e?.key || '') === 'Escape') {
if (exportModalOpen.value) {
closeExportModal()
return
}
if (previewCtx.value) closeImagePreview()
if (contextMenu.value.visible) closeContextMenu()
}
}
onMounted(() => {
if (!process.client) return
document.addEventListener('click', onGlobalClick)
document.addEventListener('keydown', onGlobalKeyDown)
})
onUnmounted(() => {
if (!process.client) return
stopSnsExportPolling()
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
})
const getProxyExternalUrl = (url) => {
// 目前难以计算enc,代理获取封面图(thumbnail
const u = String(url || '').trim()
if (!u) return ''
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(u)}`
}
</script>