refactor(web): consolidate download helpers (#31664)

This commit is contained in:
盐粒 Yanli
2026-01-29 16:02:49 +08:00
committed by GitHub
parent 74cfe77674
commit b9ac7af9c5
26 changed files with 167 additions and 270 deletions

View File

@@ -31,6 +31,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import AppIcon from '../base/app-icon' import AppIcon from '../base/app-icon'
import AppOperations from './app-operations' import AppOperations from './app-operations'
@@ -145,13 +146,8 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
appID: appDetail.id, appID: appDetail.id,
include, include,
}) })
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' }) const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file) downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
a.href = url
a.download = `${appDetail.name}.yml`
a.click()
URL.revokeObjectURL(url)
} }
catch { catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) }) notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@@ -11,6 +11,7 @@ import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/kn
import { useInvalid } from '@/service/use-base' import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline' import { useExportPipelineDSL } from '@/service/use-pipeline'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import ActionButton from '../../base/action-button' import ActionButton from '../../base/action-button'
import Confirm from '../../base/confirm' import Confirm from '../../base/confirm'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
@@ -64,13 +65,8 @@ const DropDown = ({
pipelineId: pipeline_id, pipelineId: pipeline_id,
include, include,
}) })
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' }) const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file) downloadBlob({ data: file, fileName: `${name}.pipeline` })
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
} }
catch { catch {
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) }) Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@@ -21,6 +21,7 @@ import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import Button from '../../../base/button' import Button from '../../../base/button'
import AddAnnotationModal from '../add-annotation-modal' import AddAnnotationModal from '../add-annotation-modal'
import BatchAddModal from '../batch-add-annotation-modal' import BatchAddModal from '../batch-add-annotation-modal'
@@ -56,28 +57,23 @@ const HeaderOptions: FC<Props> = ({
) )
const JSONLOutput = () => { const JSONLOutput = () => {
const a = document.createElement('a')
const content = listTransformer(list).join('\n') const content = listTransformer(list).join('\n')
const file = new Blob([content], { type: 'application/jsonl' }) const file = new Blob([content], { type: 'application/jsonl' })
const url = URL.createObjectURL(file) downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` })
a.href = url
a.download = `annotations-${locale}.jsonl`
a.click()
URL.revokeObjectURL(url)
} }
const fetchList = async () => { const fetchList = React.useCallback(async () => {
const { data }: any = await fetchExportAnnotationList(appId) const { data }: any = await fetchExportAnnotationList(appId)
setList(data as AnnotationItemBasic[]) setList(data as AnnotationItemBasic[])
} }, [appId])
useEffect(() => { useEffect(() => {
fetchList() fetchList()
}, []) }, [fetchList])
useEffect(() => { useEffect(() => {
if (controlUpdateList) if (controlUpdateList)
fetchList() fetchList()
}, [controlUpdateList]) }, [controlUpdateList, fetchList])
const [showBulkImportModal, setShowBulkImportModal] = useState(false) const [showBulkImportModal, setShowBulkImportModal] = useState(false)
const [showClearConfirm, setShowClearConfirm] = useState(false) const [showClearConfirm, setShowClearConfirm] = useState(false)

View File

@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import type { IConfigVarProps } from './index' import type { IConfigVarProps } from './index'
import type { ExternalDataTool } from '@/models/common' import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug' import type { PromptVariable } from '@/models/debug'
import { act, fireEvent, render, screen } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import { vi } from 'vitest' import { vi } from 'vitest'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
@@ -240,7 +240,9 @@ describe('ConfigVar', () => {
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' }) const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
fireEvent.click(saveButton) fireEvent.click(saveButton)
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1) await waitFor(() => {
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
})
}) })
it('should show error when variable key is duplicated', async () => { it('should show error when variable key is duplicated', async () => {

View File

@@ -33,6 +33,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import { formatTime } from '@/utils/time' import { formatTime } from '@/utils/time'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
@@ -161,13 +162,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
appID: app.id, appID: app.id,
include, include,
}) })
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' }) const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file) downloadBlob({ data: file, fileName: `${app.name}.yml` })
a.href = url
a.download = `${app.name}.yml`
a.click()
URL.revokeObjectURL(url)
} }
catch { catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) }) notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
@@ -346,7 +342,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`, dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
}) })
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}` return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}`
}, [app.updated_at, app.created_at]) }, [app.updated_at, app.created_at, t])
return ( return (
<> <>

View File

@@ -15,11 +15,11 @@ import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import { formatFileSize } from '@/utils/format' import { formatFileSize } from '@/utils/format'
import FileImageRender from '../file-image-render' import FileImageRender from '../file-image-render'
import FileTypeIcon from '../file-type-icon' import FileTypeIcon from '../file-type-icon'
import { import {
downloadFile,
fileIsUploaded, fileIsUploaded,
getFileAppearanceType, getFileAppearanceType,
getFileExtension, getFileExtension,
@@ -140,7 +140,7 @@ const FileInAttachmentItem = ({
showDownloadAction && ( showDownloadAction && (
<ActionButton onClick={(e) => { <ActionButton onClick={(e) => {
e.stopPropagation() e.stopPropagation()
downloadFile(url || base64Url || '', name) downloadUrl({ url: url || base64Url || '', fileName: name, target: '_blank' })
}} }}
> >
<RiDownloadLine className="h-4 w-4" /> <RiDownloadLine className="h-4 w-4" />

View File

@@ -8,9 +8,9 @@ import Button from '@/app/components/base/button'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other' import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ImagePreview from '@/app/components/base/image-uploader/image-preview' import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { downloadUrl } from '@/utils/download'
import FileImageRender from '../file-image-render' import FileImageRender from '../file-image-render'
import { import {
downloadFile,
fileIsUploaded, fileIsUploaded,
} from '../utils' } from '../utils'
@@ -85,7 +85,7 @@ const FileImageItem = ({
className="absolute bottom-0.5 right-0.5 flex h-6 w-6 items-center justify-center rounded-lg bg-components-actionbar-bg shadow-md" className="absolute bottom-0.5 right-0.5 flex h-6 w-6 items-center justify-center rounded-lg bg-components-actionbar-bg shadow-md"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
downloadFile(download_url || '', name) downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
}} }}
> >
<RiDownloadLine className="h-4 w-4 text-text-tertiary" /> <RiDownloadLine className="h-4 w-4 text-text-tertiary" />

View File

@@ -12,10 +12,10 @@ import VideoPreview from '@/app/components/base/file-uploader/video-preview'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other' import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import { formatFileSize } from '@/utils/format' import { formatFileSize } from '@/utils/format'
import FileTypeIcon from '../file-type-icon' import FileTypeIcon from '../file-type-icon'
import { import {
downloadFile,
fileIsUploaded, fileIsUploaded,
getFileAppearanceType, getFileAppearanceType,
getFileExtension, getFileExtension,
@@ -100,7 +100,7 @@ const FileItem = ({
className="absolute -right-1 -top-1 hidden group-hover/file-item:flex" className="absolute -right-1 -top-1 hidden group-hover/file-item:flex"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
downloadFile(download_url || '', name) downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
}} }}
> >
<RiDownloadLine className="h-3.5 w-3.5 text-text-tertiary" /> <RiDownloadLine className="h-3.5 w-3.5 text-text-tertiary" />

View File

@@ -1,4 +1,3 @@
import type { MockInstance } from 'vitest'
import mime from 'mime' import mime from 'mime'
import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { upload } from '@/service/base' import { upload } from '@/service/base'
@@ -6,7 +5,6 @@ import { TransferMethod } from '@/types/app'
import { FILE_EXTS } from '../prompt-editor/constants' import { FILE_EXTS } from '../prompt-editor/constants'
import { FileAppearanceTypeEnum } from './types' import { FileAppearanceTypeEnum } from './types'
import { import {
downloadFile,
fileIsUploaded, fileIsUploaded,
fileUpload, fileUpload,
getFileAppearanceType, getFileAppearanceType,
@@ -782,74 +780,4 @@ describe('file-uploader utils', () => {
} as any)).toBe(true) } as any)).toBe(true)
}) })
}) })
describe('downloadFile', () => {
let mockAnchor: HTMLAnchorElement
let createElementMock: MockInstance
let appendChildMock: MockInstance
let removeChildMock: MockInstance
beforeEach(() => {
// Mock createElement and appendChild
mockAnchor = {
href: '',
download: '',
style: { display: '' },
target: '',
title: '',
click: vi.fn(),
} as unknown as HTMLAnchorElement
createElementMock = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any)
appendChildMock = vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
return node
})
removeChildMock = vi.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => {
return node
})
})
afterEach(() => {
vi.resetAllMocks()
})
it('should create and trigger download with correct attributes', () => {
const url = 'https://example.com/test.pdf'
const filename = 'test.pdf'
downloadFile(url, filename)
// Verify anchor element was created with correct properties
expect(createElementMock).toHaveBeenCalledWith('a')
expect(mockAnchor.href).toBe(url)
expect(mockAnchor.download).toBe(filename)
expect(mockAnchor.style.display).toBe('none')
expect(mockAnchor.target).toBe('_blank')
expect(mockAnchor.title).toBe(filename)
// Verify DOM operations
expect(appendChildMock).toHaveBeenCalledWith(mockAnchor)
expect(mockAnchor.click).toHaveBeenCalled()
expect(removeChildMock).toHaveBeenCalledWith(mockAnchor)
})
it('should handle empty filename', () => {
const url = 'https://example.com/test.pdf'
const filename = ''
downloadFile(url, filename)
expect(mockAnchor.download).toBe('')
expect(mockAnchor.title).toBe('')
})
it('should handle empty url', () => {
const url = ''
const filename = 'test.pdf'
downloadFile(url, filename)
expect(mockAnchor.href).toBe('')
})
})
}) })

View File

@@ -249,15 +249,3 @@ export const fileIsUploaded = (file: FileEntity) => {
if (file.transferMethod === TransferMethod.remote_url && file.progress === 100) if (file.transferMethod === TransferMethod.remote_url && file.progress === 100)
return true return true
} }
export const downloadFile = (url: string, filename: string) => {
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.style.display = 'none'
anchor.target = '_blank'
anchor.title = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
}

View File

@@ -8,6 +8,7 @@ import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { downloadUrl } from '@/utils/download'
type ImagePreviewProps = { type ImagePreviewProps = {
url: string url: string
@@ -60,27 +61,14 @@ const ImagePreview: FC<ImagePreviewProps> = ({
const downloadImage = () => { const downloadImage = () => {
// Open in a new window, considering the case when the page is inside an iframe // Open in a new window, considering the case when the page is inside an iframe
if (url.startsWith('http') || url.startsWith('https')) { if (url.startsWith('http') || url.startsWith('https') || url.startsWith('data:image')) {
const a = document.createElement('a') downloadUrl({ url, fileName: title, target: '_blank' })
a.href = url return
a.target = '_blank'
a.download = title
a.click()
}
else if (url.startsWith('data:image')) {
// Base64 image
const a = document.createElement('a')
a.href = url
a.target = '_blank'
a.download = title
a.click()
}
else {
Toast.notify({
type: 'error',
message: `Unable to open image: ${url}`,
})
} }
Toast.notify({
type: 'error',
message: `Unable to open image: ${url}`,
})
} }
const zoomIn = () => { const zoomIn = () => {
@@ -135,12 +123,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
catch (err) { catch (err) {
console.error('Failed to copy image:', err) console.error('Failed to copy image:', err)
const link = document.createElement('a') downloadUrl({ url, fileName: `${title}.png` })
link.href = url
link.download = `${title}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
Toast.notify({ Toast.notify({
type: 'info', type: 'info',
@@ -215,6 +198,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
tabIndex={-1} tabIndex={-1}
> >
{ } { }
{/* eslint-disable-next-line next/no-img-element */}
<img <img
ref={imgRef} ref={imgRef}
alt={title} alt={title}

View File

@@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { downloadUrl } from '@/utils/download'
type Props = { type Props = {
content: string content: string
@@ -40,11 +41,10 @@ const ShareQRCode = ({ content }: Props) => {
}, [isShow]) }, [isShow])
const downloadQR = () => { const downloadQR = () => {
const canvas = document.getElementsByTagName('canvas')[0] const canvas = qrCodeRef.current?.querySelector('canvas')
const link = document.createElement('a') if (!(canvas instanceof HTMLCanvasElement))
link.download = 'qrcode.png' return
link.href = canvas.toDataURL() downloadUrl({ url: canvas.toDataURL(), fileName: 'qrcode.png' })
link.click()
} }
const handlePanelClick = (event: React.MouseEvent) => { const handlePanelClick = (event: React.MouseEvent) => {

View File

@@ -179,8 +179,10 @@ describe('RetryButton (IndexFailed)', () => {
}, false), }, false),
) )
// Delay the response to test loading state let resolveRetry: ((value: { result: 'success' }) => void) | undefined
mockRetryErrorDocs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: 'success' }), 100))) mockRetryErrorDocs.mockImplementation(() => new Promise((resolve) => {
resolveRetry = resolve
}))
render(<RetryButton datasetId="test-dataset" />) render(<RetryButton datasetId="test-dataset" />)
@@ -193,6 +195,11 @@ describe('RetryButton (IndexFailed)', () => {
expect(button).toHaveClass('cursor-not-allowed') expect(button).toHaveClass('cursor-not-allowed')
expect(button).toHaveClass('text-text-disabled') expect(button).toHaveClass('text-text-disabled')
}) })
resolveRetry?.({ result: 'success' })
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
}) })
}) })

View File

@@ -23,9 +23,10 @@ vi.mock('@/app/components/base/toast', () => ({
}, },
})) }))
// Mock downloadFile utility // Mock download utilities
vi.mock('@/utils/format', () => ({ vi.mock('@/utils/download', () => ({
downloadFile: vi.fn(), downloadBlob: vi.fn(),
downloadUrl: vi.fn(),
})) }))
// Capture Confirm callbacks // Capture Confirm callbacks
@@ -502,8 +503,8 @@ describe('TemplateCard', () => {
}) })
}) })
it('should call downloadFile on successful export', async () => { it('should call downloadBlob on successful export', async () => {
const { downloadFile } = await import('@/utils/format') const { downloadBlob } = await import('@/utils/download')
mockExportPipelineDSL.mockImplementation((_id, callbacks) => { mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onSuccess({ data: 'yaml_content' }) callbacks.onSuccess({ data: 'yaml_content' })
return Promise.resolve() return Promise.resolve()
@@ -514,7 +515,7 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton) fireEvent.click(exportButton)
await waitFor(() => { await waitFor(() => {
expect(downloadFile).toHaveBeenCalledWith(expect.objectContaining({ expect(downloadBlob).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'Test Pipeline.pipeline', fileName: 'Test Pipeline.pipeline',
})) }))
}) })

View File

@@ -16,7 +16,7 @@ import {
useInvalidCustomizedTemplateList, useInvalidCustomizedTemplateList,
usePipelineTemplateById, usePipelineTemplateById,
} from '@/service/use-pipeline' } from '@/service/use-pipeline'
import { downloadFile } from '@/utils/format' import { downloadBlob } from '@/utils/download'
import Actions from './actions' import Actions from './actions'
import Content from './content' import Content from './content'
import Details from './details' import Details from './details'
@@ -108,10 +108,7 @@ const TemplateCard = ({
await exportPipelineDSL(pipeline.id, { await exportPipelineDSL(pipeline.id, {
onSuccess: (res) => { onSuccess: (res) => {
const blob = new Blob([res.data], { type: 'application/yaml' }) const blob = new Blob([res.data], { type: 'application/yaml' })
downloadFile({ downloadBlob({ data: blob, fileName: `${pipeline.name}.pipeline` })
data: blob,
fileName: `${pipeline.name}.pipeline`,
})
Toast.notify({ Toast.notify({
type: 'success', type: 'success',
message: t('exportDSL.successTip', { ns: 'datasetPipeline' }), message: t('exportDSL.successTip', { ns: 'datasetPipeline' }),

View File

@@ -125,11 +125,25 @@ const WaterCrawl: FC<Props> = ({
await sleep(2500) await sleep(2500)
return await waitForCrawlFinished(jobId) return await waitForCrawlFinished(jobId)
} }
catch (e: any) { catch (error: unknown) {
const errorBody = await e.json() let errorMessage = ''
const maybeErrorWithJson = error as { json?: () => Promise<unknown>, message?: unknown } | null
if (maybeErrorWithJson?.json) {
try {
const errorBody = await maybeErrorWithJson.json() as { message?: unknown } | null
if (typeof errorBody?.message === 'string')
errorMessage = errorBody.message
}
catch {}
}
if (!errorMessage && typeof maybeErrorWithJson?.message === 'string')
errorMessage = maybeErrorWithJson.message
return { return {
isError: true, isError: true,
errorMessage: errorBody.message, errorMessage,
data: { data: {
data: [], data: [],
}, },

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card' import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card'
import { useExportPipelineDSL } from '@/service/use-pipeline' import { useExportPipelineDSL } from '@/service/use-pipeline'
import { downloadBlob } from '@/utils/download'
type ModalState = { type ModalState = {
showRenameModal: boolean showRenameModal: boolean
@@ -65,13 +66,8 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
pipelineId: pipeline_id, pipelineId: pipeline_id,
include, include,
}) })
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' }) const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file) downloadBlob({ data: file, fileName: `${name}.pipeline` })
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
} }
catch { catch {
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) }) Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@@ -10,6 +10,7 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common' import { getDocDownloadUrl } from '@/service/common'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import Button from '../../base/button' import Button from '../../base/button'
import Gdpr from '../../base/icons/src/public/common/Gdpr' import Gdpr from '../../base/icons/src/public/common/Gdpr'
import Iso from '../../base/icons/src/public/common/Iso' import Iso from '../../base/icons/src/public/common/Iso'
@@ -47,9 +48,7 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
mutationFn: async () => { mutationFn: async () => {
try { try {
const ret = await getDocDownloadUrl(doc_name) const ret = await getDocDownloadUrl(doc_name)
const a = document.createElement('a') downloadUrl({ url: ret.url })
a.href = ret.url
a.click()
Toast.notify({ Toast.notify({
type: 'success', type: 'success',
message: t('operation.downloadSuccess', { ns: 'common' }), message: t('operation.downloadSuccess', { ns: 'common' }),

View File

@@ -11,6 +11,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useExportPipelineDSL } from '@/service/use-pipeline' import { useExportPipelineDSL } from '@/service/use-pipeline'
import { fetchWorkflowDraft } from '@/service/workflow' import { fetchWorkflowDraft } from '@/service/workflow'
import { downloadBlob } from '@/utils/download'
import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesSyncDraft } from './use-nodes-sync-draft'
export const useDSL = () => { export const useDSL = () => {
@@ -37,13 +38,8 @@ export const useDSL = () => {
pipelineId, pipelineId,
include, include,
}) })
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' }) const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file) downloadBlob({ data: file, fileName: `${knowledgeName}.pipeline` })
a.href = url
a.download = `${knowledgeName}.pipeline`
a.click()
URL.revokeObjectURL(url)
} }
catch { catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) }) notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@@ -11,6 +11,7 @@ import {
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { exportAppConfig } from '@/service/apps' import { exportAppConfig } from '@/service/apps'
import { fetchWorkflowDraft } from '@/service/workflow' import { fetchWorkflowDraft } from '@/service/workflow'
import { downloadBlob } from '@/utils/download'
import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesSyncDraft } from './use-nodes-sync-draft'
export const useDSL = () => { export const useDSL = () => {
@@ -37,13 +38,8 @@ export const useDSL = () => {
include, include,
workflowID: workflowId, workflowID: workflowId,
}) })
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' }) const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file) downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
a.href = url
a.download = `${appDetail.name}.yml`
a.click()
URL.revokeObjectURL(url)
} }
catch { catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) }) notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@@ -15,7 +15,7 @@ import {
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/portal-to-follow-elem'
import { useDownloadPlugin } from '@/service/use-plugins' import { useDownloadPlugin } from '@/service/use-plugins'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadFile } from '@/utils/format' import { downloadBlob } from '@/utils/download'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
type Props = { type Props = {
@@ -67,7 +67,7 @@ const OperationDropdown: FC<Props> = ({
if (!needDownload || !blob) if (!needDownload || !blob)
return return
const fileName = `${author}-${name}_${version}.zip` const fileName = `${author}-${name}_${version}.zip`
downloadFile({ data: blob, fileName }) downloadBlob({ data: blob, fileName })
setNeedDownload(false) setNeedDownload(false)
queryClient.removeQueries({ queryClient.removeQueries({
queryKey: ['plugins', 'downloadPlugin', downloadInfo], queryKey: ['plugins', 'downloadPlugin', downloadInfo],

View File

@@ -19,6 +19,7 @@ import {
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/portal-to-follow-elem'
import { useStore } from '@/app/components/workflow/store' import { useStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import { useNodesReadOnly } from '../hooks' import { useNodesReadOnly } from '../hooks'
import TipPopup from './tip-popup' import TipPopup from './tip-popup'
@@ -146,26 +147,14 @@ const MoreActions: FC = () => {
} }
} }
const fileName = `${filename}.${type}`
if (currentWorkflow) { if (currentWorkflow) {
setPreviewUrl(dataUrl) setPreviewUrl(dataUrl)
setPreviewTitle(`${filename}.${type}`) setPreviewTitle(fileName)
}
const link = document.createElement('a') downloadUrl({ url: dataUrl, fileName })
link.href = dataUrl
link.download = `${filename}.${type}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
else {
// For current view, just download
const link = document.createElement('a')
link.href = dataUrl
link.download = `${filename}.${type}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
} }
catch (error) { catch (error) {
console.error('Export image failed:', error) console.error('Export image failed:', error)

View File

@@ -994,7 +994,7 @@
"count": 1 "count": 1
}, },
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 3 "count": 2
} }
}, },
"app/components/base/file-uploader/utils.ts": { "app/components/base/file-uploader/utils.ts": {
@@ -1661,7 +1661,7 @@
"count": 1 "count": 1
}, },
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 5 "count": 4
} }
}, },
"app/components/datasets/create/website/watercrawl/options.tsx": { "app/components/datasets/create/website/watercrawl/options.tsx": {
@@ -4376,11 +4376,6 @@
"count": 1 "count": 1
} }
}, },
"utils/format.spec.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"utils/get-icon.spec.ts": { "utils/get-icon.spec.ts": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 2 "count": 2

View File

@@ -0,0 +1,75 @@
import { downloadBlob, downloadUrl } from './download'
describe('downloadUrl', () => {
let mockAnchor: HTMLAnchorElement
beforeEach(() => {
mockAnchor = {
href: '',
download: '',
rel: '',
target: '',
style: { display: '' },
click: vi.fn(),
remove: vi.fn(),
} as unknown as HTMLAnchorElement
vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor)
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should create a link and trigger a download correctly', () => {
downloadUrl({ url: 'https://example.com/file.txt', fileName: 'file.txt', target: '_blank' })
expect(mockAnchor.href).toBe('https://example.com/file.txt')
expect(mockAnchor.download).toBe('file.txt')
expect(mockAnchor.rel).toBe('noopener noreferrer')
expect(mockAnchor.target).toBe('_blank')
expect(mockAnchor.style.display).toBe('none')
expect(mockAnchor.click).toHaveBeenCalled()
expect(mockAnchor.remove).toHaveBeenCalled()
})
it('should skip when url is empty', () => {
downloadUrl({ url: '' })
expect(document.createElement).not.toHaveBeenCalled()
})
})
describe('downloadBlob', () => {
it('should create a blob url, trigger download, and revoke url', () => {
const blob = new Blob(['test'], { type: 'text/plain' })
const mockUrl = 'blob:mock-url'
const createObjectURLMock = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue(mockUrl)
const revokeObjectURLMock = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {})
const mockAnchor = {
href: '',
download: '',
rel: '',
target: '',
style: { display: '' },
click: vi.fn(),
remove: vi.fn(),
} as unknown as HTMLAnchorElement
vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor)
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node)
downloadBlob({ data: blob, fileName: 'file.txt' })
expect(createObjectURLMock).toHaveBeenCalledWith(blob)
expect(mockAnchor.href).toBe(mockUrl)
expect(mockAnchor.download).toBe('file.txt')
expect(mockAnchor.rel).toBe('noopener noreferrer')
expect(mockAnchor.click).toHaveBeenCalled()
expect(mockAnchor.remove).toHaveBeenCalled()
expect(revokeObjectURLMock).toHaveBeenCalledWith(mockUrl)
vi.restoreAllMocks()
})
})

View File

@@ -1,4 +1,4 @@
import { downloadFile, formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format' import { formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format'
describe('formatNumber', () => { describe('formatNumber', () => {
it('should correctly format integers', () => { it('should correctly format integers', () => {
@@ -82,49 +82,6 @@ describe('formatTime', () => {
expect(formatTime(7200)).toBe('2.00 h') expect(formatTime(7200)).toBe('2.00 h')
}) })
}) })
describe('downloadFile', () => {
it('should create a link and trigger a download correctly', () => {
// Mock data
const blob = new Blob(['test content'], { type: 'text/plain' })
const fileName = 'test-file.txt'
const mockUrl = 'blob:mockUrl'
// Mock URL.createObjectURL
const createObjectURLMock = vi.fn().mockReturnValue(mockUrl)
const revokeObjectURLMock = vi.fn()
Object.defineProperty(window.URL, 'createObjectURL', { value: createObjectURLMock })
Object.defineProperty(window.URL, 'revokeObjectURL', { value: revokeObjectURLMock })
// Mock createElement and appendChild
const mockLink = {
href: '',
download: '',
click: vi.fn(),
remove: vi.fn(),
}
const createElementMock = vi.spyOn(document, 'createElement').mockReturnValue(mockLink as any)
const appendChildMock = vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
return node
})
// Call the function
downloadFile({ data: blob, fileName })
// Assertions
expect(createObjectURLMock).toHaveBeenCalledWith(blob)
expect(createElementMock).toHaveBeenCalledWith('a')
expect(mockLink.href).toBe(mockUrl)
expect(mockLink.download).toBe(fileName)
expect(appendChildMock).toHaveBeenCalledWith(mockLink)
expect(mockLink.click).toHaveBeenCalled()
expect(mockLink.remove).toHaveBeenCalled()
expect(revokeObjectURLMock).toHaveBeenCalledWith(mockUrl)
// Clean up mocks
vi.restoreAllMocks()
})
})
describe('formatNumberAbbreviated', () => { describe('formatNumberAbbreviated', () => {
it('should return number as string when less than 1000', () => { it('should return number as string when less than 1000', () => {
expect(formatNumberAbbreviated(0)).toBe('0') expect(formatNumberAbbreviated(0)).toBe('0')

View File

@@ -100,17 +100,6 @@ export const formatTime = (seconds: number) => {
return `${seconds.toFixed(2)} ${units[index]}` return `${seconds.toFixed(2)} ${units[index]}`
} }
export const downloadFile = ({ data, fileName }: { data: Blob, fileName: string }) => {
const url = window.URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
}
/** /**
* Formats a number into a readable string using "k", "M", or "B" suffix. * Formats a number into a readable string using "k", "M", or "B" suffix.
* @example * @example