Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,371 @@
/**
* 文件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;