Files
WeChatDataAnalysis/frontend/pages/sns.vue
2026-02-13 19:56:13 +08:00

1031 lines
38 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="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<!-- 右侧朋友圈区域 -->
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<div class="flex-1 overflow-auto min-h-0">
<div class="max-w-2xl mx-auto px-4 py-4">
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-2">{{ error }}</div>
<div v-else-if="isLoading && posts.length === 0" class="text-sm text-gray-500 py-2">加载中</div>
<div v-else-if="posts.length === 0" class="text-sm text-gray-500 py-2">暂无朋友圈数据</div>
<!-- 图片匹配提示实验功能 -->
<div v-if="!error" class="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<div class="font-medium">图片匹配实验功能</div>
<div class="mt-1 leading-5">
图片可能会出现错配或无法显示点击图片进入预览可在候选匹配中手动选择你的选择会保存在本机并在下次优先使用
</div>
<label class="mt-2 flex items-start gap-2 select-none">
<input v-model="snsAvoidOtherPicked" type="checkbox" class="mt-[2px]" />
<span class="leading-5">
自动匹配时避开已被你手动指定到其他动态的图片降低重复
</span>
</label>
</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 }"
>
{{ post.contentDesc }}
</div>
<div v-if="post.type === 3" class="mt-2 max-w-[360px]" :class="{ 'privacy-blur': privacyMode }">
<a :href="post.contentUrl" target="_blank" class="block bg-gray-100 p-2 rounded-sm border border-gray-200 no-underline hover:bg-gray-200 transition-colors">
<div class="flex items-center gap-3">
<img
v-if="post.contentUrl && !hasArticleThumbError(post.id)"
:src="getArticleThumbProxyUrl(post.contentUrl)"
class="w-12 h-12 object-cover flex-shrink-0 bg-white"
alt=""
@error="onArticleThumbError(post.id)"
/>
<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 flex flex-col justify-between overflow-hidden h-12">
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{{ post.title }}</div>
</div>
</div>
<div class="text-[11px] text-[#576b95] mt-1 pt-1 border-t border-gray-200/50">
公众号文章分享
</div>
</a>
</div>
<div v-else-if="post.type === 28 && post.finderFeed && Object.keys(post.finderFeed).length > 0" class="mt-2 max-w-[360px]" :class="{ 'privacy-blur': privacyMode }">
<div class="block bg-gray-100 p-2 rounded-sm border border-gray-200 no-underline hover:bg-gray-200 transition-colors">
<!-- 浏览器没有看微信视频号的环境暂时不进行跳转-->
<div class="flex items-start gap-3">
<div class="relative w-14 h-16 flex-shrink-0 bg-black overflow-hidden rounded-sm">
<img
v-if="post.finderFeed.thumbUrl"
:src="getProxyExternalUrl(post.finderFeed.thumbUrl)"
class="w-full h-full object-cover opacity-80"
alt="finder cover"
/>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="flex-1 flex flex-col overflow-hidden">
<div class="text-xs text-gray-500 truncate">{{ post.finderFeed.nickname }}</div>
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2 mt-[2px]">{{ post.finderFeed.desc || post.title }}</div>
</div>
</div>
<div class="text-[11px] text-[#576b95] mt-1 pt-1 border-t border-gray-200/50">
视频号 · 动态
</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"
@click.stop="onMediaClick(post, post.media[0], 0)"
>
<img
: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"
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
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"
@click.stop="onMediaClick(post, m, idx)"
>
<img
v-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" 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>
</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">
<span class="text-xs text-gray-400" :class="{ 'privacy-blur': privacyMode }">{{ formatRelativeTime(post.createTime) }}</span>
</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 }">: {{ String(c?.content || '').trim() }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="hasMore" class="py-2">
<button
type="button"
class="w-full text-sm text-gray-600 py-2 rounded bg-white hover:bg-gray-50 border border-gray-200"
:disabled="isLoading"
@click="loadPosts({ reset: false })"
>
{{ isLoading ? '加载中…' : '加载更多' }}
</button>
</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>
<!-- 图片预览弹窗 + 候选匹配选择 -->
<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>
<img :src="previewSrc" alt="预览" class="max-w-[90vw] max-h-[70vh] object-contain" />
<!-- 候选匹配面板仅在本地缓存匹配时有意义 -->
<div class="mt-3 w-full max-w-[90vw] rounded bg-black/35 text-white text-xs px-3 py-2">
<div class="flex items-center justify-between gap-2">
<div class="truncate">
候选匹配
<span v-if="previewCandidates.loading">加载中</span>
<span v-else-if="previewCandidates.count > 0"> {{ previewCandidates.count }} </span>
<span v-else>未找到本地候选可能仅能显示占位图</span>
<span v-if="previewEffectiveIdx != null" class="ml-2 text-white/80">当前#{{ Number(previewEffectiveIdx) + 1 }}</span>
<span v-if="previewHasUserOverride" class="ml-2 text-emerald-200">(已保存)</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
@click="toggleCandidatePanel"
>
{{ previewCandidatesOpen ? '收起' : '展开' }}
</button>
<button
v-if="previewHasUserOverride"
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
@click="clearUserOverrideForPreview"
>
恢复自动
</button>
</div>
</div>
<div v-if="previewCandidates.error" class="mt-2 text-red-200 whitespace-pre-wrap">
{{ previewCandidates.error }}
</div>
<div v-if="previewCandidatesOpen && previewCandidates.count > 0" class="mt-2">
<div class="flex gap-2 overflow-x-auto pb-1">
<button
v-for="cand in previewCandidates.items"
:key="cand.idx"
type="button"
class="flex-shrink-0 w-24"
@click="selectCandidateForPreview(cand.idx)"
>
<div class="w-24 h-24 rounded bg-black/20 overflow-hidden border border-white/10">
<img :src="getPreviewCandidateSrc(cand.idx)" class="w-full h-full object-cover" alt="" />
</div>
<div class="mt-1 text-[11px] text-white/80">#{{ Number(cand.idx) + 1 }}</div>
</button>
</div>
<div v-if="previewCandidates.hasMore" class="mt-2">
<button
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
:disabled="previewCandidates.loadingMore"
@click="loadMorePreviewCandidates"
>
{{ previewCandidates.loadingMore ? '加载中…' : '加载更多候选' }}
</button>
</div>
</div>
</div>
</div>
<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'
useHead({ title: '朋友圈 - 微信数据分析助手' })
const api = useApi()
const chatAccounts = useChatAccountsStore()
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
const privacyStore = usePrivacyStore()
const { privacyMode } = storeToRefs(privacyStore)
const posts = ref([])
const hasMore = ref(true)
const isLoading = ref(false)
const error = ref('')
const pageSize = 20
const mediaBase = process.client ? 'http://localhost:8000' : ''
// User overrides for SNS image matching (account-local, stored in localStorage).
const SNS_MEDIA_OVERRIDE_PREFIX = 'sns_media_override:v1:'
const SNS_MEDIA_OVERRIDE_REV_PREFIX = 'sns_media_override_rev:v1:'
const snsMediaOverrides = ref({})
const snsMediaOverrideRev = ref('0')
const snsOverrideStorageKey = (account) => `${SNS_MEDIA_OVERRIDE_PREFIX}${String(account || '').trim()}`
const snsOverrideRevStorageKey = (account) => `${SNS_MEDIA_OVERRIDE_REV_PREFIX}${String(account || '').trim()}`
const snsOverrideMediaKey = (postId, idx) => `${String(postId || '')}:${String(Number(idx) || 0)}`
const loadSnsMediaOverrides = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) {
snsMediaOverrides.value = {}
snsMediaOverrideRev.value = '0'
return
}
try {
const raw = localStorage.getItem(snsOverrideStorageKey(acc))
const parsed = raw ? JSON.parse(raw) : {}
snsMediaOverrides.value = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
snsMediaOverrides.value = {}
}
try {
const rev = localStorage.getItem(snsOverrideRevStorageKey(acc))
snsMediaOverrideRev.value = String(rev || '0')
} catch {
snsMediaOverrideRev.value = '0'
}
}
const saveSnsMediaOverrides = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
localStorage.setItem(snsOverrideStorageKey(acc), JSON.stringify(snsMediaOverrides.value || {}))
} catch {}
try {
localStorage.setItem(snsOverrideRevStorageKey(acc), String(snsMediaOverrideRev.value || '0'))
} catch {}
}
// Settings: avoid auto-using an image that was manually pinned to another SNS post.
const SNS_SNS_SETTINGS_PREFIX = 'sns_settings:v1:'
const snsAvoidOtherPicked = ref(true)
const snsAvoidOtherPickedStorageKey = (account) => `${SNS_SNS_SETTINGS_PREFIX}${String(account || '').trim()}:avoid_other_picked`
const loadSnsSettings = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
const raw = localStorage.getItem(snsAvoidOtherPickedStorageKey(acc))
if (raw == null || raw === '') return
snsAvoidOtherPicked.value = raw === '1' || raw === 'true'
} catch {}
}
const saveSnsSettings = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
localStorage.setItem(snsAvoidOtherPickedStorageKey(acc), snsAvoidOtherPicked.value ? '1' : '0')
} catch {}
}
const syncSnsMediaPicksToBackend = async () => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
await api.saveSnsMediaPicks({ account: acc, picks: snsMediaOverrides.value || {} })
} catch {}
}
const getSnsMediaOverridePick = (postId, idx) => {
const key = snsOverrideMediaKey(postId, idx)
const v = snsMediaOverrides.value?.[key]
return String(v || '').trim()
}
const setSnsMediaOverridePick = (postId, idx, pick) => {
if (!process.client) return
const key = snsOverrideMediaKey(postId, idx)
const v = String(pick || '').trim()
if (!v) {
if (snsMediaOverrides.value && Object.prototype.hasOwnProperty.call(snsMediaOverrides.value, key)) {
delete snsMediaOverrides.value[key]
}
} else {
snsMediaOverrides.value[key] = v
}
saveSnsMediaOverrides()
// Keep backend in sync so it can apply duplicate-avoidance logic.
// Then bump `pv` so other auto-matched images reload using the updated picks.
void syncSnsMediaPicksToBackend().finally(() => {
snsMediaOverrideRev.value = String(Date.now())
saveSnsMediaOverrides()
})
}
// 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
}
const articleThumbErrors = ref({})
const hasArticleThumbError = (postId) => !!articleThumbErrors.value[postId]
const onArticleThumbError = (postId) => {
articleThumbErrors.value[postId] = true
}
// (原有的函数保持不变)
const getArticleThumbProxyUrl = (contentUrl) => {
const u = String(contentUrl || '').trim()
if (!u) return ''
return `${mediaBase}/api/sns/article_thumb?url=${encodeURIComponent(u)}`
}
// 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 postAvatarUrl = (username) => {
const acc = String(selectedAccount.value || '').trim()
const u = String(username || '').trim()
if (!acc || !u) return ''
return `${mediaBase}/api/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 `${mediaBase}/api/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')) {
return raw.replace(/^http:\/\//i, 'https://')
}
} catch {}
return raw
}
const normalizeHex32 = (value) => {
const raw = String(value ?? '').trim()
if (!raw) return ''
const hex = raw.replace(/[^0-9a-fA-F]/g, '').toLowerCase()
return hex.length >= 32 ? hex.slice(0, 32) : ''
}
const mediaSizeKey = (m) => {
const t = String(m?.type ?? '')
const w = String(m?.size?.width || m?.size?.w || '').trim()
const h = String(m?.size?.height || m?.size?.h || '').trim()
if (!w || !h) return ''
return `${t}:${w}x${h}`
}
// Our backend matches SNS cache images by width/height and then uses `idx` to
// pick the N-th match. `idx` must be the index within the same size-group,
// not the global media index in the post, otherwise images can shift.
const mediaSizeGroupIndex = (post, m, idx) => {
const list = Array.isArray(post?.media) ? post.media : []
const key = mediaSizeKey(m)
const i0 = Number(idx) || 0
if (!key || i0 <= 0) return i0
let count = 0
for (let i = 0; i < i0; i++) {
if (mediaSizeKey(list[i]) === key) count++
}
return count
}
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, keep it as-is.
if (rawLower.startsWith('/api/') || 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()
const ct = String(post?.createTime || '').trim()
const w = String(m?.size?.width || m?.size?.w || '').trim()
const h = String(m?.size?.height || m?.size?.h || '').trim()
const ts = String(m?.size?.totalSize || m?.size?.total_size || m?.size?.total || '').trim()
const sizeIdx = mediaSizeGroupIndex(post, m, idx)
const pick = getSnsMediaOverridePick(post?.id, idx)
let md5 = normalizeHex32(m?.urlAttrs?.md5 || m?.thumbAttrs?.md5 || m?.urlAttrs?.MD5 || m?.thumbAttrs?.MD5)
if (!md5) {
const match = /[?&]md5=([0-9a-fA-F]{16,32})/.exec(raw)
if (match?.[1]) md5 = normalizeHex32(match[1])
}
const parts = new URLSearchParams()
if (acc) parts.set('account', acc)
if (ct) parts.set('create_time', ct)
if (w) parts.set('width', w)
if (h) parts.set('height', h)
if (/^\d+$/.test(ts)) parts.set('total_size', ts)
parts.set('idx', String(Number(sizeIdx) || 0))
const pid = String(post?.id || '').trim()
if (pid) parts.set('post_id', pid)
const mid = String(m?.id || '').trim()
if (mid) parts.set('media_id', mid)
const mtype = String(m?.type || '').trim()
if (mtype) parts.set('media_type', mtype)
if (pick) parts.set('pick', pick)
if (!pick && snsAvoidOtherPicked.value) {
parts.set('avoid_picked', '1')
parts.set('pv', String(snsMediaOverrideRev.value || '0'))
}
if (md5) parts.set('md5', md5)
// Bump this when changing backend matching logic to avoid stale cached wrong images.
parts.set('v', '7')
parts.set('url', raw)
return `${mediaBase}/api/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) => {
return getSnsMediaUrl(post, m, idx, m?.url || m?.thumb)
}
// 图片预览 + 候选匹配选择
const previewCtx = ref(null) // { post, media, idx }
const previewCandidatesOpen = ref(false)
const previewCandidates = reactive({
loading: false,
loadingMore: false,
error: '',
items: [],
count: 0,
hasMore: false
})
const resetPreviewCandidates = () => {
previewCandidates.loading = false
previewCandidates.loadingMore = false
previewCandidates.error = ''
previewCandidates.items = []
previewCandidates.count = 0
previewCandidates.hasMore = false
}
const previewSrc = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
})
const previewHasUserOverride = computed(() => {
const ctx = previewCtx.value
if (!ctx) return false
return !!getSnsMediaOverridePick(ctx.post?.id, ctx.idx)
})
const previewEffectiveIdx = computed(() => {
const ctx = previewCtx.value
if (!ctx) return null
const pick = getSnsMediaOverridePick(ctx.post?.id, ctx.idx)
if (pick) {
const found = (previewCandidates.items || []).find((c) => String(c?.key || '') === pick)
if (found) return Number(found.idx)
return null
}
const baseIdx = mediaSizeGroupIndex(ctx.post, ctx.media, ctx.idx)
if (!snsAvoidOtherPicked.value) return baseIdx
const curPid = String(ctx.post?.id || '').trim()
if (!curPid) return baseIdx
// Mirror backend logic: skip candidates that were manually pinned to other posts.
const reserved = new Set()
try {
for (const [k, v] of Object.entries(snsMediaOverrides.value || {})) {
const pid = String(k || '').split(':', 1)[0].trim()
if (!pid || pid === curPid) continue
const key = String(v || '').trim()
if (key) reserved.add(key)
}
} catch {}
const items = Array.isArray(previewCandidates.items) ? [...previewCandidates.items] : []
items.sort((a, b) => Number(a?.idx || 0) - Number(b?.idx || 0))
for (const c of items) {
const i = Number(c?.idx)
const key = String(c?.key || '').trim()
if (!Number.isFinite(i) || i < baseIdx) continue
if (!key) continue
if (!reserved.has(key)) return i
}
return baseIdx
})
const toggleCandidatePanel = () => {
previewCandidatesOpen.value = !previewCandidatesOpen.value
}
const loadPreviewCandidates = async ({ reset }) => {
const ctx = previewCtx.value
if (!ctx) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
const toInt = (v) => Number.parseInt(String(v || '').trim(), 10) || 0
const w = toInt(ctx.media?.size?.width || ctx.media?.size?.w)
const h = toInt(ctx.media?.size?.height || ctx.media?.size?.h)
// Without dimensions, local matching is too noisy; keep it empty.
if (w <= 0 || h <= 0) {
resetPreviewCandidates()
return
}
const limit = 24
const offset = reset ? 0 : (previewCandidates.items?.length || 0)
if (reset) {
resetPreviewCandidates()
previewCandidates.loading = true
} else {
previewCandidates.loadingMore = true
}
previewCandidates.error = ''
try {
const resp = await api.listSnsMediaCandidates({
account: acc,
create_time: Number(ctx.post?.createTime || 0),
width: w,
height: h,
limit,
offset
})
const items = Array.isArray(resp?.items) ? resp.items : []
previewCandidates.count = Number(resp?.count || 0)
previewCandidates.hasMore = !!resp?.hasMore
if (reset) {
previewCandidates.items = items
} else {
previewCandidates.items = [...(previewCandidates.items || []), ...items]
}
} catch (e) {
previewCandidates.error = e?.message || '加载候选失败'
} finally {
previewCandidates.loading = false
previewCandidates.loadingMore = false
}
}
const openImagePreview = async (post, m, idx = 0) => {
if (!process.client) return
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
previewCandidatesOpen.value = false
resetPreviewCandidates()
document.body.style.overflow = 'hidden'
// Load the first page so we can show the candidate count in the header.
await loadPreviewCandidates({ reset: true })
}
const closeImagePreview = () => {
if (!process.client) return
previewCtx.value = null
previewCandidatesOpen.value = false
resetPreviewCandidates()
document.body.style.overflow = ''
}
const getPreviewCandidateSrc = (candIdx) => {
const ctx = previewCtx.value
const acc = String(selectedAccount.value || '').trim()
if (!ctx || !acc) return ''
const idxNum = Number(candIdx)
const cand = (previewCandidates.items || []).find((c) => Number(c?.idx) === idxNum)
const key = String(cand?.key || '').trim()
if (!key) return ''
const parts = new URLSearchParams()
parts.set('account', acc)
parts.set('pick', key)
const ct = String(ctx.post?.createTime || '').trim()
if (ct) parts.set('create_time', ct)
parts.set('v', '7')
return `${mediaBase}/api/sns/media?${parts.toString()}`
}
const selectCandidateForPreview = (candIdx) => {
const ctx = previewCtx.value
if (!ctx) return
const idxNum = Number(candIdx)
const cand = (previewCandidates.items || []).find((c) => Number(c?.idx) === idxNum)
const key = String(cand?.key || '').trim()
if (!key) return
setSnsMediaOverridePick(ctx.post?.id, ctx.idx, key)
// Allow <img> to retry after user switches candidates.
try {
delete mediaErrors.value[mediaErrorKey(ctx.post?.id, ctx.idx)]
} catch {}
}
const clearUserOverrideForPreview = () => {
const ctx = previewCtx.value
if (!ctx) return
setSnsMediaOverridePick(ctx.post?.id, ctx.idx, '')
try {
delete mediaErrors.value[mediaErrorKey(ctx.post?.id, ctx.idx)]
} catch {}
}
const loadMorePreviewCandidates = async () => {
if (previewCandidates.loading || previewCandidates.loadingMore) return
if (!previewCandidates.hasMore) return
await loadPreviewCandidates({ reset: false })
}
const onMediaClick = (post, m, idx = 0) => {
if (!process.client) return
const mt = Number(m?.type || 0)
// 视频:打开视频链接(新窗口),图片:打开预览
if (mt === 6) {
const u = String(m?.url || '').trim()
if (u) window.open(u, '_blank', 'noopener,noreferrer')
return
}
// Open preview overlay; it also loads local candidates for manual selection.
void 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 {
const offset = reset ? 0 : posts.value.length
const resp = await api.listSnsTimeline({
account: selectedAccount.value,
limit: pageSize,
offset
})
const items = resp?.timeline || []
if (reset) {
posts.value = items
} else {
posts.value = [...posts.value, ...items]
}
hasMore.value = !!resp?.hasMore
} catch (e) {
error.value = e?.message || '加载朋友圈失败'
} finally {
isLoading.value = false
}
}
watch(
() => selectedAccount.value,
async (v, oldV) => {
if (v && v !== oldV) {
// Account switch: reload overrides and reset preview state.
loadSnsMediaOverrides()
loadSnsSettings()
void syncSnsMediaPicksToBackend()
if (previewCtx.value) closeImagePreview()
await loadPosts({ reset: true })
} else if (!v) {
snsMediaOverrides.value = {}
}
}
)
watch(
() => snsAvoidOtherPicked.value,
() => {
saveSnsSettings()
}
)
onMounted(async () => {
privacyStore.init()
await loadAccounts()
loadSnsMediaOverrides()
loadSnsSettings()
void syncSnsMediaPicksToBackend()
})
const onGlobalClick = () => {
if (contextMenu.value.visible) closeContextMenu()
}
const onGlobalKeyDown = (e) => {
if (!process.client) return
if (String(e?.key || '') === 'Escape') {
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
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
})
const getProxyExternalUrl = (url) => {
// 目前难以计算enc代理获取封面图thumbnail
const u = String(url || '').trim()
if (!u) return ''
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
}
</script>