372372 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.5 5.5a8 8 0 010 13" />
373373 </svg>
374374 </button>
375+
376+ <button
377+ type="button"
378+ class="absolute bottom-2 right-2 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-black/45 text-white opacity-0 transition group-hover:opacity-100 hover:bg-[#07c160]"
379+ title="下载"
380+ aria-label="下载朋友圈媒体"
381+ @click.stop="downloadSnsMedia(post, post.media[0], 0)"
382+ >
383+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
384+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v11m0 0l4-4m-4 4l-4-4M5 21h14" />
385+ </svg>
386+ </button>
375387 </div>
376388 <div
377389 v-else
462474 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.5 5.5a8 8 0 010 13" />
463475 </svg>
464476 </button>
477+
478+ <button
479+ type="button"
480+ class="absolute bottom-1.5 right-1.5 z-20 flex h-6 w-6 items-center justify-center rounded-full bg-black/45 text-white opacity-0 transition group-hover:opacity-100 hover:bg-[#07c160]"
481+ title="下载"
482+ aria-label="下载朋友圈媒体"
483+ @click.stop="downloadSnsMedia(post, m, idx)"
484+ >
485+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
486+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v11m0 0l4-4m-4 4l-4-4M5 21h14" />
487+ </svg>
488+ </button>
465489 </div>
466490 </div>
467491 </div>
@@ -2050,10 +2074,11 @@ const mediaSizeGroupIndex = (post, m, idx) => {
20502074 return count
20512075}
20522076
2053- const getSnsMediaUrl = (post, m, idx, rawUrl) => {
2077+ const getSnsMediaUrl = (post, m, idx, rawUrl, options = {} ) => {
20542078 const raw = upgradeTencentHttps(String(rawUrl || '').trim())
20552079 if (!raw) return ''
20562080 const rawLower = raw.toLowerCase()
2081+ const preferFull = !!options?.preferFull
20572082
20582083 // If backend already provides a local media endpoint, rewrite it to the effective API base
20592084 // (so web builds with a custom API port still work).
@@ -2098,7 +2123,7 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
20982123 if (mediaType) parts.set('media_type', mediaType)
20992124
21002125 const thumbCandidate = String(m?.thumb || m?.thumbUrl || '').trim()
2101- const isThumbRequest = !!thumbCandidate && raw === upgradeTencentHttps(thumbCandidate)
2126+ const isThumbRequest = (!preferFull) && !!thumbCandidate && raw === upgradeTencentHttps(thumbCandidate)
21022127 const token = String(
21032128 isThumbRequest
21042129 ? (m?.thumbToken || m?.thumbUrlToken || m?.thumbAttrs?.token || m?.token || m?.urlAttrs?.token || '')
@@ -2117,8 +2142,9 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
21172142 // When cache is disabled, bust browser caching so backend really downloads+decrypts each time.
21182143 if (!snsUseCache.value) parts.set('_t', String(Date.now()))
21192144 if (md5) parts.set('md5', md5)
2145+ if (preferFull) parts.set('variant', 'full')
21202146 // 修改后端媒体匹配逻辑时递增版本号,避免浏览器复用旧的错误缓存。
2121- parts.set('v', '11')
2147+ parts.set('v', preferFull ? '12' : '11')
21222148 parts.set('url', raw)
21232149 return `${apiBase}/sns/media?${parts.toString()}`
21242150 }
@@ -2133,7 +2159,82 @@ const getMediaThumbSrc = (post, m, idx = 0) => {
21332159}
21342160
21352161const getMediaPreviewSrc = (post, m, idx = 0) => {
2136- return getSnsMediaUrl(post, m, idx, m?.url || m?.thumb || m?.thumbUrl)
2162+ return getSnsMediaUrl(post, m, idx, m?.url || m?.originUrl || m?.originalUrl || m?.thumb || m?.thumbUrl, { preferFull: true })
2163+ }
2164+
2165+ const inferSnsDownloadExt = (blob, url, isVideo = false) => {
2166+ const type = String(blob?.type || '').toLowerCase()
2167+ if (type.includes('mp4')) return 'mp4'
2168+ if (type.includes('quicktime')) return 'mov'
2169+ if (type.includes('webm')) return 'webm'
2170+ if (type.includes('png')) return 'png'
2171+ if (type.includes('webp')) return 'webp'
2172+ if (type.includes('gif')) return 'gif'
2173+ if (type.includes('jpeg') || type.includes('jpg')) return 'jpg'
2174+ try {
2175+ const pathname = new URL(String(url || ''), window.location.href).pathname
2176+ const m = pathname.match(/\.([a-z0-9]{2,5})$/i)
2177+ if (m?.[1]) return m[1].toLowerCase()
2178+ } catch {}
2179+ return isVideo ? 'mp4' : 'jpg'
2180+ }
2181+
2182+ const makeSnsDownloadName = (post, m, idx, ext) => {
2183+ const ts = Number(post?.createTime || 0)
2184+ const baseTime = ts > 0 ? new Date(ts * 1000) : new Date()
2185+ const pad2 = (n) => String(n).padStart(2, '0')
2186+ const stamp = `${baseTime.getFullYear()}${pad2(baseTime.getMonth() + 1)}${pad2(baseTime.getDate())}_${pad2(baseTime.getHours())}${pad2(baseTime.getMinutes())}${pad2(baseTime.getSeconds())}`
2187+ const mediaId = String(m?.id || m?.mediaId || '').trim().replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 24)
2188+ const suffix = mediaId || String(Number(idx) || 0)
2189+ return `sns_${stamp}_${suffix}.${ext || 'jpg'}`
2190+ }
2191+
2192+ const triggerBrowserDownload = (blob, filename) => {
2193+ if (!process.client || typeof document === 'undefined') return false
2194+ const objectUrl = URL.createObjectURL(blob)
2195+ const a = document.createElement('a')
2196+ a.href = objectUrl
2197+ a.download = filename
2198+ document.body.appendChild(a)
2199+ a.click()
2200+ document.body.removeChild(a)
2201+ setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
2202+ return true
2203+ }
2204+
2205+ const downloadSnsMedia = async (post, m, idx = 0) => {
2206+ if (!process.client) return
2207+ const isVideo = Number(m?.type || 0) === 6
2208+ const candidates = [
2209+ isVideo ? getSnsRemoteVideoSrc(post, m) : '',
2210+ getMediaPreviewSrc(post, m, idx),
2211+ getMediaThumbSrc(post, m, idx),
2212+ normalizeMediaUrl(upgradeTencentHttps(String(m?.url || m?.originUrl || m?.originalUrl || m?.thumb || '').trim()))
2213+ ].map((u) => String(u || '').trim()).filter(Boolean)
2214+
2215+ const seen = new Set()
2216+ const urls = candidates.filter((u) => {
2217+ if (seen.has(u)) return false
2218+ seen.add(u)
2219+ return true
2220+ })
2221+ if (!urls.length) return
2222+
2223+ for (const url of urls) {
2224+ try {
2225+ const resp = await fetch(url)
2226+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
2227+ const blob = await resp.blob()
2228+ if (!blob || !blob.size) throw new Error('empty media')
2229+ const ext = inferSnsDownloadExt(blob, url, isVideo)
2230+ triggerBrowserDownload(blob, makeSnsDownloadName(post, m, idx, ext))
2231+ return
2232+ } catch {}
2233+ }
2234+
2235+ try {
2236+ window.open(urls[0], '_blank', 'noopener,noreferrer')
2237+ } catch {}
21372238}
21382239
21392240const commentImageErrors = ref({})
0 commit comments