From dae0bb8ac32bfdd2f910c0af4477f523920ad659 Mon Sep 17 00:00:00 2001 From: liquidliang Date: Wed, 4 Feb 2026 11:19:47 +0800 Subject: [PATCH] feat: integrate fake-indexeddb and refactor media executor - Added fake-indexeddb as a dev dependency for improved testing. - Refactored media executor by extracting utility functions for building image request bodies, parsing image responses, and polling video status. - Updated workflow engine to utilize new utility functions for better code clarity and maintainability. - Adjusted version.json build time to reflect recent changes. --- apps/web/public/version.json | 2 +- .../media-executor/fallback-executor.ts | 142 +------ .../services/media-executor/fallback-utils.ts | 141 +++++++ .../services/media-executor/llm-api-logger.ts | 37 +- .../src/services/workflow-engine/engine.ts | 57 +-- .../workflow-engine/workflow-factory.ts | 72 ++++ packages/utils/package.json | 2 +- packages/utils/src/blob/index.test.ts | 134 ++++++ packages/utils/src/blob/index.ts | 133 ++++++ packages/utils/src/device/index.test.ts | 225 ++++++++++ packages/utils/src/device/index.ts | 163 ++++++++ packages/utils/src/format/index.test.ts | 192 +++++++++ packages/utils/src/format/index.ts | 187 +++++++++ packages/utils/src/index.ts | 4 + packages/utils/src/indexeddb/index.test.ts | 342 +++++++++++++++ packages/utils/src/indexeddb/index.ts | 37 ++ packages/utils/src/indexeddb/operations.ts | 388 ++++++++++++++++++ packages/utils/src/indexeddb/types.ts | 56 +++ pnpm-lock.yaml | 11 +- 19 files changed, 2109 insertions(+), 216 deletions(-) create mode 100644 packages/drawnix/src/services/media-executor/fallback-utils.ts create mode 100644 packages/drawnix/src/services/workflow-engine/workflow-factory.ts create mode 100644 packages/utils/src/blob/index.test.ts create mode 100644 packages/utils/src/blob/index.ts create mode 100644 packages/utils/src/device/index.test.ts create mode 100644 packages/utils/src/device/index.ts create mode 100644 packages/utils/src/format/index.test.ts create mode 100644 packages/utils/src/format/index.ts create mode 100644 packages/utils/src/indexeddb/index.test.ts create mode 100644 packages/utils/src/indexeddb/index.ts create mode 100644 packages/utils/src/indexeddb/operations.ts create mode 100644 packages/utils/src/indexeddb/types.ts diff --git a/apps/web/public/version.json b/apps/web/public/version.json index df45e9c6..f9689d4e 100644 --- a/apps/web/public/version.json +++ b/apps/web/public/version.json @@ -1,6 +1,6 @@ { "version": "0.5.69", - "buildTime": "2026-02-04T02:30:15.489Z", + "buildTime": "2026-02-04T03:05:47.518Z", "gitCommit": "unknown", "changelog": [ "support opening tools via URL parameter and display tool icons in window title", diff --git a/packages/drawnix/src/services/media-executor/fallback-executor.ts b/packages/drawnix/src/services/media-executor/fallback-executor.ts index 7347419e..288d44a5 100644 --- a/packages/drawnix/src/services/media-executor/fallback-executor.ts +++ b/packages/drawnix/src/services/media-executor/fallback-executor.ts @@ -25,6 +25,12 @@ import { } from './llm-api-logger'; import { parseToolCalls, extractTextContent } from '@aitu/utils'; import { unifiedCacheService } from '../unified-cache-service'; +import { + extractPromptFromMessages, + buildImageRequestBody, + parseImageResponse, + pollVideoStatus, +} from './fallback-utils'; /** * 主线程降级执行器 @@ -82,7 +88,7 @@ export class FallbackMediaExecutor implements IMediaExecutor { } // 构建请求体 - const requestBody = this.buildImageRequestBody({ + const requestBody = buildImageRequestBody({ prompt, model: modelName, size, @@ -118,7 +124,7 @@ export class FallbackMediaExecutor implements IMediaExecutor { options?.onProgress?.({ progress: 80, phase: 'downloading' }); const data = await response.json(); - const result = this.parseImageResponse(data); + const result = parseImageResponse(data); const duration = Date.now() - startTime; // 记录成功 @@ -227,7 +233,7 @@ export class FallbackMediaExecutor implements IMediaExecutor { options?.onProgress?.({ progress: 10, phase: 'polling' }); // 轮询等待视频完成 - const result = await this.pollVideoStatus( + const result = await pollVideoStatus( videoId, config.videoConfig, (progress) => { @@ -320,7 +326,7 @@ export class FallbackMediaExecutor implements IMediaExecutor { } // 提取 prompt 用于日志记录 - const logPrompt = this.extractPromptFromMessages(chatMessages); + const logPrompt = extractPromptFromMessages(chatMessages); // 开始记录 LLM API 调用 const logId = startLLMApiLog({ @@ -445,134 +451,6 @@ export class FallbackMediaExecutor implements IMediaExecutor { }; } - /** - * 从消息数组中提取 prompt 用于日志记录 - */ - private extractPromptFromMessages(messages: Array<{ role: string; content: unknown }>): string { - for (const msg of messages) { - if (msg.role === 'user') { - if (typeof msg.content === 'string') { - return msg.content.substring(0, 500); - } - if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'text' && part.text) { - return part.text.substring(0, 500); - } - } - } - } - } - return ''; - } - - /** - * 构建图片生成请求体 - */ - private buildImageRequestBody(params: { - prompt: string; - model: string; - size?: string; - referenceImages?: string[]; - quality?: string; - n: number; - }): Record { - const body: Record = { - prompt: params.prompt, - model: params.model, - n: params.n, - }; - - if (params.size) { - body.size = params.size; - } - - if (params.quality) { - body.quality = params.quality; - } - - // 添加参考图片(已经转换为 base64 或 URL) - if (params.referenceImages && params.referenceImages.length > 0) { - body.image = params.referenceImages; - } - - return body; - } - - /** - * 解析图片生成响应 - */ - private parseImageResponse(data: any): { url: string; urls?: string[] } { - // 支持多种响应格式 - if (data.data && Array.isArray(data.data)) { - const urls = data.data - .map((item: any) => item.url || item.b64_json) - .filter(Boolean); - return { - url: urls[0] || '', - urls: urls.length > 1 ? urls : undefined, - }; - } - - if (data.url) { - return { url: data.url }; - } - - throw new Error('Invalid image generation response'); - } - - /** - * 轮询视频状态 - */ - private async pollVideoStatus( - videoId: string, - config: VideoAPIConfig, - onProgress: (progress: number) => void, - signal?: AbortSignal - ): Promise<{ url: string }> { - const maxAttempts = 120; // 最多轮询 10 分钟 - const interval = 5000; // 5 秒轮询间隔 - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - if (signal?.aborted) { - throw new Error('Video generation cancelled'); - } - - const response = await fetch(`${config.baseUrl}/v1/videos/${videoId}`, { - headers: { - Authorization: `Bearer ${config.apiKey}`, - }, - signal, - }); - - if (!response.ok) { - throw new Error(`Failed to check video status: ${response.status}`); - } - - const data = await response.json(); - const status = data.status || data.state; - const progress = data.progress || 0; - - onProgress(progress / 100); - - if (status === 'completed' || status === 'succeeded') { - const url = data.video_url || data.url || data.output?.url; - if (!url) { - throw new Error('No video URL in completed response'); - } - return { url }; - } - - if (status === 'failed' || status === 'error') { - throw new Error(data.error || data.message || 'Video generation failed'); - } - - // 等待下一次轮询 - await new Promise((resolve) => setTimeout(resolve, interval)); - } - - throw new Error('Video generation timeout'); - } } /** diff --git a/packages/drawnix/src/services/media-executor/fallback-utils.ts b/packages/drawnix/src/services/media-executor/fallback-utils.ts new file mode 100644 index 00000000..1c96cf72 --- /dev/null +++ b/packages/drawnix/src/services/media-executor/fallback-utils.ts @@ -0,0 +1,141 @@ +/** + * Fallback Executor 辅助函数 + * + * 提供降级执行器的通用工具函数 + */ + +import type { VideoAPIConfig } from './types'; + +/** + * 从消息数组中提取 prompt 用于日志记录 + */ +export function extractPromptFromMessages( + messages: Array<{ role: string; content: unknown }> +): string { + for (const msg of messages) { + if (msg.role === 'user') { + if (typeof msg.content === 'string') { + return msg.content.substring(0, 500); + } + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text' && part.text) { + return part.text.substring(0, 500); + } + } + } + } + } + return ''; +} + +/** + * 构建图片生成请求体 + */ +export function buildImageRequestBody(params: { + prompt: string; + model: string; + size?: string; + referenceImages?: string[]; + quality?: string; + n: number; +}): Record { + const body: Record = { + prompt: params.prompt, + model: params.model, + n: params.n, + }; + + if (params.size) { + body.size = params.size; + } + + if (params.quality) { + body.quality = params.quality; + } + + // 添加参考图片(已经转换为 base64 或 URL) + if (params.referenceImages && params.referenceImages.length > 0) { + body.image = params.referenceImages; + } + + return body; +} + +/** + * 解析图片生成响应 + */ +export function parseImageResponse(data: Record): { + url: string; + urls?: string[]; +} { + // 支持多种响应格式 + if (data.data && Array.isArray(data.data)) { + const urls = data.data + .map((item: Record) => item.url || item.b64_json) + .filter(Boolean) as string[]; + return { + url: urls[0] || '', + urls: urls.length > 1 ? urls : undefined, + }; + } + + if (data.url && typeof data.url === 'string') { + return { url: data.url }; + } + + throw new Error('Invalid image generation response'); +} + +/** + * 轮询视频状态 + */ +export async function pollVideoStatus( + videoId: string, + config: VideoAPIConfig, + onProgress: (progress: number) => void, + signal?: AbortSignal +): Promise<{ url: string }> { + const maxAttempts = 120; // 最多轮询 10 分钟 + const interval = 5000; // 5 秒轮询间隔 + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (signal?.aborted) { + throw new Error('Video generation cancelled'); + } + + const response = await fetch(`${config.baseUrl}/v1/videos/${videoId}`, { + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + signal, + }); + + if (!response.ok) { + throw new Error(`Failed to check video status: ${response.status}`); + } + + const data = await response.json(); + const status = data.status || data.state; + const progress = data.progress || 0; + + onProgress(progress / 100); + + if (status === 'completed' || status === 'succeeded') { + const url = data.video_url || data.url || data.output?.url; + if (!url) { + throw new Error('No video URL in completed response'); + } + return { url }; + } + + if (status === 'failed' || status === 'error') { + throw new Error(data.error || data.message || 'Video generation failed'); + } + + // 等待下一次轮询 + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error('Video generation timeout'); +} diff --git a/packages/drawnix/src/services/media-executor/llm-api-logger.ts b/packages/drawnix/src/services/media-executor/llm-api-logger.ts index dd0506d9..e2e2f573 100644 --- a/packages/drawnix/src/services/media-executor/llm-api-logger.ts +++ b/packages/drawnix/src/services/media-executor/llm-api-logger.ts @@ -5,6 +5,8 @@ * 写入与 SW 相同的 IndexedDB,确保调试面板能读取。 */ +import { truncate, sanitizeRequestBody } from '@aitu/utils'; + // 与 SW 端保持一致的数据库配置 const DB_NAME = 'llm-api-logs'; const DB_VERSION = 4; @@ -124,29 +126,6 @@ async function saveLogToDB(log: LLMApiLog): Promise { } } -/** - * 截断文本 - */ -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) return text; - return text.substring(0, maxLength) + '...'; -} - -/** - * 过滤敏感信息 - */ -function sanitizeRequestBody(body: string): string { - try { - const parsed = JSON.parse(body); - // 移除可能包含敏感信息的字段 - if (parsed.apiKey) parsed.apiKey = '[REDACTED]'; - if (parsed.api_key) parsed.api_key = '[REDACTED]'; - if (parsed.authorization) parsed.authorization = '[REDACTED]'; - return JSON.stringify(parsed); - } catch { - return body; - } -} /** * 开始记录 LLM API 调用 @@ -170,7 +149,7 @@ export function startLLMApiLog(params: { endpoint: params.endpoint, model: params.model, taskType: params.taskType, - prompt: params.prompt ? truncateText(params.prompt, 2000) : undefined, + prompt: params.prompt ? truncate(params.prompt, 2000) : undefined, requestBody: params.requestBody ? sanitizeRequestBody(params.requestBody) : undefined, hasReferenceImages: params.hasReferenceImages, referenceImageCount: params.referenceImageCount, @@ -215,8 +194,8 @@ export function completeLLMApiLog( log.resultType = params.resultType; log.resultCount = params.resultCount; log.resultUrl = params.resultUrl; - log.resultText = params.resultText ? truncateText(params.resultText, 1000) : undefined; - log.responseBody = params.responseBody ? truncateText(params.responseBody, 2000) : undefined; + log.resultText = params.resultText ? truncate(params.resultText, 1000) : undefined; + log.responseBody = params.responseBody ? truncate(params.responseBody, 2000) : undefined; log.remoteId = params.remoteId; // 更新 IndexedDB @@ -238,7 +217,7 @@ export function updateLLMApiLogMetadata( const log = memoryLogs.find((l) => l.id === logId); if (log) { if (params.remoteId) log.remoteId = params.remoteId; - if (params.responseBody) log.responseBody = truncateText(params.responseBody, 2000); + if (params.responseBody) log.responseBody = truncate(params.responseBody, 2000); if (params.httpStatus) log.httpStatus = params.httpStatus; // 更新 IndexedDB @@ -263,8 +242,8 @@ export function failLLMApiLog( log.status = 'error'; log.httpStatus = params.httpStatus; log.duration = params.duration; - log.errorMessage = truncateText(params.errorMessage, 500); - log.responseBody = params.responseBody ? truncateText(params.responseBody, 2000) : undefined; + log.errorMessage = truncate(params.errorMessage, 500); + log.responseBody = params.responseBody ? truncate(params.responseBody, 2000) : undefined; // 更新 IndexedDB saveLogToDB(log); diff --git a/packages/drawnix/src/services/workflow-engine/engine.ts b/packages/drawnix/src/services/workflow-engine/engine.ts index 37d8dd21..11980c5d 100644 --- a/packages/drawnix/src/services/workflow-engine/engine.ts +++ b/packages/drawnix/src/services/workflow-engine/engine.ts @@ -9,18 +9,17 @@ */ import { Subject, Observable } from 'rxjs'; -import { generateUUID } from '@aitu/utils'; import type { Workflow, WorkflowStep, WorkflowStatus, - WorkflowStepStatus, WorkflowEvent, WorkflowEngineOptions, } from './types'; import { executorFactory, waitForTaskCompletion, taskStorageWriter } from '../media-executor'; import type { AIAnalyzeParams } from '../media-executor/types'; import { workflowStorageWriter } from './workflow-storage-writer'; +import { findExecutableSteps, getFirstError } from './workflow-factory'; /** * 主线程工作流引擎 @@ -187,7 +186,7 @@ export class WorkflowEngine { if (hasFailed && !this.options.continueOnError) { workflow.status = 'failed'; - workflow.error = this.getFirstError(workflow); + workflow.error = getFirstError(workflow); } else if (allCompleted) { workflow.status = 'completed'; workflow.completedAt = Date.now(); @@ -240,7 +239,7 @@ export class WorkflowEngine { } // 查找可执行的步骤 - const executableSteps = this.findExecutableSteps(workflow); + const executableSteps = findExecutableSteps(workflow); console.log(`[WorkflowEngine] Iteration ${iteration}: ${executableSteps.length} executable steps, total ${workflow.steps.length} steps`); if (executableSteps.length === 0) { @@ -258,25 +257,6 @@ export class WorkflowEngine { } } - /** - * 查找可执行的步骤 - */ - private findExecutableSteps(workflow: Workflow): WorkflowStep[] { - return workflow.steps.filter((step) => { - // 只处理 pending 状态的步骤 - if (step.status !== 'pending') return false; - - // 检查依赖 - if (step.dependsOn && step.dependsOn.length > 0) { - return step.dependsOn.every((depId) => { - const depStep = workflow.steps.find((s) => s.id === depId); - return depStep?.status === 'completed'; - }); - } - - return true; - }); - } /** * 执行单个步骤 @@ -496,13 +476,6 @@ export class WorkflowEngine { } } - /** - * 获取第一个错误 - */ - private getFirstError(workflow: Workflow): string { - const failedStep = workflow.steps.find((s) => s.status === 'failed'); - return failedStep?.error || 'Unknown error'; - } /** * 发送事件 @@ -525,25 +498,5 @@ export class WorkflowEngine { } } -/** - * 创建工作流定义 - */ -export function createWorkflow( - name: string, - steps: Omit[], - context?: Workflow['context'] -): Workflow { - const now = Date.now(); - return { - id: generateUUID(), - name, - steps: steps.map((step) => ({ - ...step, - status: 'pending' as WorkflowStepStatus, - })), - status: 'pending', - createdAt: now, - updatedAt: now, - context, - }; -} +// Re-export createWorkflow from workflow-factory +export { createWorkflow } from './workflow-factory'; diff --git a/packages/drawnix/src/services/workflow-engine/workflow-factory.ts b/packages/drawnix/src/services/workflow-engine/workflow-factory.ts new file mode 100644 index 00000000..1c09c4f1 --- /dev/null +++ b/packages/drawnix/src/services/workflow-engine/workflow-factory.ts @@ -0,0 +1,72 @@ +/** + * Workflow Factory + * + * 工作流创建辅助函数 + */ + +import { generateUUID } from '@aitu/utils'; +import type { Workflow, WorkflowStep, WorkflowStepStatus } from './types'; + +/** + * 创建工作流定义 + * + * @param name 工作流名称 + * @param steps 步骤定义(不含 status) + * @param context 上下文数据 + * @returns 完整的工作流定义 + * + * @example + * ```typescript + * const workflow = createWorkflow('My Workflow', [ + * { id: 'step1', name: 'Step 1', tool: 'generate_image', params: { prompt: '...' } }, + * { id: 'step2', name: 'Step 2', tool: 'generate_video', params: { ... }, dependsOn: ['step1'] }, + * ]); + * ``` + */ +export function createWorkflow( + name: string, + steps: Omit[], + context?: Workflow['context'] +): Workflow { + const now = Date.now(); + return { + id: generateUUID(), + name, + steps: steps.map((step) => ({ + ...step, + status: 'pending' as WorkflowStepStatus, + })), + status: 'pending', + createdAt: now, + updatedAt: now, + context, + }; +} + +/** + * 查找工作流中的第一个错误 + */ +export function getFirstError(workflow: Workflow): string { + const failedStep = workflow.steps.find((s) => s.status === 'failed'); + return failedStep?.error || 'Unknown error'; +} + +/** + * 查找可执行的步骤(依赖已完成的 pending 步骤) + */ +export function findExecutableSteps(workflow: Workflow): WorkflowStep[] { + return workflow.steps.filter((step) => { + // 只处理 pending 状态的步骤 + if (step.status !== 'pending') return false; + + // 检查依赖 + if (step.dependsOn && step.dependsOn.length > 0) { + return step.dependsOn.every((depId) => { + const depStep = workflow.steps.find((s) => s.id === depId); + return depStep?.status === 'completed'; + }); + } + + return true; + }); +} diff --git a/packages/utils/package.json b/packages/utils/package.json index 16a7d137..c967cfb4 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -13,8 +13,8 @@ "lint": "eslint src --ext .ts,.tsx", "typecheck": "tsc --noEmit" }, - "dependencies": {}, "devDependencies": { + "fake-indexeddb": "^6.2.5", "vite": "^6.4.1", "vite-plugin-dts": "^4.3.0" } diff --git a/packages/utils/src/blob/index.test.ts b/packages/utils/src/blob/index.test.ts new file mode 100644 index 00000000..73cd7fd0 --- /dev/null +++ b/packages/utils/src/blob/index.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; +import { + blobToBase64, + pureBase64ToBlob, + dataUrlToBlob, + blobToDataUrl, +} from './index'; + +describe('blobToBase64', () => { + it('should convert text Blob to base64', async () => { + const blob = new Blob(['Hello'], { type: 'text/plain' }); + const base64 = await blobToBase64(blob); + + // "Hello" in base64 is "SGVsbG8=" + expect(base64).toBe('SGVsbG8='); + }); + + it('should convert binary Blob to base64', async () => { + const bytes = new Uint8Array([0, 1, 2, 3, 4, 5]); + const blob = new Blob([bytes], { type: 'application/octet-stream' }); + const base64 = await blobToBase64(blob); + + expect(base64).toBe('AAECAwQF'); + }); + + it('should handle empty Blob', async () => { + const blob = new Blob([], { type: 'text/plain' }); + const base64 = await blobToBase64(blob); + + expect(base64).toBe(''); + }); +}); + +describe('pureBase64ToBlob', () => { + it('should convert base64 to Blob with correct type', () => { + // "Hello" in base64 is "SGVsbG8=" + const blob = pureBase64ToBlob('SGVsbG8=', 'text/plain'); + + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('text/plain'); + expect(blob.size).toBe(5); // "Hello" is 5 bytes + }); + + it('should convert binary base64 to Blob', () => { + // [0, 1, 2, 3, 4, 5] in base64 is "AAECAwQF" + const blob = pureBase64ToBlob('AAECAwQF', 'application/octet-stream'); + + expect(blob.size).toBe(6); + expect(blob.type).toBe('application/octet-stream'); + }); + + it('should handle empty base64', () => { + const blob = pureBase64ToBlob('', 'text/plain'); + + expect(blob.size).toBe(0); + }); +}); + +describe('dataUrlToBlob', () => { + it('should extract mime type and convert to Blob', () => { + const dataUrl = 'data:text/plain;base64,SGVsbG8='; + const blob = dataUrlToBlob(dataUrl); + + expect(blob.type).toBe('text/plain'); + expect(blob.size).toBe(5); + }); + + it('should handle image data URL', () => { + // Minimal 1x1 PNG + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const blob = dataUrlToBlob(dataUrl); + + expect(blob.type).toBe('image/png'); + expect(blob.size).toBeGreaterThan(0); + }); + + it('should default to application/octet-stream for unknown types', () => { + // Malformed header without proper mime type + const dataUrl = 'data:;base64,SGVsbG8='; + const blob = dataUrlToBlob(dataUrl); + + expect(blob.type).toBe('application/octet-stream'); + }); +}); + +describe('blobToDataUrl', () => { + it('should convert Blob to data URL', async () => { + const blob = new Blob(['Hello'], { type: 'text/plain' }); + const dataUrl = await blobToDataUrl(blob); + + expect(dataUrl).toBe('data:text/plain;base64,SGVsbG8='); + }); + + it('should preserve mime type in data URL', async () => { + const blob = new Blob(['test'], { type: 'application/json' }); + const dataUrl = await blobToDataUrl(blob); + + expect(dataUrl.startsWith('data:application/json;base64,')).toBe(true); + }); +}); + +describe('roundtrip conversions', () => { + // Helper to read blob as text + async function readBlobAsText(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + } + + it('should roundtrip blobToBase64 -> pureBase64ToBlob', async () => { + const original = new Blob(['Test content 123'], { type: 'text/plain' }); + const base64 = await blobToBase64(original); + const restored = pureBase64ToBlob(base64, 'text/plain'); + + expect(restored.size).toBe(original.size); + expect(await readBlobAsText(restored)).toBe(await readBlobAsText(original)); + }); + + it('should roundtrip blobToDataUrl -> dataUrlToBlob', async () => { + const original = new Blob(['Binary data'], { type: 'application/octet-stream' }); + const dataUrl = await blobToDataUrl(original); + const restored = dataUrlToBlob(dataUrl); + + expect(restored.size).toBe(original.size); + expect(await readBlobAsText(restored)).toBe(await readBlobAsText(original)); + }); +}); diff --git a/packages/utils/src/blob/index.ts b/packages/utils/src/blob/index.ts new file mode 100644 index 00000000..c730941a --- /dev/null +++ b/packages/utils/src/blob/index.ts @@ -0,0 +1,133 @@ +/** + * Blob 工具模块 + * + * 提供 Blob 和 Base64 之间的转换工具函数 + */ + +/** + * 将 Blob 转换为 Base64 字符串 + * + * @param blob 要转换的 Blob + * @returns Promise 纯 Base64 字符串(不含 data URL 前缀) + * + * @example + * ```typescript + * const blob = new Blob(['Hello'], { type: 'text/plain' }); + * const base64 = await blobToBase64(blob); + * console.log(base64); // "SGVsbG8=" + * ``` + */ +export async function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onloadend = () => { + const result = reader.result as string; + // 移除 data URL 前缀 (e.g., "data:image/png;base64,") + const base64Data = result.split(',')[1]; + resolve(base64Data); + }; + + reader.onerror = () => { + reject(new Error('Failed to read blob as base64')); + }; + + reader.readAsDataURL(blob); + }); +} + +/** + * 将纯 Base64 字符串转换为 Blob + * + * 注意:此函数接受纯 base64 字符串(不含 data URL 前缀)。 + * 如果你有 data URL 格式的字符串,请使用 `@aitu/utils` 的 `base64ToBlob`(来自 encoding 模块)。 + * + * @param base64 纯 Base64 字符串(不含 data URL 前缀) + * @param mimeType MIME 类型 + * @returns Blob + * + * @example + * ```typescript + * const blob = pureBase64ToBlob('SGVsbG8=', 'text/plain'); + * console.log(blob.size); // 5 + * console.log(blob.type); // "text/plain" + * ``` + */ +export function pureBase64ToBlob(base64: string, mimeType: string): Blob { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return new Blob([bytes], { type: mimeType }); +} + +/** + * 将 Data URL 转换为 Blob + * + * @param dataUrl Data URL 字符串 (e.g., "data:image/png;base64,...") + * @returns Blob + * + * @example + * ```typescript + * const blob = dataUrlToBlob('data:text/plain;base64,SGVsbG8='); + * console.log(blob.type); // "text/plain" + * ``` + */ +export function dataUrlToBlob(dataUrl: string): Blob { + const [header, base64] = dataUrl.split(','); + const mimeType = header.match(/:(.*?);/)?.[1] || 'application/octet-stream'; + return pureBase64ToBlob(base64, mimeType); +} + +/** + * 将 Blob 转换为 Data URL + * + * @param blob 要转换的 Blob + * @returns Promise Data URL 字符串 + * + * @example + * ```typescript + * const blob = new Blob(['Hello'], { type: 'text/plain' }); + * const dataUrl = await blobToDataUrl(blob); + * console.log(dataUrl); // "data:text/plain;base64,SGVsbG8=" + * ``` + */ +export async function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onloadend = () => { + resolve(reader.result as string); + }; + + reader.onerror = () => { + reject(new Error('Failed to read blob as data URL')); + }; + + reader.readAsDataURL(blob); + }); +} + +/** + * 计算 Blob 的 MD5 校验和(使用 SubtleCrypto) + * + * 注意:浏览器 SubtleCrypto 不支持 MD5,这里使用 SHA-256 代替 + * + * @param blob 要计算校验和的 Blob + * @returns Promise 十六进制格式的校验和 + * + * @example + * ```typescript + * const blob = new Blob(['Hello'], { type: 'text/plain' }); + * const checksum = await calculateBlobChecksum(blob); + * ``` + */ +export async function calculateBlobChecksum(blob: Blob): Promise { + const arrayBuffer = await blob.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/packages/utils/src/device/index.test.ts b/packages/utils/src/device/index.test.ts new file mode 100644 index 00000000..7610c497 --- /dev/null +++ b/packages/utils/src/device/index.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + getOrCreateDeviceId, + getDeviceId, + generateDeviceId, + getDeviceName, + getDeviceType, + getBrowserName, +} from './index'; + +describe('generateDeviceId', () => { + it('should generate unique IDs', () => { + const id1 = generateDeviceId(); + const id2 = generateDeviceId(); + + expect(id1).not.toBe(id2); + }); + + it('should match expected format', () => { + const id = generateDeviceId(); + + // Format: {timestamp36}-{random} + expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); + }); + + it('should generate non-empty strings', () => { + const id = generateDeviceId(); + + expect(id.length).toBeGreaterThan(5); + }); +}); + +describe('getOrCreateDeviceId', () => { + const storageKey = 'test_device_id'; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should create new ID if not exists', () => { + const id = getOrCreateDeviceId({ storageKey }); + + expect(id).toBeTruthy(); + expect(localStorage.getItem(storageKey)).toBe(id); + }); + + it('should return existing ID', () => { + const existingId = 'existing-device-id-123'; + localStorage.setItem(storageKey, existingId); + + const id = getOrCreateDeviceId({ storageKey }); + + expect(id).toBe(existingId); + }); + + it('should persist ID across calls', () => { + const id1 = getOrCreateDeviceId({ storageKey }); + const id2 = getOrCreateDeviceId({ storageKey }); + + expect(id1).toBe(id2); + }); +}); + +describe('getDeviceId', () => { + const storageKey = 'test_device_id_readonly'; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should return "unknown" if not exists', () => { + const id = getDeviceId({ storageKey }); + + expect(id).toBe('unknown'); + }); + + it('should return existing ID without creating', () => { + const existingId = 'readonly-test-id'; + localStorage.setItem(storageKey, existingId); + + const id = getDeviceId({ storageKey }); + + expect(id).toBe(existingId); + }); + + it('should not create new ID', () => { + getDeviceId({ storageKey }); + + expect(localStorage.getItem(storageKey)).toBeNull(); + }); +}); + +describe('getDeviceName', () => { + it('should return a non-empty string', () => { + const name = getDeviceName(); + + expect(name).toBeTruthy(); + expect(typeof name).toBe('string'); + }); + + it('should return a known device name in browser environment', () => { + const name = getDeviceName(); + + // In jsdom environment, should return something like "Unknown" or platform-based + expect(name.length).toBeGreaterThan(0); + }); +}); + +describe('getDeviceType', () => { + it('should return a valid device type', () => { + const type = getDeviceType(); + + expect(['mobile', 'tablet', 'desktop']).toContain(type); + }); + + it('should default to desktop in jsdom', () => { + // jsdom doesn't have mobile user agent + const type = getDeviceType(); + + expect(type).toBe('desktop'); + }); +}); + +describe('getBrowserName', () => { + it('should return a browser name string', () => { + const name = getBrowserName(); + + expect(typeof name).toBe('string'); + expect(name.length).toBeGreaterThan(0); + }); + + it('should return one of known browsers or Unknown', () => { + const name = getBrowserName(); + const knownBrowsers = ['Chrome', 'Firefox', 'Safari', 'Edge', 'Opera', 'Unknown']; + + expect(knownBrowsers).toContain(name); + }); +}); + +describe('user agent mocking', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + // Restore original navigator + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + writable: true, + }); + }); + + it('should detect iOS device', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)', + platform: 'iPhone', + }, + writable: true, + }); + + expect(getDeviceName()).toBe('iOS Device'); + expect(getDeviceType()).toBe('mobile'); + }); + + it('should detect Android device', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Linux; Android 11) Mobile', + platform: 'Linux armv7l', + }, + writable: true, + }); + + expect(getDeviceName()).toBe('Android Device'); + expect(getDeviceType()).toBe('mobile'); + }); + + it('should detect macOS', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + platform: 'MacIntel', + }, + writable: true, + }); + + expect(getDeviceName()).toBe('macOS'); + expect(getDeviceType()).toBe('desktop'); + }); + + it('should detect Chrome browser', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + platform: 'Win32', + }, + writable: true, + }); + + expect(getBrowserName()).toBe('Chrome'); + }); + + it('should detect Firefox browser', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0', + platform: 'Win32', + }, + writable: true, + }); + + expect(getBrowserName()).toBe('Firefox'); + }); +}); diff --git a/packages/utils/src/device/index.ts b/packages/utils/src/device/index.ts new file mode 100644 index 00000000..d6d3478f --- /dev/null +++ b/packages/utils/src/device/index.ts @@ -0,0 +1,163 @@ +/** + * 设备信息工具模块 + * + * 提供设备识别和信息获取功能 + */ + +/** + * 设备 ID 存储配置 + */ +export interface DeviceIdConfig { + /** localStorage 存储键 */ + storageKey: string; +} + +/** + * 获取或创建设备 ID + * + * 如果 localStorage 中已存在设备 ID,则返回它; + * 否则生成一个新的设备 ID 并存储。 + * + * @param config 配置选项 + * @returns 设备 ID 字符串 + * + * @example + * ```typescript + * const deviceId = getOrCreateDeviceId({ storageKey: 'my_app_device_id' }); + * console.log(deviceId); // "m4x7k3-a9b2c1d" + * ``` + */ +export function getOrCreateDeviceId(config: DeviceIdConfig): string { + const { storageKey } = config; + + try { + let deviceId = localStorage.getItem(storageKey); + if (!deviceId) { + deviceId = generateDeviceId(); + localStorage.setItem(storageKey, deviceId); + } + return deviceId; + } catch { + // localStorage 不可用时,返回临时 ID + return generateDeviceId(); + } +} + +/** + * 获取设备 ID(只读,不创建) + * + * @param config 配置选项 + * @returns 设备 ID 或 'unknown' + * + * @example + * ```typescript + * const deviceId = getDeviceId({ storageKey: 'my_app_device_id' }); + * ``` + */ +export function getDeviceId(config: DeviceIdConfig): string { + const { storageKey } = config; + + try { + return localStorage.getItem(storageKey) || 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * 生成唯一的设备 ID + * + * 格式: {timestamp36}-{random} + * + * @returns 设备 ID 字符串 + */ +export function generateDeviceId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * 获取设备名称(基于 User Agent 推断) + * + * @returns 设备名称字符串 + * + * @example + * ```typescript + * const name = getDeviceName(); + * console.log(name); // "macOS" / "Windows" / "iOS Device" / ... + * ``` + */ +export function getDeviceName(): string { + if (typeof navigator === 'undefined') { + return 'Unknown'; + } + + const userAgent = navigator.userAgent; + const platform = navigator.platform || ''; + + // 移动设备检测 + if (/iPhone|iPad|iPod/.test(userAgent)) { + return 'iOS Device'; + } + if (/Android/.test(userAgent)) { + return 'Android Device'; + } + + // 桌面操作系统检测 + if (/Mac/.test(platform)) { + return 'macOS'; + } + if (/Win/.test(platform)) { + return 'Windows'; + } + if (/Linux/.test(platform)) { + return 'Linux'; + } + + return platform || 'Unknown'; +} + +/** + * 获取设备类型 + * + * @returns 设备类型: 'mobile' | 'tablet' | 'desktop' + */ +export function getDeviceType(): 'mobile' | 'tablet' | 'desktop' { + if (typeof navigator === 'undefined') { + return 'desktop'; + } + + const userAgent = navigator.userAgent; + + // 平板检测 + if (/iPad|Android(?!.*Mobile)/.test(userAgent)) { + return 'tablet'; + } + + // 手机检测 + if (/iPhone|iPod|Android.*Mobile|webOS|BlackBerry|IEMobile|Opera Mini/.test(userAgent)) { + return 'mobile'; + } + + return 'desktop'; +} + +/** + * 获取浏览器名称 + * + * @returns 浏览器名称 + */ +export function getBrowserName(): string { + if (typeof navigator === 'undefined') { + return 'Unknown'; + } + + const userAgent = navigator.userAgent; + + if (/Edg/.test(userAgent)) return 'Edge'; + if (/Chrome/.test(userAgent)) return 'Chrome'; + if (/Safari/.test(userAgent)) return 'Safari'; + if (/Firefox/.test(userAgent)) return 'Firefox'; + if (/Opera|OPR/.test(userAgent)) return 'Opera'; + + return 'Unknown'; +} diff --git a/packages/utils/src/format/index.test.ts b/packages/utils/src/format/index.test.ts new file mode 100644 index 00000000..f1d8eace --- /dev/null +++ b/packages/utils/src/format/index.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest'; +import { + formatSize, + formatDurationMs, + formatNumber, + formatPercent, + formatRelativeTime, + truncateText, +} from './index'; + +describe('formatSize', () => { + it('should format bytes', () => { + expect(formatSize(0)).toBe('0 B'); + expect(formatSize(500)).toBe('500 B'); + expect(formatSize(1023)).toBe('1023 B'); + }); + + it('should format kilobytes', () => { + expect(formatSize(1024)).toBe('1.0 KB'); + expect(formatSize(1536)).toBe('1.5 KB'); + expect(formatSize(10240)).toBe('10.0 KB'); + }); + + it('should format megabytes', () => { + expect(formatSize(1048576)).toBe('1.0 MB'); + expect(formatSize(5242880)).toBe('5.0 MB'); + expect(formatSize(1572864)).toBe('1.5 MB'); + }); + + it('should format gigabytes', () => { + expect(formatSize(1073741824)).toBe('1.0 GB'); + expect(formatSize(5368709120)).toBe('5.0 GB'); + }); + + it('should format terabytes', () => { + expect(formatSize(1099511627776)).toBe('1.0 TB'); + }); + + it('should respect decimal places', () => { + expect(formatSize(1536, 0)).toBe('2 KB'); + expect(formatSize(1536, 2)).toBe('1.50 KB'); + expect(formatSize(1536, 3)).toBe('1.500 KB'); + }); + + it('should handle negative values', () => { + expect(formatSize(-100)).toBe('Invalid size'); + }); +}); + +describe('formatDurationMs', () => { + it('should format milliseconds', () => { + expect(formatDurationMs(0)).toBe('0ms'); + expect(formatDurationMs(500)).toBe('500ms'); + expect(formatDurationMs(999)).toBe('999ms'); + }); + + it('should format seconds', () => { + expect(formatDurationMs(1000)).toBe('1.0s'); + expect(formatDurationMs(1500)).toBe('1.5s'); + expect(formatDurationMs(30000)).toBe('30.0s'); + }); + + it('should format minutes and seconds', () => { + expect(formatDurationMs(60000)).toBe('1m 0s'); + expect(formatDurationMs(65000)).toBe('1m 5s'); + expect(formatDurationMs(125000)).toBe('2m 5s'); + }); + + it('should format hours, minutes and seconds', () => { + expect(formatDurationMs(3600000)).toBe('1h 0m'); + expect(formatDurationMs(3661000)).toBe('1h 1m 1s'); + expect(formatDurationMs(7325000)).toBe('2h 2m 5s'); + }); + + it('should handle negative values', () => { + expect(formatDurationMs(-100)).toBe('Invalid duration'); + }); +}); + +describe('formatNumber', () => { + it('should format with thousand separators', () => { + expect(formatNumber(1234567)).toBe('1,234,567'); + expect(formatNumber(1000)).toBe('1,000'); + expect(formatNumber(999)).toBe('999'); + }); + + it('should handle decimals', () => { + expect(formatNumber(1234.56)).toBe('1,234.56'); + }); + + it('should handle zero', () => { + expect(formatNumber(0)).toBe('0'); + }); + + it('should handle negative numbers', () => { + expect(formatNumber(-1234567)).toBe('-1,234,567'); + }); +}); + +describe('formatPercent', () => { + it('should format decimal to percent', () => { + expect(formatPercent(0.75)).toBe('75%'); + expect(formatPercent(0.5)).toBe('50%'); + expect(formatPercent(1)).toBe('100%'); + }); + + it('should respect decimal places', () => { + expect(formatPercent(0.756, 1)).toBe('75.6%'); + expect(formatPercent(0.7567, 2)).toBe('75.67%'); + }); + + it('should handle non-decimal values', () => { + expect(formatPercent(75, 0, false)).toBe('75%'); + expect(formatPercent(33.33, 1, false)).toBe('33.3%'); + }); + + it('should handle edge cases', () => { + expect(formatPercent(0)).toBe('0%'); + expect(formatPercent(0.001, 1)).toBe('0.1%'); + }); +}); + +describe('formatRelativeTime', () => { + const now = Date.now(); + + it('should format seconds ago', () => { + expect(formatRelativeTime(now - 30000, now)).toBe('30 秒前'); + expect(formatRelativeTime(now - 1000, now)).toBe('1 秒前'); + }); + + it('should format minutes ago', () => { + expect(formatRelativeTime(now - 60000, now)).toBe('1 分钟前'); + expect(formatRelativeTime(now - 120000, now)).toBe('2 分钟前'); + }); + + it('should format hours ago', () => { + expect(formatRelativeTime(now - 3600000, now)).toBe('1 小时前'); + expect(formatRelativeTime(now - 7200000, now)).toBe('2 小时前'); + }); + + it('should format days ago', () => { + expect(formatRelativeTime(now - 86400000, now)).toBe('1 天前'); + expect(formatRelativeTime(now - 172800000, now)).toBe('2 天前'); + }); + + it('should format months ago', () => { + expect(formatRelativeTime(now - 2592000000, now)).toBe('1 个月前'); + }); + + it('should format years ago', () => { + expect(formatRelativeTime(now - 31536000000, now)).toBe('1 年前'); + }); + + it('should handle future dates', () => { + expect(formatRelativeTime(now + 10000, now)).toBe('刚刚'); + }); + + it('should handle Date objects', () => { + const date = new Date(now - 60000); + expect(formatRelativeTime(date, now)).toBe('1 分钟前'); + }); +}); + +describe('truncateText', () => { + it('should truncate long text', () => { + expect(truncateText('Hello World', 8)).toBe('Hello...'); + expect(truncateText('Long text here', 10)).toBe('Long te...'); + }); + + it('should not truncate short text', () => { + expect(truncateText('Hello', 10)).toBe('Hello'); + expect(truncateText('Hi', 5)).toBe('Hi'); + }); + + it('should handle exact length', () => { + expect(truncateText('Hello', 5)).toBe('Hello'); + }); + + it('should use custom ellipsis', () => { + expect(truncateText('Hello World', 8, '…')).toBe('Hello W…'); + expect(truncateText('Hello World', 6, '--')).toBe('Hell--'); + }); + + it('should handle empty string', () => { + expect(truncateText('', 10)).toBe(''); + }); + + it('should handle very short max length', () => { + expect(truncateText('Hello', 3)).toBe('...'); + expect(truncateText('Hello', 1, '…')).toBe('…'); + }); +}); diff --git a/packages/utils/src/format/index.ts b/packages/utils/src/format/index.ts new file mode 100644 index 00000000..00393599 --- /dev/null +++ b/packages/utils/src/format/index.ts @@ -0,0 +1,187 @@ +/** + * 格式化工具模块 + * + * 提供各种数据格式化函数 + */ + +/** + * 格式化文件大小为人类可读的字符串 + * + * @param bytes 字节数 + * @param decimals 小数位数,默认 1 + * @returns 格式化后的字符串 + * + * @example + * ```typescript + * formatSize(1024); // "1.0 KB" + * formatSize(1536); // "1.5 KB" + * formatSize(1048576); // "1.0 MB" + * formatSize(1073741824); // "1.0 GB" + * formatSize(500); // "500 B" + * ``` + */ +export function formatSize(bytes: number, decimals: number = 1): string { + if (bytes === 0) return '0 B'; + if (bytes < 0) return 'Invalid size'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const k = 1024; + + // 找到合适的单位 + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const unitIndex = Math.min(i, units.length - 1); + + if (unitIndex === 0) { + return `${bytes} ${units[0]}`; + } + + const size = bytes / Math.pow(k, unitIndex); + return `${size.toFixed(decimals)} ${units[unitIndex]}`; +} + +/** + * 格式化持续时间为人类可读的字符串(支持毫秒级别) + * + * 注意:如果不需要毫秒级精度,可以使用 `@aitu/utils` 的 `formatDuration`(来自 function/format)。 + * + * @param ms 毫秒数 + * @returns 格式化后的字符串 + * + * @example + * ```typescript + * formatDurationMs(500); // "500ms" + * formatDurationMs(1500); // "1.5s" + * formatDurationMs(65000); // "1m 5s" + * formatDurationMs(3661000); // "1h 1m 1s" + * ``` + */ +export function formatDurationMs(ms: number): string { + if (ms < 0) return 'Invalid duration'; + if (ms < 1000) return `${ms}ms`; + + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMinutes = minutes % 60; + const remainingSeconds = seconds % 60; + if (remainingSeconds > 0) { + return `${hours}h ${remainingMinutes}m ${remainingSeconds}s`; + } + return `${hours}h ${remainingMinutes}m`; + } + + if (minutes > 0) { + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } + + // 小于 1 分钟时显示小数秒 + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * 格式化数字为带千分位分隔符的字符串 + * + * @param num 数字 + * @param locale 区域设置,默认 'en-US' + * @returns 格式化后的字符串 + * + * @example + * ```typescript + * formatNumber(1234567); // "1,234,567" + * formatNumber(1234.56); // "1,234.56" + * ``` + */ +export function formatNumber(num: number, locale: string = 'en-US'): string { + return new Intl.NumberFormat(locale).format(num); +} + +/** + * 格式化百分比 + * + * @param value 值(0-1 之间的小数,或 0-100 之间的百分比) + * @param decimals 小数位数,默认 0 + * @param isDecimal 是否为小数(true 表示 0-1,false 表示 0-100),默认 true + * @returns 格式化后的百分比字符串 + * + * @example + * ```typescript + * formatPercent(0.75); // "75%" + * formatPercent(0.756, 1); // "75.6%" + * formatPercent(75, 0, false); // "75%" + * ``` + */ +export function formatPercent( + value: number, + decimals: number = 0, + isDecimal: boolean = true +): string { + const percentage = isDecimal ? value * 100 : value; + return `${percentage.toFixed(decimals)}%`; +} + +/** + * 格式化日期为相对时间(如 "3 分钟前") + * + * @param date 日期(Date 对象或时间戳) + * @param now 当前时间,默认为 Date.now() + * @returns 相对时间字符串 + * + * @example + * ```typescript + * formatRelativeTime(Date.now() - 30000); // "30 秒前" + * formatRelativeTime(Date.now() - 120000); // "2 分钟前" + * ``` + */ +export function formatRelativeTime(date: Date | number, now: number = Date.now()): string { + const timestamp = typeof date === 'number' ? date : date.getTime(); + const diff = now - timestamp; + + if (diff < 0) { + return '刚刚'; + } + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (years > 0) return `${years} 年前`; + if (months > 0) return `${months} 个月前`; + if (days > 0) return `${days} 天前`; + if (hours > 0) return `${hours} 小时前`; + if (minutes > 0) return `${minutes} 分钟前`; + if (seconds > 0) return `${seconds} 秒前`; + + return '刚刚'; +} + +/** + * 截断文本并添加省略号 + * + * @param text 原始文本 + * @param maxLength 最大长度 + * @param ellipsis 省略号,默认 '...' + * @returns 截断后的文本 + * + * @example + * ```typescript + * truncateText('Hello World', 8); // "Hello..." + * truncateText('Hello', 10); // "Hello" + * truncateText('Hello World', 8, '…'); // "Hello W…" + * ``` + */ +export function truncateText( + text: string, + maxLength: number, + ellipsis: string = '...' +): string { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - ellipsis.length) + ellipsis; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 181d4902..6ce9c7b5 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -13,3 +13,7 @@ export * from './id'; export * from './dom'; export * from './security'; export * from './workflow'; +export * from './indexeddb'; +export * from './blob'; +export * from './device'; +export * from './format'; diff --git a/packages/utils/src/indexeddb/index.test.ts b/packages/utils/src/indexeddb/index.test.ts new file mode 100644 index 00000000..a5a041c4 --- /dev/null +++ b/packages/utils/src/indexeddb/index.test.ts @@ -0,0 +1,342 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { + openIndexedDB, + getById, + getAll, + getAllWithCursor, + put, + putMany, + deleteById, + deleteMany, + clearStore, + countRecords, + hasStore, +} from './operations'; + +// Test data type +interface TestRecord { + id: string; + name: string; + value: number; + timestamp?: number; +} + +describe('IndexedDB Operations', () => { + const DB_NAME = 'test-db'; + const STORE_NAME = 'test-store'; + let db: IDBDatabase; + + // Helper to create a test database with store + async function createTestDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + + request.onerror = () => reject(request.error); + + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + const store = database.createObjectStore(STORE_NAME, { keyPath: 'id' }); + store.createIndex('timestamp', 'timestamp', { unique: false }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + }); + } + + beforeEach(async () => { + // Reset fake-indexeddb + indexedDB.deleteDatabase(DB_NAME); + db = await createTestDB(); + }); + + afterEach(() => { + if (db) { + db.close(); + } + indexedDB.deleteDatabase(DB_NAME); + }); + + describe('openIndexedDB', () => { + it('should open an existing database', async () => { + const openedDb = await openIndexedDB(DB_NAME); + + expect(openedDb).toBeInstanceOf(IDBDatabase); + expect(openedDb.name).toBe(DB_NAME); + + openedDb.close(); + }); + + it('should call onUpgradeNeeded when provided', async () => { + const newDbName = 'new-test-db'; + let upgradeCalled = false; + + const openedDb = await openIndexedDB(newDbName, { + version: 1, + onUpgradeNeeded: (database) => { + upgradeCalled = true; + database.createObjectStore('new-store', { keyPath: 'id' }); + }, + }); + + expect(upgradeCalled).toBe(true); + expect(openedDb.objectStoreNames.contains('new-store')).toBe(true); + + openedDb.close(); + indexedDB.deleteDatabase(newDbName); + }); + }); + + describe('put and getById', () => { + it('should store and retrieve a record', async () => { + const record: TestRecord = { id: 'test-1', name: 'Test', value: 42 }; + + await put(db, STORE_NAME, record); + const retrieved = await getById(db, STORE_NAME, 'test-1'); + + expect(retrieved).toEqual(record); + }); + + it('should return null for non-existent record', async () => { + const result = await getById(db, STORE_NAME, 'non-existent'); + + expect(result).toBeNull(); + }); + + it('should update existing record', async () => { + const record: TestRecord = { id: 'test-1', name: 'Original', value: 1 }; + await put(db, STORE_NAME, record); + + const updated: TestRecord = { id: 'test-1', name: 'Updated', value: 2 }; + await put(db, STORE_NAME, updated); + + const retrieved = await getById(db, STORE_NAME, 'test-1'); + expect(retrieved?.name).toBe('Updated'); + expect(retrieved?.value).toBe(2); + }); + }); + + describe('getAll', () => { + it('should retrieve all records', async () => { + const records: TestRecord[] = [ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + { id: '3', name: 'Third', value: 3 }, + ]; + + for (const record of records) { + await put(db, STORE_NAME, record); + } + + const all = await getAll(db, STORE_NAME); + + expect(all).toHaveLength(3); + expect(all.map((r) => r.id).sort()).toEqual(['1', '2', '3']); + }); + + it('should return empty array for empty store', async () => { + const all = await getAll(db, STORE_NAME); + + expect(all).toEqual([]); + }); + }); + + describe('getAllWithCursor', () => { + beforeEach(async () => { + const records: TestRecord[] = [ + { id: '1', name: 'First', value: 10, timestamp: 100 }, + { id: '2', name: 'Second', value: 20, timestamp: 200 }, + { id: '3', name: 'Third', value: 30, timestamp: 300 }, + { id: '4', name: 'Fourth', value: 40, timestamp: 400 }, + { id: '5', name: 'Fifth', value: 50, timestamp: 500 }, + ]; + + for (const record of records) { + await put(db, STORE_NAME, record); + } + }); + + it('should retrieve all records with cursor', async () => { + const all = await getAllWithCursor(db, STORE_NAME); + + expect(all).toHaveLength(5); + }); + + it('should limit results', async () => { + const limited = await getAllWithCursor(db, STORE_NAME, { + limit: 3, + }); + + expect(limited).toHaveLength(3); + }); + + it('should skip with offset', async () => { + const skipped = await getAllWithCursor(db, STORE_NAME, { + offset: 2, + }); + + expect(skipped).toHaveLength(3); + }); + + it('should combine limit and offset', async () => { + const paged = await getAllWithCursor(db, STORE_NAME, { + offset: 1, + limit: 2, + }); + + expect(paged).toHaveLength(2); + }); + + it('should filter records', async () => { + const filtered = await getAllWithCursor(db, STORE_NAME, { + filter: (r) => r.value > 25, + }); + + expect(filtered).toHaveLength(3); + expect(filtered.every((r) => r.value > 25)).toBe(true); + }); + + it('should use index for sorting', async () => { + const sorted = await getAllWithCursor(db, STORE_NAME, { + indexName: 'timestamp', + direction: 'prev', // Descending + limit: 3, + }); + + expect(sorted).toHaveLength(3); + expect(sorted[0].timestamp).toBe(500); + expect(sorted[1].timestamp).toBe(400); + expect(sorted[2].timestamp).toBe(300); + }); + }); + + describe('putMany', () => { + it('should insert multiple records', async () => { + const records: TestRecord[] = [ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + { id: '3', name: 'Third', value: 3 }, + ]; + + await putMany(db, STORE_NAME, records); + + const all = await getAll(db, STORE_NAME); + expect(all).toHaveLength(3); + }); + + it('should handle empty array', async () => { + await putMany(db, STORE_NAME, []); + + const all = await getAll(db, STORE_NAME); + expect(all).toHaveLength(0); + }); + }); + + describe('deleteById', () => { + it('should delete a record', async () => { + await put(db, STORE_NAME, { id: 'to-delete', name: 'Delete Me', value: 0 }); + + await deleteById(db, STORE_NAME, 'to-delete'); + + const result = await getById(db, STORE_NAME, 'to-delete'); + expect(result).toBeNull(); + }); + + it('should not throw for non-existent record', async () => { + await expect(deleteById(db, STORE_NAME, 'non-existent')).resolves.not.toThrow(); + }); + }); + + describe('deleteMany', () => { + beforeEach(async () => { + const records: TestRecord[] = [ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + { id: '3', name: 'Third', value: 3 }, + ]; + + for (const record of records) { + await put(db, STORE_NAME, record); + } + }); + + it('should delete multiple records', async () => { + const count = await deleteMany(db, STORE_NAME, ['1', '2']); + + expect(count).toBe(2); + + const remaining = await getAll(db, STORE_NAME); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe('3'); + }); + + it('should handle empty array', async () => { + const count = await deleteMany(db, STORE_NAME, []); + + expect(count).toBe(0); + }); + }); + + describe('clearStore', () => { + it('should remove all records', async () => { + const records: TestRecord[] = [ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + ]; + + for (const record of records) { + await put(db, STORE_NAME, record); + } + + await clearStore(db, STORE_NAME); + + const all = await getAll(db, STORE_NAME); + expect(all).toHaveLength(0); + }); + + it('should work on empty store', async () => { + await expect(clearStore(db, STORE_NAME)).resolves.not.toThrow(); + }); + }); + + describe('countRecords', () => { + it('should count records', async () => { + const records: TestRecord[] = [ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + { id: '3', name: 'Third', value: 3 }, + ]; + + for (const record of records) { + await put(db, STORE_NAME, record); + } + + const count = await countRecords(db, STORE_NAME); + + expect(count).toBe(3); + }); + + it('should return 0 for empty store', async () => { + const count = await countRecords(db, STORE_NAME); + + expect(count).toBe(0); + }); + }); + + describe('hasStore', () => { + it('should return true for existing store', () => { + expect(hasStore(db, STORE_NAME)).toBe(true); + }); + + it('should return false for non-existent store', () => { + expect(hasStore(db, 'non-existent-store')).toBe(false); + }); + }); +}); diff --git a/packages/utils/src/indexeddb/index.ts b/packages/utils/src/indexeddb/index.ts new file mode 100644 index 00000000..079632b8 --- /dev/null +++ b/packages/utils/src/indexeddb/index.ts @@ -0,0 +1,37 @@ +/** + * IndexedDB 工具模块 + * + * 提供通用的 IndexedDB 操作工具函数,减少重复代码 + * + * @example + * ```typescript + * import { + * openIndexedDB, + * getById, + * getAll, + * getAllWithCursor, + * put, + * deleteById, + * } from '@aitu/utils/indexeddb'; + * + * // 打开数据库 + * const db = await openIndexedDB('my-database', { logPrefix: 'MyService' }); + * + * // CRUD 操作 + * const task = await getById(db, 'tasks', 'task-123'); + * const allTasks = await getAll(db, 'tasks'); + * await put(db, 'tasks', newTask); + * await deleteById(db, 'tasks', 'task-123'); + * + * // 高级查询 + * const recentLogs = await getAllWithCursor(db, 'logs', { + * indexName: 'timestamp', + * direction: 'prev', + * limit: 10, + * filter: (log) => log.level === 'error', + * }); + * ``` + */ + +export * from './types'; +export * from './operations'; diff --git a/packages/utils/src/indexeddb/operations.ts b/packages/utils/src/indexeddb/operations.ts new file mode 100644 index 00000000..3b069de9 --- /dev/null +++ b/packages/utils/src/indexeddb/operations.ts @@ -0,0 +1,388 @@ +/** + * IndexedDB 操作工具函数 + * + * 提供通用的 IndexedDB CRUD 操作,减少重复代码 + */ + +import type { OpenDBOptions, CursorOptions } from './types'; + +/** + * 打开 IndexedDB 数据库 + * + * @param dbName 数据库名称 + * @param options 打开选项 + * @returns Promise + * + * @example + * ```typescript + * const db = await openIndexedDB('my-database'); + * const db = await openIndexedDB('my-database', { version: 2, logPrefix: 'MyService' }); + * ``` + */ +export function openIndexedDB( + dbName: string, + options: OpenDBOptions = {} +): Promise { + const { version, logPrefix = 'IndexedDB', onUpgradeNeeded } = options; + + return new Promise((resolve, reject) => { + const request = version ? indexedDB.open(dbName, version) : indexedDB.open(dbName); + + request.onerror = () => { + reject(new Error(`[${logPrefix}] Failed to open IndexedDB: ${request.error?.message}`)); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onupgradeneeded = (event) => { + if (onUpgradeNeeded) { + onUpgradeNeeded(request.result, event); + } + }; + }); +} + +/** + * 通过 ID 获取单条记录 + * + * @param db 数据库连接 + * @param storeName store 名称 + * @param id 记录 ID + * @returns Promise + * + * @example + * ```typescript + * const task = await getById(db, 'tasks', 'task-123'); + * ``` + */ +export function getById( + db: IDBDatabase, + storeName: string, + id: string | number +): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.get(id); + + request.onsuccess = () => { + resolve((request.result as T) || null); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 获取 store 中的所有记录 + * + * @param db 数据库连接 + * @param storeName store 名称 + * @returns Promise + * + * @example + * ```typescript + * const tasks = await getAll(db, 'tasks'); + * ``` + */ +export function getAll(db: IDBDatabase, storeName: string): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.getAll(); + + request.onsuccess = () => { + resolve((request.result as T[]) || []); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 使用游标遍历记录(支持索引、方向、过滤和分页) + * + * @param db 数据库连接 + * @param storeName store 名称 + * @param options 游标选项 + * @returns Promise + * + * @example + * ```typescript + * // 按时间倒序获取最近 10 条记录 + * const logs = await getAllWithCursor(db, 'logs', { + * indexName: 'timestamp', + * direction: 'prev', + * limit: 10, + * }); + * + * // 过滤特定状态的记录 + * const pendingTasks = await getAllWithCursor(db, 'tasks', { + * filter: (task) => task.status === 'pending', + * }); + * ``` + */ +export function getAllWithCursor( + db: IDBDatabase, + storeName: string, + options: CursorOptions = {} +): Promise { + const { indexName, direction = 'next', filter, limit, offset = 0 } = options; + + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const source = indexName ? store.index(indexName) : store; + const request = source.openCursor(null, direction); + + const results: T[] = []; + let skipped = 0; + + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + const value = cursor.value as T; + + // 应用过滤器 + const shouldInclude = !filter || filter(value); + + if (shouldInclude) { + // 处理 offset + if (skipped < offset) { + skipped++; + } else { + results.push(value); + } + } + + // 检查是否达到 limit + if (limit && results.length >= limit) { + resolve(results); + return; + } + + cursor.continue(); + } else { + resolve(results); + } + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 插入或更新记录 + * + * @param db 数据库连接 + * @param storeName store 名称 + * @param value 要保存的值 + * @returns Promise + * + * @example + * ```typescript + * await put(db, 'tasks', { id: 'task-123', name: 'My Task' }); + * ``` + */ +export function put(db: IDBDatabase, storeName: string, value: T): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.put(value); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 批量插入或更新记录 + * + * @param db 数据库连接 + * @param storeName store 名称 + * @param values 要保存的值数组 + * @returns Promise + * + * @example + * ```typescript + * await putMany(db, 'tasks', [task1, task2, task3]); + * ``` + */ +export function putMany(db: IDBDatabase, storeName: string, values: T[]): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + + transaction.oncomplete = () => { + resolve(); + }; + + transaction.onerror = () => { + reject(transaction.error); + }; + + for (const value of values) { + store.put(value); + } + }); +} + +/** + * 删除单条记录 + * + * @param db 数据库连接 + * @param storeName store 名称 + * @param id 记录 ID + * @returns Promise + * + * @example + * ```typescript + * await deleteById(db, 'tasks', 'task-123'); + * ``` + */ +export function deleteById( + db: IDBDatabase, + storeName: string, + id: string | number +): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.delete(id); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 批量删除记录 + * + * @param db 数据库连接 + * @param storeName store 名称 + * @param ids 记录 ID 数组 + * @returns Promise 删除的记录数 + * + * @example + * ```typescript + * const deletedCount = await deleteMany(db, 'tasks', ['task-1', 'task-2']); + * ``` + */ +export function deleteMany( + db: IDBDatabase, + storeName: string, + ids: (string | number)[] +): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + let deletedCount = 0; + + transaction.oncomplete = () => { + resolve(deletedCount); + }; + + transaction.onerror = () => { + reject(transaction.error); + }; + + for (const id of ids) { + const request = store.delete(id); + request.onsuccess = () => { + deletedCount++; + }; + } + }); +} + +/** + * 清空整个 store + * + * @param db 数据库连接 + * @param storeName store 名称 + * @returns Promise + * + * @example + * ```typescript + * await clearStore(db, 'tasks'); + * ``` + */ +export function clearStore(db: IDBDatabase, storeName: string): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.clear(); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 获取记录数量 + * + * @param db 数据库连接 + * @param storeName store 名称 + * @returns Promise + * + * @example + * ```typescript + * const count = await count(db, 'tasks'); + * ``` + */ +export function countRecords(db: IDBDatabase, storeName: string): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.count(); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 检查 store 是否存在 + * + * @param db 数据库连接 + * @param storeName store 名称 + * @returns boolean + * + * @example + * ```typescript + * if (hasStore(db, 'tasks')) { + * // store exists + * } + * ``` + */ +export function hasStore(db: IDBDatabase, storeName: string): boolean { + return db.objectStoreNames.contains(storeName); +} diff --git a/packages/utils/src/indexeddb/types.ts b/packages/utils/src/indexeddb/types.ts new file mode 100644 index 00000000..b3d3699a --- /dev/null +++ b/packages/utils/src/indexeddb/types.ts @@ -0,0 +1,56 @@ +/** + * IndexedDB 工具类型定义 + */ + +/** + * 打开数据库的选项 + */ +export interface OpenDBOptions { + /** 数据库版本,默认不指定 */ + version?: number; + /** 日志前缀 */ + logPrefix?: string; + /** 升级回调 */ + onUpgradeNeeded?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void; +} + +/** + * Store 配置(用于创建 store) + */ +export interface StoreConfig { + name: string; + keyPath: string; + autoIncrement?: boolean; + indexes?: IndexConfig[]; +} + +/** + * 索引配置 + */ +export interface IndexConfig { + name: string; + keyPath: string | string[]; + unique?: boolean; + multiEntry?: boolean; +} + +/** + * 游标遍历选项 + */ +export interface CursorOptions { + /** 索引名称,不指定则使用 store 主键 */ + indexName?: string; + /** 游标方向 */ + direction?: IDBCursorDirection; + /** 过滤函数 */ + filter?: (value: T) => boolean; + /** 限制返回数量 */ + limit?: number; + /** 跳过前 N 条记录 */ + offset?: number; +} + +/** + * 事务模式 + */ +export type TransactionMode = 'readonly' | 'readwrite'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 037faa06..7f6c6a9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,6 +378,9 @@ importers: packages/utils: devDependencies: + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 vite: specifier: ^6.4.1 version: 6.4.1(@types/node@18.16.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.2)(terser@5.46.0) @@ -4834,6 +4837,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5063,7 +5070,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -13534,6 +13541,8 @@ snapshots: transitivePeerDependencies: - supports-color + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {}