mateclaw/mateclaw-ui/src/composables/useAuthenticatedAttachment.ts

149 lines
4.6 KiB
TypeScript
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.

import { ref } from 'vue'
import { fetchAuthenticatedBlob } from '@/api/index'
import type { ChatAttachment } from '@/types'
/**
* 统一受保护附件 Loader
*
* 项目使用 Bearer token 认证localStorage<img src> 和 <a href> 不会自动携带
* Authorization header。此 composable 统一处理鉴权 fetch → blob → ObjectURL。
*
* 使用方式:
* - 图片loadAllImages() 批量加载 → blobUrls[key] 绑定 <img :src>
* - 文件downloadFile() 点击时鉴权下载
* - 图片点击放大openImage() 同步开窗避免弹窗拦截
* - 组件卸载revokeAll() 释放所有 blob URL
*/
export function useAuthenticatedAttachment() {
/** 已加载的 blob URL 映射key = storedName || url */
const blobUrls = ref<Record<string, string>>({})
/** 跟踪所有创建的 blob URL用于清理 */
const trackedUrls: string[] = []
/** 正在加载的 URL 集合,防止重复请求 */
const loading = new Set<string>()
/**
* 加载单个附件的 blob URL
*/
async function loadBlobUrl(url: string, key: string): Promise<string | null> {
// 跳过:已加载、正在加载、本地 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 → 创建临时 <a download> → 触发点击
*/
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,
}
}