Files
ai-agent-admin/web/apps/web-ele/src/composables/useFileUrl.ts
T
2026-06-08 18:14:59 +08:00

372 lines
8.5 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.
/**
* 文件URL管理 Composable
* 用于管理文件的临时访问URL,支持缓存和自动刷新
*
* 支持两种访问模式:
* - 私有文件:需要临时访问令牌,URL会自动缓存和刷新
* - 公开文件:直接返回URL,无需认证
*/
import { ref, watch } from 'vue';
import { getFileUrlWithToken, getPublicFileUrl } from '#/api/core/file';
// 全局URL缓存(临时令牌URL)
const urlCache = new Map<string, { expiresAt: number; url: string }>();
// 全局 Blob ObjectURL 缓存(图片等二进制内容,长期有效)
const blobCache = new Map<string, string>();
// 正在进行的请求去重(避免同一 fileId 并发重复请求)
const pendingRequests = new Map<string, Promise<string>>();
// Blob 缓存最大条目数,防止内存无限增长
const BLOB_CACHE_MAX_SIZE = 500;
// 默认过期时间(秒)
const DEFAULT_EXPIRES_IN = 3600;
// 提前刷新时间(秒)- 在过期前5分钟刷新
const REFRESH_BEFORE = 300;
/**
* 清理过期的缓存
*/
function cleanupExpiredCache() {
const now = Date.now();
for (const [key, value] of urlCache.entries()) {
if (value.expiresAt < now) {
urlCache.delete(key);
}
}
}
/**
* 获取公开文件的URL(无需认证,同步返回)
* @param fileId 文件ID
* @returns string 文件访问URL
*/
export function getFileUrlPublic(fileId: string): string {
if (!fileId) return '';
return getPublicFileUrl(fileId);
}
/**
* 获取临时令牌URL(内部方法,不含 Blob 缓存)
*/
async function getTokenUrl(
fileId: string,
expiresIn: number = DEFAULT_EXPIRES_IN,
): Promise<string> {
const now = Date.now();
const cached = urlCache.get(fileId);
if (cached && cached.expiresAt - REFRESH_BEFORE * 1000 > now) {
return cached.url;
}
const result = await getFileUrlWithToken(fileId, expiresIn);
const expiresAt = new Date(result.expiresAt).getTime();
const url = `/basic-api${result.url}`;
urlCache.set(fileId, { url, expiresAt });
return url;
}
/**
* 获取文件的访问URL(带 Blob 缓存 + 令牌URL缓存)
*
* 缓存策略:
* 1. 如果 Blob 缓存命中(ObjectURL),直接返回,零网络请求
* 2. 否则获取临时令牌URLfetch 下载为 Blob,生成 ObjectURL 缓存
* 3. 如果 fetch 失败,降级返回临时令牌URL
* 4. 并发请求同一 fileId 会自动去重
*
* @param fileId 文件ID
* @param expiresIn 过期时间(秒)
* @returns Promise<string> 文件访问URL
*/
export async function getFileUrl(
fileId: string,
expiresIn: number = DEFAULT_EXPIRES_IN,
): Promise<string> {
if (!fileId) return '';
// 1. Blob 缓存命中,直接返回
const blobUrl = blobCache.get(fileId);
if (blobUrl) {
return blobUrl;
}
// 2. 去重:如果已有相同 fileId 的请求在进行中,等待它完成
const pending = pendingRequests.get(fileId);
if (pending) {
return pending;
}
// 3. 发起新请求
const request = (async () => {
try {
const tokenUrl = await getTokenUrl(fileId, expiresIn);
// 尝试 fetch 并缓存为 Blob ObjectURL
try {
const response = await fetch(tokenUrl);
if (response.ok) {
const blob = await response.blob();
// 限制缓存大小:超出时清理最早的条目
if (blobCache.size >= BLOB_CACHE_MAX_SIZE) {
const firstKey = blobCache.keys().next().value;
if (firstKey) {
const oldUrl = blobCache.get(firstKey);
if (oldUrl) URL.revokeObjectURL(oldUrl);
blobCache.delete(firstKey);
}
}
const objectUrl = URL.createObjectURL(blob);
blobCache.set(fileId, objectUrl);
return objectUrl;
}
} catch {
// fetch 失败,降级返回令牌URL
}
return tokenUrl;
} catch (error) {
console.error('获取文件URL失败:', error);
const cached = urlCache.get(fileId);
if (cached) return cached.url;
return '';
} finally {
pendingRequests.delete(fileId);
}
})();
pendingRequests.set(fileId, request);
return request;
}
/**
* 批量获取文件URL
* @param fileIds 文件ID数组
* @param expiresIn 过期时间(秒)
* @returns Promise<Map<string, string>> 文件ID到URL的映射
*/
export async function getFileUrls(
fileIds: string[],
expiresIn: number = DEFAULT_EXPIRES_IN,
): Promise<Map<string, string>> {
const result = new Map<string, string>();
const promises = fileIds.map(async (fileId) => {
const url = await getFileUrl(fileId, expiresIn);
result.set(fileId, url);
});
await Promise.all(promises);
return result;
}
/**
* 清除文件URL缓存
* @param fileId 可选,指定文件ID;不传则清除所有缓存
*/
export function clearFileUrlCache(fileId?: string) {
if (fileId) {
urlCache.delete(fileId);
const blobUrl = blobCache.get(fileId);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobCache.delete(fileId);
}
} else {
urlCache.clear();
for (const url of blobCache.values()) {
URL.revokeObjectURL(url);
}
blobCache.clear();
}
}
/**
* useFileUrl Composable
* 用于在组件中响应式地获取文件URL
*
* @example
* ```vue
* <script setup>
* const { url, loading, refresh } = useFileUrl(fileId);
* </script>
* <template>
* <img v-if="!loading" :src="url" />
* </template>
* ```
*/
export function useFileUrl(
fileId: (() => string | undefined) | string | undefined,
options: {
expiresIn?: number;
immediate?: boolean;
} = {},
) {
const { expiresIn = DEFAULT_EXPIRES_IN, immediate = true } = options;
const url = ref('');
const loading = ref(false);
const error = ref<Error | null>(null);
const getFileIdValue = () => {
if (typeof fileId === 'function') {
return fileId();
}
return fileId;
};
async function refresh() {
const id = getFileIdValue();
if (!id) {
url.value = '';
return;
}
loading.value = true;
error.value = null;
try {
// 清除缓存以强制刷新
clearFileUrlCache(id);
url.value = await getFileUrl(id, expiresIn);
} catch (error_) {
error.value = error_ as Error;
console.error('获取文件URL失败:', error_);
} finally {
loading.value = false;
}
}
async function load() {
const id = getFileIdValue();
if (!id) {
url.value = '';
return;
}
loading.value = true;
error.value = null;
try {
url.value = await getFileUrl(id, expiresIn);
} catch (error_) {
error.value = error_ as Error;
console.error('获取文件URL失败:', error_);
} finally {
loading.value = false;
}
}
// 监听fileId变化
if (typeof fileId === 'function') {
watch(fileId, (newId) => {
if (newId) {
load();
} else {
url.value = '';
}
});
}
// 立即加载
if (immediate) {
load();
}
return {
url,
loading,
error,
refresh,
load,
};
}
/**
* useFileUrls Composable
* 用于批量获取多个文件的URL
*/
export function useFileUrls(
fileIds: (() => string[]) | string[],
options: {
expiresIn?: number;
immediate?: boolean;
} = {},
) {
const { expiresIn = DEFAULT_EXPIRES_IN, immediate = true } = options;
const urls = ref<Map<string, string>>(new Map());
const loading = ref(false);
const error = ref<Error | null>(null);
const getFileIdsValue = () => {
if (typeof fileIds === 'function') {
return fileIds();
}
return fileIds;
};
async function load() {
const ids = getFileIdsValue();
if (!ids || ids.length === 0) {
urls.value = new Map();
return;
}
loading.value = true;
error.value = null;
try {
urls.value = await getFileUrls(ids, expiresIn);
} catch (error_) {
error.value = error_ as Error;
console.error('批量获取文件URL失败:', error_);
} finally {
loading.value = false;
}
}
async function refresh() {
const ids = getFileIdsValue();
ids.forEach((id) => clearFileUrlCache(id));
await load();
}
// 监听fileIds变化
if (typeof fileIds === 'function') {
watch(fileIds, () => {
load();
});
}
// 立即加载
if (immediate) {
load();
}
/**
* 获取单个文件的URL
*/
function getUrl(fileId: string): string {
return urls.value.get(fileId) || '';
}
return {
urls,
loading,
error,
refresh,
load,
getUrl,
};
}
// 定期清理过期缓存(每5分钟)
setInterval(cleanupExpiredCache, 5 * 60 * 1000);
export default useFileUrl;