import { ref } from 'vue' import { fetchAuthenticatedBlob } from '@/api/index' import type { ChatAttachment } from '@/types' /** * 统一受保护附件 Loader * * 项目使用 Bearer token 认证(localStorage), 不会自动携带 * Authorization header。此 composable 统一处理鉴权 fetch → blob → ObjectURL。 * * 使用方式: * - 图片:loadAllImages() 批量加载 → blobUrls[key] 绑定 * - 文件:downloadFile() 点击时鉴权下载 * - 图片点击放大:openImage() 同步开窗避免弹窗拦截 * - 组件卸载:revokeAll() 释放所有 blob URL */ export function useAuthenticatedAttachment() { /** 已加载的 blob URL 映射(key = storedName || url) */ const blobUrls = ref>({}) /** 跟踪所有创建的 blob URL,用于清理 */ const trackedUrls: string[] = [] /** 正在加载的 URL 集合,防止重复请求 */ const loading = new Set() /** * 加载单个附件的 blob URL */ async function loadBlobUrl(url: string, key: string): Promise { // 跳过:已加载、正在加载、本地 blob if (blobUrls.value[key] || loading.has(key) || url.startsWith('blob:')) return blobUrls.value[key] || url loading.add(key) try { const blob = await fetchAuthenticatedBlob(url) const objectUrl = URL.createObjectURL(blob) blobUrls.value[key] = objectUrl trackedUrls.push(objectUrl) return objectUrl } catch (e) { console.warn('[useAuthenticatedAttachment] Failed to load:', url, e) return null } finally { loading.delete(key) } } /** * 批量加载所有图片附件的 blob URL */ async function loadAllImages(attachments: ChatAttachment[]) { const imageAtts = attachments.filter(a => a.contentType?.startsWith('image/')) for (const att of imageAtts) { const key = att.storedName || att.url if (!att.previewUrl && att.url && !blobUrls.value[key]) { await loadBlobUrl(att.url, key) } } } /** * 批量加载所有视频附件的 blob URL */ async function loadAllVideos(attachments: ChatAttachment[]) { const videoAtts = attachments.filter(a => a.contentType?.startsWith('video/')) for (const att of videoAtts) { const key = att.storedName || att.url if (!att.previewUrl && att.url && !blobUrls.value[key]) { await loadBlobUrl(att.url, key) } } } /** * 鉴权下载文件:fetch blob → 创建临时 → 触发点击 */ async function downloadFile(attachment: ChatAttachment) { try { const blob = await fetchAuthenticatedBlob(attachment.url) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = attachment.name || 'download' document.body.appendChild(a) a.click() document.body.removeChild(a) // 延迟释放,确保下载已启动 setTimeout(() => URL.revokeObjectURL(url), 10000) } catch (e) { console.error('[useAuthenticatedAttachment] Download failed:', attachment.name, e) // fallback:直接打开 URL(可能 401 但至少给个反馈) window.open(attachment.url, '_blank') } } /** * 鉴权打开图片到新标签页。 * 同步 window.open 空白页(绕过弹窗拦截),异步写入 blob URL。 */ async function openImage(url: string) { if (url.startsWith('blob:')) { window.open(url, '_blank') return } // 同步开窗:必须在用户点击的同步调用栈中,否则被浏览器拦截 const win = window.open('about:blank', '_blank') if (!win) return try { const blob = await fetchAuthenticatedBlob(url) const blobUrl = URL.createObjectURL(blob) win.location.href = blobUrl // 页面关闭后释放 setTimeout(() => URL.revokeObjectURL(blobUrl), 300000) } catch { win.location.href = url // fallback } } /** * 获取附件的显示 URL(优先 blob → previewUrl → 原始 url) */ function getDisplayUrl(attachment: ChatAttachment): string { const key = attachment.storedName || attachment.url return blobUrls.value[key] || attachment.previewUrl || attachment.url || '' } /** * 释放所有跟踪的 blob URL */ function revokeAll() { for (const url of trackedUrls) { URL.revokeObjectURL(url) } trackedUrls.length = 0 blobUrls.value = {} } return { blobUrls, loadBlobUrl, loadAllImages, loadAllVideos, downloadFile, openImage, getDisplayUrl, revokeAll, } }