This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
import {
memo,
useState,
} from 'react'
import {
RiDeleteBinLine,
RiDownloadLine,
RiEyeLine,
} from '@remixicon/react'
import FileTypeIcon from '../file-type-icon'
import {
downloadFile,
fileIsUploaded,
getFileAppearanceType,
getFileExtension,
} from '../utils'
import FileImageRender from '../file-image-render'
import type { FileEntity } from '../types'
import ActionButton from '@/app/components/base/action-button'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { formatFileSize } from '@/utils/format'
import cn from '@/utils/classnames'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import { PreviewMode } from '@/app/components/base/features/types'
type FileInAttachmentItemProps = {
file: FileEntity
showDeleteAction?: boolean
showDownloadAction?: boolean
onRemove?: (fileId: string) => void
onReUpload?: (fileId: string) => void
canPreview?: boolean
previewMode?: PreviewMode
}
const FileInAttachmentItem = ({
file,
showDeleteAction,
showDownloadAction = true,
onRemove,
onReUpload,
canPreview,
previewMode = PreviewMode.CurrentPage,
}: FileInAttachmentItemProps) => {
const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file
const ext = getFileExtension(name, type, isRemote)
const isImageFile = supportFileType === SupportUploadFileTypes.image
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
return (
<>
<div className={cn(
'flex h-12 items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pr-3 shadow-xs',
progress === -1 && 'border-state-destructive-border bg-state-destructive-hover',
canPreview && previewMode === PreviewMode.NewPage && 'cursor-pointer',
)}
onClick={() => {
if (canPreview && previewMode === PreviewMode.NewPage)
window.open(url || base64Url || '', '_blank')
}}
>
<div className='flex h-12 w-12 items-center justify-center'>
{
isImageFile && (
<FileImageRender
className='h-8 w-8'
imageUrl={base64Url || url || ''}
/>
)
}
{
!isImageFile && (
<FileTypeIcon
type={getFileAppearanceType(name, type)}
size='xl'
/>
)
}
</div>
<div className='mr-1 w-0 grow'>
<div
className='system-xs-medium mb-0.5 flex items-center truncate text-text-secondary'
title={file.name}
>
<div className='truncate'>{name}</div>
</div>
<div className='system-2xs-medium-uppercase flex items-center text-text-tertiary'>
{
ext && (
<span>{ext.toLowerCase()}</span>
)
}
{
ext && (
<span className='system-2xs-medium mx-1'></span>
)
}
{
!!file.size && (
<span>{formatFileSize(file.size)}</span>
)
}
</div>
</div>
<div className='flex shrink-0 items-center'>
{
progress >= 0 && !fileIsUploaded(file) && (
<ProgressCircle
className='mr-2.5'
percentage={progress}
/>
)
}
{
progress === -1 && (
<ActionButton
className='mr-1'
onClick={() => onReUpload?.(id)}
>
<ReplayLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
)
}
{
showDeleteAction && (
<ActionButton onClick={() => onRemove?.(id)}>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
)
}
{
canPreview && isImageFile && (
<ActionButton className='mr-1' onClick={() => setImagePreviewUrl(url || '')}>
<RiEyeLine className='h-4 w-4' />
</ActionButton>
)
}
{
showDownloadAction && (
<ActionButton onClick={(e) => {
e.stopPropagation()
downloadFile(url || base64Url || '', name)
}}>
<RiDownloadLine className='h-4 w-4' />
</ActionButton>
)
}
</div>
</div>
{
imagePreviewUrl && canPreview && (
<ImagePreview
title={name}
url={imagePreviewUrl}
onCancel={() => setImagePreviewUrl('')}
/>
)
}
</>
)
}
export default memo(FileInAttachmentItem)

View File

@@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import FileUploaderInAttachmentWrapper from './index'
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { PreviewMode } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
import { ToastProvider } from '@/app/components/base/toast'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'128\' height=\'128\'><rect width=\'128\' height=\'128\' rx=\'16\' fill=\'#E0F2FE\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'18\' fill=\'#1F2937\'>IMG</text></svg>'
const mockFiles: FileEntity[] = [
{
id: 'file-1',
name: 'Requirements.pdf',
size: 256000,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.document,
url: '',
},
{
id: 'file-2',
name: 'Interface.png',
size: 128000,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.image,
base64Url: SAMPLE_IMAGE,
},
{
id: 'file-3',
name: 'Voiceover.mp3',
size: 512000,
type: 'audio/mpeg',
progress: 35,
transferMethod: TransferMethod.remote_url,
supportFileType: SupportUploadFileTypes.audio,
url: '',
},
]
const fileConfig: FileUpload = {
enabled: true,
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
allowed_file_types: ['document', 'image', 'audio'],
number_limits: 5,
preview_config: { mode: PreviewMode.NewPage, file_type_list: ['pdf', 'png'] },
}
const meta = {
title: 'Base/Data Entry/FileUploaderInAttachment',
component: FileUploaderInAttachmentWrapper,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Attachment-style uploader that supports local files and remote links. Demonstrates upload progress, re-upload, and preview actions.',
},
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/uploads',
params: { appId: 'demo-app' },
},
},
},
tags: ['autodocs'],
args: {
fileConfig,
},
} satisfies Meta<typeof FileUploaderInAttachmentWrapper>
export default meta
type Story = StoryObj<typeof meta>
const AttachmentDemo = (props: React.ComponentProps<typeof FileUploaderInAttachmentWrapper>) => {
const [files, setFiles] = useState<FileEntity[]>(mockFiles)
return (
<ToastProvider>
<div className="w-[320px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs">
<FileUploaderInAttachmentWrapper
{...props}
value={files}
onChange={setFiles}
/>
</div>
</ToastProvider>
)
}
export const Playground: Story = {
render: args => <AttachmentDemo {...args} />,
args: {
onChange: fn(),
},
}
export const Disabled: Story = {
render: args => <AttachmentDemo {...args} isDisabled />,
args: {
onChange: fn(),
},
}

View File

@@ -0,0 +1,141 @@
import {
useCallback,
} from 'react'
import {
RiLink,
RiUploadCloud2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import FileFromLinkOrLocal from '../file-from-link-or-local'
import {
FileContextProvider,
useStore,
} from '../store'
import type { FileEntity } from '../types'
import FileInput from '../file-input'
import { useFile } from '../hooks'
import FileItem from './file-item'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
import type { FileUpload } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
type Option = {
value: string
label: string
icon: React.JSX.Element
}
type FileUploaderInAttachmentProps = {
isDisabled?: boolean
fileConfig: FileUpload
}
const FileUploaderInAttachment = ({
isDisabled,
fileConfig,
}: FileUploaderInAttachmentProps) => {
const { t } = useTranslation()
const files = useStore(s => s.files)
const {
handleRemoveFile,
handleReUploadFile,
} = useFile(fileConfig)
const options = [
{
value: TransferMethod.local_file,
label: t('common.fileUploader.uploadFromComputer'),
icon: <RiUploadCloud2Line className='h-4 w-4' />,
},
{
value: TransferMethod.remote_url,
label: t('common.fileUploader.pasteFileLink'),
icon: <RiLink className='h-4 w-4' />,
},
]
const renderButton = useCallback((option: Option, open?: boolean) => {
return (
<Button
key={option.value}
variant='tertiary'
className={cn('relative grow', open && 'bg-components-button-tertiary-bg-hover')}
disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)}
>
{option.icon}
<span className='ml-1'>{option.label}</span>
{
option.value === TransferMethod.local_file && (
<FileInput fileConfig={fileConfig} />
)
}
</Button>
)
}, [fileConfig, files.length])
const renderTrigger = useCallback((option: Option) => {
return (open: boolean) => renderButton(option, open)
}, [renderButton])
const renderOption = useCallback((option: Option) => {
if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file))
return renderButton(option)
if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) {
return (
<FileFromLinkOrLocal
key={option.value}
showFromLocal={false}
trigger={renderTrigger(option)}
fileConfig={fileConfig}
/>
)
}
}, [renderButton, renderTrigger, fileConfig])
return (
<div>
{!isDisabled && (
<div className='flex items-center space-x-1'>
{options.map(renderOption)}
</div>
)}
<div className='mt-1 space-y-1'>
{
files.map(file => (
<FileItem
key={file.id}
file={file}
showDeleteAction={!isDisabled}
showDownloadAction={false}
onRemove={() => handleRemoveFile(file.id)}
onReUpload={() => handleReUploadFile(file.id)}
canPreview={fileConfig.preview_config?.file_type_list?.includes(file.type)}
previewMode={fileConfig.preview_config?.mode}
/>
))
}
</div>
</div>
)
}
export type FileUploaderInAttachmentWrapperProps = {
value?: FileEntity[]
onChange: (files: FileEntity[]) => void
fileConfig: FileUpload
isDisabled?: boolean
}
const FileUploaderInAttachmentWrapper = ({
value,
onChange,
fileConfig,
isDisabled,
}: FileUploaderInAttachmentWrapperProps) => {
return (
<FileContextProvider
value={value}
onChange={onChange}
>
<FileUploaderInAttachment isDisabled={isDisabled} fileConfig={fileConfig} />
</FileContextProvider>
)
}
export default FileUploaderInAttachmentWrapper