diff --git a/frontend/components/LivePhotoIcon.vue b/frontend/components/LivePhotoIcon.vue new file mode 100644 index 0000000..79ae903 --- /dev/null +++ b/frontend/components/LivePhotoIcon.vue @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index 956e6e1..fe84ac3 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -236,6 +236,16 @@ export const useApi = () => { return await request(url) } + // 朋友圈联系人列表(按发圈数统计) + const listSnsUsers = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.keyword) query.set('keyword', String(params.keyword)) + if (params && params.limit != null) query.set('limit', String(params.limit)) + const url = '/sns/users' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + // 朋友圈图片本地缓存候选(用于错图时手动选择) const listSnsMediaCandidates = async (params = {}) => { const query = new URLSearchParams() @@ -356,6 +366,31 @@ export const useApi = () => { return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) } + // 朋友圈导出(离线 HTML zip) + const createSnsExport = async (data = {}) => { + return await request('/sns/exports', { + method: 'POST', + body: { + account: data.account || null, + scope: data.scope || 'selected', + usernames: Array.isArray(data.usernames) ? data.usernames : [], + use_cache: data.use_cache == null ? true : !!data.use_cache, + output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(), + file_name: data.file_name || null + } + }) + } + + const getSnsExport = async (exportId) => { + if (!exportId) throw new Error('Missing exportId') + return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`) + } + + const cancelSnsExport = async (exportId) => { + if (!exportId) throw new Error('Missing exportId') + return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) + } + // 联系人 const listChatContacts = async (params = {}) => { const query = new URLSearchParams() @@ -454,6 +489,7 @@ export const useApi = () => { resolveNestedChatHistory, resolveAppMsg, listSnsTimeline, + listSnsUsers, listSnsMediaCandidates, saveSnsMediaPicks, openChatMediaFolder, @@ -465,6 +501,9 @@ export const useApi = () => { getChatExport, listChatExports, cancelChatExport, + createSnsExport, + getSnsExport, + cancelSnsExport, listChatContacts, exportChatContacts, getWrappedAnnual, diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index 14d231f..c7ca3d5 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -1,17 +1,154 @@ + + + + + 朋友圈联系人 + {{ snsUsers.length }} + + + + + + 导出全部 + + + 导出此人 + + + {{ exportError }} + + 导出状态:{{ exportJob.status }} + + 下载 ZIP + + + + + + + 全 + 全部 + + + + + + + {{ (u.displayName || u.username || '友').charAt(0) }} + + + + + {{ u.displayName || u.username }} + + {{ u.username }} + · + + + {{ posts.length }} + /{{ u.postCount || 0 }} + 条 + + + {{ u.postCount || 0 }} 条 + + + + + + + - + + + + {{ formatCoverTime(activeCover.createTime) }} + + · {{ coverIndex + 1 }}/{{ covers.length }} + + + + + + + + + + + + + + @@ -41,6 +178,16 @@ 暂无朋友圈数据 + + 已显示:{{ posts.length }} + 缓存统计:{{ selectedSnsUserInfo.postCount || 0 }} + source: {{ timelineSource }} + (已到末尾) + + + 提示:左侧“缓存统计”来自解密后的 sns.db;当前 timeline 接口只返回可见部分,所以会出现 + {{ posts.length }}/{{ selectedSnsUserInfo?.postCount || 0 }}。 + @@ -71,55 +218,60 @@ class="mt-1 text-sm text-gray-900 leading-6 whitespace-pre-wrap break-words" :class="{ 'privacy-blur': privacyMode }" > - {{ post.contentDesc }} + + {{ seg.content }} + + - - + + 文章 - + {{ post.title }} - - 公众号文章分享 - - - - - - + + + + + + {{ formatFinderFeedCardText(post) }} + + + + 视频 + - + + + - - {{ post.finderFeed.nickname }} - {{ post.finderFeed.desc || post.title }} - - - - 视频号 · 动态 @@ -130,10 +282,12 @@ 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)" + @mouseenter="onLivePhotoEnter(post.id, 0, post.media[0])" + @mouseleave="onLivePhotoLeave(post.id, 0, post.media[0])" > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -221,7 +452,23 @@ - {{ formatRelativeTime(post.createTime) }} + + {{ formatRelativeTime(post.createTime) }} + {{ formatMomentTypeLabel(post) }} + {{ formatMomentTypeLabel(post) }} + @@ -262,7 +509,12 @@ {{ cleanLikeName(c?.refNickname || c?.refUsername || c?.refUserName || '') }} - : {{ String(c?.content || '').trim() }} + : + + {{ seg.content }} + + + @@ -302,10 +554,39 @@ @click="closeImagePreview" > - + + + + + + + + + + + + + + { + 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 selectedSnsUserInfo = computed(() => { + const uname = String(selectedSnsUser.value || '').trim() + if (!uname) return null + const list = Array.isArray(snsUsers.value) ? snsUsers.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 = Array.isArray(snsUsers.value) ? snsUsers.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 mediaBase = process.client ? 'http://localhost:8000' : '' +// 朋友圈导出(HTML 离线 ZIP) +const exportJob = ref(null) +const exportError = ref('') +let exportEventSource = null +let exportPollTimer = null + +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 base = 'http://localhost:8000' + const url = `${base}/api/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 downloadSnsExport = (exportId) => { + if (!process.client) return + const id = String(exportId || '').trim() + if (!id) return + const base = 'http://localhost:8000' + const url = `${base}/api/sns/exports/${encodeURIComponent(id)}/download` + window.open(url, '_blank', 'noopener,noreferrer') +} + +const onExportAllClick = async () => { + if (!selectedAccount.value) return + exportError.value = '' + try { + const resp = await api.createSnsExport({ + account: selectedAccount.value, + scope: 'all', + usernames: [], + use_cache: snsUseCache.value ? 1 : 0 + }) + exportJob.value = resp?.job || null + const exportId = exportJob.value?.exportId + if (exportId) startSnsExportPolling(exportId) + } catch (e) { + exportError.value = e?.message || '创建导出任务失败' + } +} + +const onExportCurrentClick = async () => { + if (!selectedAccount.value) return + const uname = String(selectedSnsUser.value || '').trim() + if (!uname) return + exportError.value = '' + try { + const resp = await api.createSnsExport({ + account: selectedAccount.value, + scope: 'selected', + usernames: [uname], + use_cache: snsUseCache.value ? 1 : 0 + }) + exportJob.value = resp?.job || null + const exportId = exportJob.value?.exportId + if (exportId) startSnsExportPolling(exportId) + } catch (e) { + exportError.value = e?.message || '创建导出任务失败' + } +} + // Track failed images per-post, per-index to render placeholders instead of broken . const mediaErrors = ref({}) @@ -353,13 +826,10 @@ 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 -} +// 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 . +const articleThumbStage = ref({}) // postId -> 'proxy' | 'none' const selfInfo = ref({ wxid: '', nickname: '' }) @@ -375,12 +845,159 @@ const loadSelfInfo = async () => { } } +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 `${mediaBase}/api/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 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 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}` : '视频号' + } + 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 }) @@ -508,7 +1125,7 @@ const upgradeTencentHttps = (u) => { 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')) { + 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 {} @@ -589,10 +1206,19 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => { const mediaType = String(m?.type || '2').trim() if (mediaType) parts.set('media_type', mediaType) + 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())) 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('v', '9') parts.set('url', raw) return `${mediaBase}/api/sns/media?${parts.toString()}` } @@ -618,6 +1244,27 @@ const getSnsVideoUrl = (postId, mediaId) => { return `${mediaBase}/api/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 `${mediaBase}/api/sns/video_remote?${parts.toString()}` +} + const localVideoStatus = ref({}) const videoStatusKey = (postId, mediaId) => `${String(postId)}:${String(mediaId)}` @@ -635,6 +1282,105 @@ 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 `${mediaBase}/api/sns/video_remote?${parts.toString()}` +} + // 图片预览 + 候选匹配选择 const previewCtx = ref(null) // { post, media, idx } const previewCandidatesOpen = ref(false) @@ -662,6 +1408,74 @@ const previewSrc = computed(() => { return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx) }) +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 loadPreviewCandidates = async ({ reset }) => { const ctx = previewCtx.value @@ -717,6 +1531,17 @@ const loadPreviewCandidates = async ({ reset }) => { const openImagePreview = async (post, m, idx = 0) => { if (!process.client) return + // 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 } previewCandidatesOpen.value = false resetPreviewCandidates() @@ -739,14 +1564,14 @@ const onMediaClick = (post, m, idx = 0) => { // 视频点击逻辑 if (mt === 6) { - // 1. 如果本地缓存加载成功,永远不请求 CDN!直接在新标签页打开本地的高清完整视频 - if (isLocalVideoLoaded(post.id, m.id)) { - const localUrl = getSnsVideoUrl(post.id, m.id) - window.open(localUrl, '_blank', 'noopener,noreferrer') + // Open a playable mp4 via backend (downloads+decrypts as needed). + const remoteUrl = getSnsRemoteVideoSrc(post, m) + if (remoteUrl) { + window.open(remoteUrl, '_blank', 'noopener,noreferrer') return } - // 2. 如果本地没有缓存,按原逻辑 fallback 到 CDN + // Last-resort: open raw CDN url. const u = String(m?.url || '').trim() if (u) window.open(u, '_blank', 'noopener,noreferrer') return @@ -788,25 +1613,100 @@ const loadPosts = async ({ reset }) => { error.value = '' isLoading.value = true try { - const offset = reset ? 0 : posts.value.length + if (reset) { + timelineOffset.value = 0 + timelineSource.value = '' + 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 + offset, + usernames: selectedSnsUser.value ? [String(selectedSnsUser.value).trim()] : [] }) - const items = resp?.timeline || [] + timelineSource.value = String(resp?.source || '').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 = items.filter(p => p.type !== 7) + 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, ...items.filter(p => p.type !== 7)] + posts.value = [...posts.value, ...nextItems] } - hasMore.value = !!resp?.hasMore + + // 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) + } } } @@ -815,8 +1715,17 @@ watch( () => selectedAccount.value, async (v, oldV) => { if (v && v !== oldV) { + stopSnsExportPolling() + exportJob.value = null + exportError.value = '' + snsUserQuery.value = '' + selectedSnsUser.value = '' + snsUsers.value = [] + activeLivePhotoKey.value = '' + livePhotoVideoErrors.value = {} if (previewCtx.value) closeImagePreview() await loadSelfInfo() + await loadSnsUsers() await loadPosts({ reset: true }) } }, @@ -826,6 +1735,7 @@ watch( onMounted(async () => { privacyStore.init() + snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true) await loadAccounts() }) @@ -849,6 +1759,7 @@ onMounted(() => { onUnmounted(() => { if (!process.client) return + stopSnsExportPolling() document.removeEventListener('click', onGlobalClick) document.removeEventListener('keydown', onGlobalKeyDown) })