diff --git a/packages/components/config/src/defaultConfig.ts b/packages/components/config/src/defaultConfig.ts index 19e96b3da..fec7f5c2c 100644 --- a/packages/components/config/src/defaultConfig.ts +++ b/packages/components/config/src/defaultConfig.ts @@ -55,6 +55,8 @@ import type { TooltipConfig, TreeConfig, TreeSelectConfig, + UploadConfig, + UploadListConfig, } from './types' import { numFormatter } from './numFormatter' @@ -200,6 +202,25 @@ const treeSelect: TreeSelectConfig = { nodeKey: 'key', } +const upload: UploadConfig = { + multiple: false, + dragable: false, + directory: false, + name: 'file', + withCredentials: false, + requestMethod: 'post', + strokeColor: '#20CC94', +} + +const uploadList: UploadListConfig = { + listType: 'text', + icon: { + file: true, + remove: true, + retry: true, + }, +} + // --------------------- Data Display --------------------- const avatar: AvatarConfig = { gap: 4, @@ -403,6 +424,8 @@ export const defaultConfig: GlobalConfig = { timePicker, timeRangePicker, treeSelect, + upload, + uploadList, // Data Display avatar, badge, diff --git a/packages/components/config/src/types.ts b/packages/components/config/src/types.ts index 823b799b8..592b3c874 100644 --- a/packages/components/config/src/types.ts +++ b/packages/components/config/src/types.ts @@ -21,13 +21,14 @@ import type { MessageType } from '@idux/components/message' import type { ModalType } from '@idux/components/modal' import type { NotificationPlacement, NotificationType } from '@idux/components/notification' import type { PaginationItemRenderFn, PaginationSize, PaginationTotalRenderFn } from '@idux/components/pagination' -import type { ProgressFormat, ProgressSize } from '@idux/components/progress' +import type { ProgressFormat, ProgressGradient, ProgressSize } from '@idux/components/progress' import type { ResultStatus } from '@idux/components/result' import type { SpinSize, SpinTipAlignType } from '@idux/components/spin' import type { StepperSize } from '@idux/components/stepper' import type { TableColumnAlign, TableColumnSortOrder, TablePaginationPosition, TableSize } from '@idux/components/table' import type { TagShape } from '@idux/components/tag' import type { TextareaAutoRows, TextareaResize } from '@idux/components/textarea' +import type { UploadIconType, UploadListType, UploadRequestMethod, UploadRequestOption } from '@idux/components/upload' import type { VNode } from 'vue' // Common @@ -190,6 +191,22 @@ export interface TimePickerConfig { export type TimeRangePickerConfig = TimePickerConfig +export interface UploadConfig { + multiple: boolean + dragable: boolean + directory: boolean + name: string + withCredentials: boolean + requestMethod: UploadRequestMethod + strokeColor: ProgressGradient | string + customRequest?: (option: UploadRequestOption) => { abort: () => void } +} + +export interface UploadListConfig { + listType: UploadListType + icon: Partial> +} + // Data Display export interface AvatarConfig { gap: number @@ -427,6 +444,8 @@ export interface GlobalConfig { treeSelect: TreeSelectConfig timePicker: TimePickerConfig timeRangePicker: TimeRangePickerConfig + upload: UploadConfig + uploadList: UploadListConfig // Data Display avatar: AvatarConfig badge: BadgeConfig diff --git a/packages/components/default.less b/packages/components/default.less index aa6f6a53d..03a5daeab 100644 --- a/packages/components/default.less +++ b/packages/components/default.less @@ -61,3 +61,4 @@ @import './tree/style/themes/default.less'; @import './tree-select/style/themes/default.less'; @import './typography/style/themes/default.less'; +@import './upload/style/themes/default.less'; diff --git a/packages/components/i18n/src/locales/en-US.ts b/packages/components/i18n/src/locales/en-US.ts index 0dd5cb7d1..6d4b5548c 100644 --- a/packages/components/i18n/src/locales/en-US.ts +++ b/packages/components/i18n/src/locales/en-US.ts @@ -89,6 +89,15 @@ const enUS: Locale = { separator: 'To', placeholder: ['Start time', 'End time'], }, + upload: { + uploading: 'Uploading...', + error: 'Upload error', + cancel: 'Cancel Upload', + preview: 'Preview file', + remove: 'Remove file', + retry: 'Reupload', + download: 'Download file', + }, } export default enUS diff --git a/packages/components/i18n/src/locales/zh-CN.ts b/packages/components/i18n/src/locales/zh-CN.ts index e6f8af634..38eadd379 100755 --- a/packages/components/i18n/src/locales/zh-CN.ts +++ b/packages/components/i18n/src/locales/zh-CN.ts @@ -89,6 +89,15 @@ const zhCN: Locale = { separator: '至', placeholder: ['起始时间', '结束时间'], }, + upload: { + uploading: '正在上传...', + error: '上传失败', + cancel: '取消上传', + preview: '预览文件', + remove: '删除文件', + retry: '重新上传', + download: '下载文件', + }, } export default zhCN diff --git a/packages/components/i18n/src/types.ts b/packages/components/i18n/src/types.ts index 10ea3dfef..8864d7081 100644 --- a/packages/components/i18n/src/types.ts +++ b/packages/components/i18n/src/types.ts @@ -94,6 +94,16 @@ export interface TimeRangePickerLocale { placeholder: [string, string] } +export interface UploadLocale { + uploading: string + error: string + cancel: string + preview: string + remove: string + retry: string + download: string +} + export interface Locale { type: LocaleType date: DateLocale @@ -106,6 +116,7 @@ export interface Locale { table: TableLocale timePicker: TimePickerLocale timeRangePicker: TimeRangePickerLocale + upload: UploadLocale } export type LocaleKey = keyof Locale diff --git a/packages/components/index.ts b/packages/components/index.ts index cc92bbf5b..a75dc79dc 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -62,6 +62,7 @@ import { IxTooltip } from '@idux/components/tooltip' import { IxTree } from '@idux/components/tree' import { IxTreeSelect } from '@idux/components/tree-select' import { IxTypography } from '@idux/components/typography' +import { IxUpload, IxUploadList } from '@idux/components/upload' import { version } from '@idux/components/version' const components = [ @@ -148,6 +149,8 @@ const components = [ IxTooltip, IxTree, IxTreeSelect, + IxUpload, + IxUploadList, ] const directives: Record = { diff --git a/packages/components/progress/index.ts b/packages/components/progress/index.ts index 9000124cd..9acc10a02 100644 --- a/packages/components/progress/index.ts +++ b/packages/components/progress/index.ts @@ -22,5 +22,6 @@ export type { ProgressType, ProgressGapPositionType, ProgressStatus, + ProgressGradient, ProgressStrokeLinecap, } from './src/types' diff --git a/packages/components/style/variable/prefix.less b/packages/components/style/variable/prefix.less index d37fa98e8..96d9d625e 100644 --- a/packages/components/style/variable/prefix.less +++ b/packages/components/style/variable/prefix.less @@ -65,6 +65,7 @@ @time-range-picker-prefix: ~'@{idux-prefix}-time-range-picker'; @tree-select-prefix: ~'@{idux-prefix}-tree-select'; @tree-select-option-prefix: ~'@{idux-prefix}-tree-select-option'; +@upload-prefix: ~'@{idux-prefix}-upload'; // Feedback @alert-prefix: ~'@{idux-prefix}-alert'; diff --git a/packages/components/upload/__tests__/__snapshots__/list.spec.ts.snap b/packages/components/upload/__tests__/__snapshots__/list.spec.ts.snap new file mode 100644 index 000000000..3ea1e16e7 --- /dev/null +++ b/packages/components/upload/__tests__/__snapshots__/list.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Upload list render render work 1`] = `""`; diff --git a/packages/components/upload/__tests__/__snapshots__/upload.spec.ts.snap b/packages/components/upload/__tests__/__snapshots__/upload.spec.ts.snap new file mode 100644 index 000000000..17bd88eae --- /dev/null +++ b/packages/components/upload/__tests__/__snapshots__/upload.spec.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Upload render render work 1`] = ` +"
+
+ +
+ +
+ +
+
" +`; + +exports[`Upload render work 1`] = ` +"
+
+ +
+ +
+ +
+
" +`; diff --git a/packages/components/upload/__tests__/list.spec.ts b/packages/components/upload/__tests__/list.spec.ts new file mode 100644 index 000000000..b46859fb1 --- /dev/null +++ b/packages/components/upload/__tests__/list.spec.ts @@ -0,0 +1,240 @@ +import type { UploadListProps } from '../src/types' +import type { MountingOptions } from '@vue/test-utils' + +import { flushPromises, mount } from '@vue/test-utils' +import { h } from 'vue' + +import { renderWork } from '@tests' + +import { IxIcon } from '@idux/components/icon' + +import UploadFilesListCpm from '../src/List' +import { uploadToken } from '../src/token' + +const uploadListMount = (options?: MountingOptions>) => { + return mount(UploadFilesListCpm, (options ?? {}) as unknown as MountingOptions) +} + +describe('Upload list render', () => { + renderWork(UploadFilesListCpm) + + test('type work', async () => { + const wrapper = uploadListMount({ + provide: { + [uploadToken as symbol]: { + props: { + files: [ + { + uid: 'test1', + name: 'idux.svg', + thumbUrl: '/icons/logo.svg', + }, + ], + }, + }, + }, + } as MountingOptions>) + await flushPromises() + + expect(wrapper.classes()).toContain('ix-upload-list-text') + + await wrapper.setProps({ type: 'image' }) + + expect(wrapper.classes()).toContain('ix-upload-list-image') + + await wrapper.setProps({ type: 'imageCard' }) + + expect(wrapper.classes()).toContain('ix-upload-list-imageCard') + }) + + test('icon work', async () => { + const wrapper = uploadListMount({ + provide: { + [uploadToken as symbol]: { + props: { + files: [ + { + uid: 'test1', + name: 'idux.svg', + errorTip: 'error', + status: 'error', + }, + ], + }, + }, + }, + } as MountingOptions>) + await flushPromises() + + expect(wrapper.find('.ix-icon-paper-clip').exists()).toBeTruthy() + expect(wrapper.find('.ix-icon-delete').exists()).toBeTruthy() + expect(wrapper.find('.ix-icon-edit').exists()).toBeTruthy() + expect(wrapper.find('.ix-icon-exclamation-circle').exists()).toBeTruthy() + expect(wrapper.find('.ix-icon-download').exists()).toBeFalsy() + + const wrapperFileSuccess = uploadListMount({ + provide: { + [uploadToken as symbol]: { + props: { + files: [ + { + uid: 'test1', + name: 'idux.svg', + status: 'success', + }, + ], + }, + }, + }, + props: { + icon: { + download: true, + remove: h(IxIcon, { name: 'close' }), + file: 'left', + }, + }, + } as MountingOptions>) + await flushPromises() + + expect(wrapperFileSuccess.find('.ix-icon-download').exists()).toBeTruthy() + expect(wrapperFileSuccess.find('.ix-icon-left').exists()).toBeTruthy() + expect(wrapperFileSuccess.find('.ix-icon-close').exists()).toBeTruthy() + }) + + test('onDownload work', async () => { + const onDownload = jest.fn() + const defaultFiles = [ + { + uid: 'test1', + name: 'idux.svg', + status: 'success', + }, + ] + + const wrapper = uploadListMount({ + provide: { + [uploadToken as symbol]: { + props: { + files: defaultFiles, + }, + }, + }, + props: { + icon: { + download: true, + }, + onDownload, + }, + } as MountingOptions>) + + await wrapper.find('.ix-upload-icon-download').trigger('click') + + expect(onDownload).toBeCalledWith(expect.objectContaining(defaultFiles[0])) + }) + + test('onPreview work', async () => { + const onPreview = jest.fn() + const defaultFiles = [ + { + uid: 'test1', + name: 'idux.svg', + status: 'success', + }, + ] + + const wrapper = uploadListMount({ + provide: { + [uploadToken as symbol]: { + props: { + files: defaultFiles, + }, + }, + }, + props: { + icon: { + preview: true, + }, + onPreview, + }, + } as MountingOptions>) + + await wrapper.find('.ix-upload-name').trigger('click') + + expect(onPreview).toBeCalledWith(expect.objectContaining(defaultFiles[0])) + + await wrapper.setProps({ type: 'imageCard' }) + await wrapper.find('.ix-icon-zoom-in').trigger('click') + + expect(onPreview).toBeCalledWith(expect.objectContaining(defaultFiles[0])) + }) + + test('onRetry work', async () => { + const onRetry = jest.fn() + const upload = jest.fn() + const defaultFiles = [ + { + uid: 'test1', + name: 'idux.svg', + status: 'error', + }, + ] + + const wrapper = uploadListMount({ + provide: { + [uploadToken as symbol]: { + props: { + files: defaultFiles, + }, + upload, + }, + }, + props: { + icon: { + retry: true, + }, + onRetry, + }, + } as MountingOptions>) + await wrapper.find('.ix-upload-icon-retry').trigger('click') + + expect(upload).toBeCalled() + expect(onRetry).toBeCalledWith(expect.objectContaining(defaultFiles[0])) + }) + + test('onRemove work', async () => { + const onRemove = jest.fn(() => false) + const onUpdateFiles = jest.fn() + const defaultFiles = [ + { + uid: 'test1', + name: 'idux.svg', + status: 'success', + }, + ] + + const wrapper = uploadListMount({ + provide: { + [uploadToken as symbol]: { + props: { + files: defaultFiles, + }, + onUpdateFiles, + }, + }, + props: { + onRemove, + }, + } as MountingOptions>) + await wrapper.find('.ix-upload-icon-remove').trigger('click') + + expect(onRemove).toBeCalledWith(expect.objectContaining(defaultFiles[0])) + expect(onUpdateFiles).not.toBeCalled() + + const allowRemove = jest.fn(() => Promise.resolve(true)) + await wrapper.setProps({ onRemove: allowRemove }) + await wrapper.find('.ix-upload-icon-remove').trigger('click') + + expect(onRemove).toBeCalledWith(expect.objectContaining(defaultFiles[0])) + expect(onUpdateFiles).toBeCalled() + }) +}) diff --git a/packages/components/upload/__tests__/upload.spec.ts b/packages/components/upload/__tests__/upload.spec.ts new file mode 100644 index 000000000..73fa7bf8a --- /dev/null +++ b/packages/components/upload/__tests__/upload.spec.ts @@ -0,0 +1,438 @@ +import type { UploadFile, UploadRequestChangeOption } from '../src/types' +import type { MountingOptions } from '@vue/test-utils' +import type { Ref } from 'vue' + +import { DOMWrapper, flushPromises, mount } from '@vue/test-utils' +import { h, ref } from 'vue' + +import { renderWork } from '@tests' + +import { IxButton } from '@idux/components/button' +import { IxProgress } from '@idux/components/progress' + +import UploadFilesListCpm from '../src/List' +import UploadCpm from '../src/Upload' +import { UploadProps } from '../src/types' + +const defaultFiles: UploadFile[] = [ + { + uid: 'test1', + name: 'idux.svg', + thumbUrl: '/icons/logo.svg', + }, + { + uid: 'error', + name: 'error.png', + status: 'error', + errorTip: 'Upload failed.', + thumbUrl: '/icons/comp-properties-1.png', + }, +] + +const getTestFiles = (count = 1) => + new Array(count).fill({}).map((item, index) => new File([`${index}`], `test${index}.png`, { type: 'image/png' })) + +// mock input +const triggerInput = async (inputDom: DOMWrapper, files: File[]) => { + Object.defineProperty(inputDom.element, 'files', { + configurable: true, + writable: true, + value: files, + }) + return await inputDom.trigger('change') +} + +const uploadMount = (options?: MountingOptions>) => { + const action = '/upload' + const files: Ref = ref([]) + const { props, slots, ...rest } = options || {} + const wrapper = mount(UploadCpm, { + ...rest, + props: { action, files: files.value, 'onUpdate:files': onUpdateFiles, ...props }, + slots: { list: UploadFilesListCpm, default: h('a', { id: 'upload-test-btn' }), ...slots }, + } as unknown as MountingOptions) + + function onUpdateFiles(val: UploadFile[]) { + wrapper.setProps({ files: val }) + } + + return wrapper +} + +describe('Upload render', () => { + renderWork(UploadCpm) + + test('slots work', async () => { + const wrapper = uploadMount({ + slots: { + default: h(IxButton, { id: 'upload-test-trigger' }), + list: h( + 'div', + { id: 'upload-test-list' }, + defaultFiles.map(file => h('a', { class: 'upload-list-file' }, file.name)), + ), + tip: h('p', { id: 'upload-test-tip' }, 'Tip test'), + }, + }) + await flushPromises() + + expect(wrapper.find('#upload-test-trigger').exists()).toBe(true) + expect(wrapper.findAll('.upload-list-file').length).toBe(2) + expect(wrapper.find('#upload-test-tip').text()).toBe('Tip test') + }) + + test('v-model:files work', async () => { + const onUpdateFiles = jest.fn() + const wrapper = uploadMount({ + props: { files: defaultFiles, 'onUpdate:files': onUpdateFiles }, + }) + await flushPromises() + + expect(wrapper.findAll('.ix-upload-file').length).toBe(2) + + await wrapper.setProps({ files: [{ uid: 'files test', name: 'files test' }] }) + + expect(wrapper.findAll('.ix-upload-file').length).toBe(1) + + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(onUpdateFiles).toBeCalled() + }) + + test('accept work', async () => { + const accept = '.png, image/jpeg' + const wrapper = uploadMount({ props: { accept } }) + await flushPromises() + + expect(wrapper.find('.ix-upload-input').element.getAttribute('accept')).toBe(accept) + + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(wrapper.findAll('.ix-upload-file').length).toBe(1) // +1 + + await triggerInput(wrapper.find('.ix-upload-input'), [new File(['test'], 'test.jpeg', { type: 'image/jpeg' })]) + + expect(wrapper.findAll('.ix-upload-file').length).toBe(2) // +1 + + await triggerInput(wrapper.find('.ix-upload-input'), [new File(['test'], 'test.svg', { type: 'image/svg+xml' })]) + + expect(wrapper.findAll('.ix-upload-file').length).toBe(2) // illegal + }) + + test('directory work', async () => { + const wrapper = uploadMount({}) + await flushPromises() + + expect(wrapper.find('.ix-upload-input').element.getAttribute('directory')).toBeFalsy() + + await wrapper.setProps({ directory: true }) + + expect(wrapper.find('.ix-upload-input').element.getAttribute('directory')).toBe('directory') + }) + + test('disabled work', async () => { + const wrapper = uploadMount({}) + await flushPromises() + + expect(wrapper.find('.ix-upload-selector-disabled').exists()).toBeFalsy() + + await wrapper.setProps({ disabled: true }) + + expect(wrapper.find('.ix-upload-selector-disabled').exists()).toBeTruthy() + }) + + test('multiple work', async () => { + const wrapper = uploadMount({}) + await flushPromises() + + expect(wrapper.find('.ix-upload-input').html()).not.toContain('multiple') + + await wrapper.setProps({ multiple: true }) + + expect(wrapper.find('.ix-upload-input').html()).toContain('multiple') + }) + + test('maxCount work', async () => { + const wrapper = uploadMount({ props: { multiple: true, maxCount: 1 } }) + await flushPromises() + + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles(2)) + + expect(wrapper.findAll('.ix-upload-file').length).toBe(1) + + await wrapper.setProps({ maxCount: 2 }) + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles(3)) + expect(wrapper.findAll('.ix-upload-file').length).toBe(2) + + await wrapper.setProps({ maxCount: 0 }) + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles(2)) + expect(wrapper.findAll('.ix-upload-file').length).toBe(4) // 2 + 2 + }) + + test('dragable work', async () => { + const wrapper = uploadMount({ props: { dragable: true } }) + await flushPromises() + + const selectorCpm = wrapper.find('.ix-upload-selector-drag') + + expect(selectorCpm.exists()).toBeTruthy() + + await selectorCpm.trigger('dragover') + + expect(selectorCpm.classes()).toContain('ix-upload-selector-dragover') + + await selectorCpm.trigger('dragleave') + + expect(selectorCpm.classes()).not.toContain('ix-upload-selector-dragover') + + await selectorCpm.trigger('drop', { dataTransfer: { files: getTestFiles() } }) + + expect(wrapper.findAll('.ix-upload-file').length).toBe(1) + }) + + test('strokeColor work', async () => { + const wrapper = uploadMount({ + props: { + files: [ + { + uid: 'test2', + name: 'idux.svg', + status: 'uploading', + percent: 50, + }, + ], + }, + }) + await flushPromises() + + expect(wrapper.findComponent(IxProgress).props()).toMatchObject(expect.objectContaining({ strokeColor: '#20CC94' })) + + await wrapper.setProps({ strokeColor: { '0%': '#108ee9', '100%': '#87d068' } }) + + expect(wrapper.findComponent(IxProgress).props()).toMatchObject( + expect.objectContaining({ + strokeColor: { '0%': '#108ee9', '100%': '#87d068' }, + }), + ) + }) +}) + +describe('Upload request', () => { + let xhrMock: Partial = {} + + beforeEach(() => { + xhrMock = { + send: jest.fn(), + open: jest.fn(), + setRequestHeader: jest.fn(), + readyState: 4, + status: 200, + withCredentials: false, + } + jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock as XMLHttpRequest) + }) + + test('action & requestMethod work', async () => { + const wrapper = uploadMount({ + props: { action: () => Promise.resolve(`/upload/image`) }, + }) + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + await flushPromises() + + expect(xhrMock.open).toBeCalledWith('post', '/upload/image', true) + + await wrapper.setProps({ action: '/upload/static', requestMethod: 'get' }) + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(xhrMock.open).toBeCalledWith('get', '/upload/static', true) + }) + + test('name work', async () => { + let requestParams: FormData | null = null + xhrMock.send = jest.fn((val: FormData) => (requestParams = val)) + + const wrapper = uploadMount({}) + await flushPromises() + + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(xhrMock.open).toBeCalled() + expect(xhrMock.send).toBeCalled() + expect(requestParams!.has('file')).toBe(true) + + await wrapper.setProps({ name: 'testName' }) + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(requestParams!.has('file')).toBe(false) + expect(requestParams!.has('testName')).toBe(true) + }) + + test('customRequest work', async () => { + const customRequest = jest.fn() + const wrapper = uploadMount({ props: { customRequest: customRequest } }) + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(xhrMock.open).not.toBeCalled() + expect(customRequest).toBeCalled() + }) + + test('withCredentials work', async () => { + const wrapper = uploadMount({}) + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(xhrMock.withCredentials).toBe(false) + + await wrapper.setProps({ withCredentials: true }) + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(xhrMock.withCredentials).toBe(true) + }) + + test('requestData work', async () => { + let resultParams: FormData | null = null + xhrMock.send = jest.fn((val: FormData) => (resultParams = val)) + const requestData = { testField: Math.random().toString(36).slice(-6) } + const wrapper = uploadMount({ props: { requestData } }) + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(resultParams!.get('testField')).toBe(requestData.testField) + }) + + test('requestHeaders work', async () => { + const resultHeaders: Record = {} + xhrMock.setRequestHeader = jest.fn((name: string, val: string) => (resultHeaders[name] = val)) + const requestHeaders = { testHeader: Math.random().toString(36).slice(-6) } + const wrapper = uploadMount({ props: { requestHeaders } }) + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), getTestFiles()) + + expect(resultHeaders!.testHeader).toBe(requestHeaders.testHeader) + }) +}) + +describe('Upload hooks', () => { + let xhrMock: Partial = {} + + beforeEach(() => { + xhrMock = { + send: jest.fn(), + open: jest.fn(), + setRequestHeader: jest.fn(), + readyState: 4, + status: 200, + withCredentials: false, + } + jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock as XMLHttpRequest) + }) + + test('onSelect work', async () => { + const onSelectNotAllow = jest.fn(() => false) + const wrapper = uploadMount({ props: { onSelect: onSelectNotAllow } }) + const fileNotAllowSelect = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileNotAllowSelect) + + expect(onSelectNotAllow).toBeCalledWith(fileNotAllowSelect) + expect(wrapper.findAll('.ix-upload-file').length).toBe(0) + + const onSelectAllow = jest.fn(() => true) + await wrapper.setProps({ onSelect: onSelectAllow }) + const fileAllowSelect = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileAllowSelect) + + expect(onSelectAllow).toBeCalledWith(fileAllowSelect) + expect(wrapper.findAll('.ix-upload-file').length).toBe(1) + + const onSelectFileTransform = jest.fn((files: File[]) => + files.map(file => Object.assign(file, { transformKey: 'test' })), + ) + await wrapper.setProps({ onSelect: onSelectFileTransform }) + const fileSelected = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileSelected) + + expect(onSelectFileTransform).toBeCalledWith( + expect.arrayContaining(fileSelected.map(() => expect.objectContaining({ transformKey: 'test' }))), + ) + expect(wrapper.findAll('.ix-upload-file').length).toBe(2) + }) + + test('onBeforeUpload work', async () => { + const onBeforeUploadNotAllow = jest.fn(() => false) + const wrapper = uploadMount({ props: { onBeforeUpload: onBeforeUploadNotAllow } }) + const fileNotAllowUpload = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileNotAllowUpload) + + expect(onBeforeUploadNotAllow).toBeCalledWith(expect.objectContaining({ raw: fileNotAllowUpload[0] })) + // Adding to the file list is allowed, but uploading requests are not allowed + expect(wrapper.findAll('.ix-upload-file').length).toBe(1) + expect(xhrMock.open).not.toBeCalled() + ;(xhrMock.open as jest.Mock).mockRestore() + + const onBeforeUploadAllow = jest.fn(() => true) + await wrapper.setProps({ onBeforeUpload: onBeforeUploadAllow }) + const fileAllowUpload = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileAllowUpload) + + expect(onBeforeUploadAllow).toBeCalledWith(expect.objectContaining({ raw: fileAllowUpload[0] })) + expect(wrapper.findAll('.ix-upload-file').length).toBe(2) + expect(xhrMock.open).toBeCalled() + ;(xhrMock.open as jest.Mock).mockRestore() + + const onBeforeUploadFileTransform = jest.fn((file: UploadFile) => file) + await wrapper.setProps({ onBeforeUpload: onBeforeUploadFileTransform }) + const fileUpload = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileUpload) + + expect(onBeforeUploadFileTransform).toBeCalledWith(expect.objectContaining({ raw: fileUpload[0] })) + expect(wrapper.findAll('.ix-upload-file').length).toBe(3) + expect(xhrMock.open).toBeCalled() + + const onBeforeUploadFilePromise = jest.fn(() => Promise.resolve(true)) + await wrapper.setProps({ onBeforeUpload: onBeforeUploadFilePromise }) + const fileUploadPromise = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileUploadPromise) + + expect(onBeforeUploadFilePromise).toBeCalledWith(expect.objectContaining({ raw: fileUploadPromise[0] })) + expect(wrapper.findAll('.ix-upload-file').length).toBe(4) + expect(xhrMock.open).toBeCalled() + }) + + test('onFileStatusChange work', async () => { + let hasStatus = false + const onFileStatusChange = jest.fn( + (file: UploadFile) => (hasStatus = ['selected', 'uploading', 'error', 'success', 'abort'].includes(file.status!)), + ) + const wrapper = uploadMount({ props: { onFileStatusChange } }) + const fileSelect = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileSelect) + + expect(onFileStatusChange).toBeCalled() + expect(hasStatus).toBe(true) + }) + + test('onRequestChange work', async () => { + let hasStatus = false + const onRequestChange = jest.fn( + (option: UploadRequestChangeOption) => + (hasStatus = ['loadstart', 'progress', 'abort', 'error', 'loadend'].includes(option.status!)), + ) + const wrapper = uploadMount({ props: { onRequestChange } }) + const fileSelect = getTestFiles() + await flushPromises() + await triggerInput(wrapper.find('.ix-upload-input'), fileSelect) + + expect(onRequestChange).toBeCalled() + expect(hasStatus).toBe(true) + }) +}) diff --git a/packages/components/upload/demo/Action.md b/packages/components/upload/demo/Action.md new file mode 100644 index 000000000..6e088389d --- /dev/null +++ b/packages/components/upload/demo/Action.md @@ -0,0 +1,14 @@ +--- +title: + zh: 上传远程地址 + en: Action +order: 5 +--- + +## zh + +可以根据文件不同设置不同的上传地址`action` + +## en + +You can set different upload addresses `action` according to different files diff --git a/packages/components/upload/demo/Action.vue b/packages/components/upload/demo/Action.vue new file mode 100644 index 000000000..b5c1b2b98 --- /dev/null +++ b/packages/components/upload/demo/Action.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/components/upload/demo/Basic.md b/packages/components/upload/demo/Basic.md new file mode 100644 index 000000000..80cba41a6 --- /dev/null +++ b/packages/components/upload/demo/Basic.md @@ -0,0 +1,14 @@ +--- +title: + zh: 基本使用 + en: Basic usage +order: 0 +--- + +## zh + +最简单的用法,点击按钮出现文件选择框 + +## en + +The simplest usage, click the button and a file selection box appears. diff --git a/packages/components/upload/demo/Basic.vue b/packages/components/upload/demo/Basic.vue new file mode 100644 index 000000000..0617ebf04 --- /dev/null +++ b/packages/components/upload/demo/Basic.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/components/upload/demo/ButtonDisplay.md b/packages/components/upload/demo/ButtonDisplay.md new file mode 100644 index 000000000..9f36b7814 --- /dev/null +++ b/packages/components/upload/demo/ButtonDisplay.md @@ -0,0 +1,14 @@ +--- +title: + zh: 展示 + en: Display +order: 1 +--- + +## zh + +按钮可自定义,disabled需要自行处理 + +## en + +Buttons can be customized, disabled need to be handled by yourself. diff --git a/packages/components/upload/demo/ButtonDisplay.vue b/packages/components/upload/demo/ButtonDisplay.vue new file mode 100644 index 000000000..019bfb916 --- /dev/null +++ b/packages/components/upload/demo/ButtonDisplay.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/components/upload/demo/Check.md b/packages/components/upload/demo/Check.md new file mode 100644 index 000000000..8018041f1 --- /dev/null +++ b/packages/components/upload/demo/Check.md @@ -0,0 +1,26 @@ +--- +title: + zh: 校验文件 + en: Check file +order: 4 +--- + +## zh + +- 使用`accept`限制允许上传的文件类型,详见 [原生input accept](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/input/file) +- 使用`onSelect`可以在选中文件时做校验,并是否限制加入到待上传列表 + - 返回`boolean`类型表示是否添加到待上传列表 + - 支持Promise +- 使用`onBeforeUpload`可以在上传前对上传列表做校验,这是校验文件的最后时机 + - 返回`boolean`类型表示是否允许发送上传请求 + - 支持Promise + +## en + +- Use `accept` to limit the file types allowed to be uploaded, see [Native input accept](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/input/file). + -Use `onSelect` to check when the file is selected, and whether to restrict it to be added to the list to be uploaded. + -Return the `boolean` type to indicate whether to add to the upload list. + -Support Promise. + -Use `onBeforeUpload` to verify the upload list before uploading. This is the last time to verify the file. + -Return `boolean` type to indicate whether upload request is allowed. + -Support Promise. diff --git a/packages/components/upload/demo/Check.vue b/packages/components/upload/demo/Check.vue new file mode 100644 index 000000000..0bc6f34a5 --- /dev/null +++ b/packages/components/upload/demo/Check.vue @@ -0,0 +1,38 @@ + + + diff --git a/packages/components/upload/demo/CustomFileList.md b/packages/components/upload/demo/CustomFileList.md new file mode 100644 index 000000000..dffe3874a --- /dev/null +++ b/packages/components/upload/demo/CustomFileList.md @@ -0,0 +1,20 @@ +--- +title: + zh: 自定义文件列表 + en: Custom file list +order: 14 +--- + +## zh + +支持在`#list`插槽内自定义文件列表展示,提供以下内置工具函数 + +- 若使用内置的请求函数,可以`upload`重新提交上传请求 +- 若使用内置的请求函数,可用`abort`中断请求 + +## en + +Supports custom file list display in the `#list` slot, and provides the following built-in tool functions + +- If you use the built-in request function, you can resubmit the upload request by `upload` +- If you use the built-in request function, you can use `abort` to interrupt the request diff --git a/packages/components/upload/demo/CustomFileList.vue b/packages/components/upload/demo/CustomFileList.vue new file mode 100644 index 000000000..35c5f6a94 --- /dev/null +++ b/packages/components/upload/demo/CustomFileList.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/components/upload/demo/CustomRequest.md b/packages/components/upload/demo/CustomRequest.md new file mode 100644 index 000000000..a30bdb2b2 --- /dev/null +++ b/packages/components/upload/demo/CustomRequest.md @@ -0,0 +1,14 @@ +--- +title: + zh: 自定义请求 + en: Custom request +order: 12 +--- + +## zh + +`IxUpload`内置了基于`XMLHttpRequest`的请求方式,可以自定义请求逻辑 + +## en + +`IxUpload` has a built-in request method based on `XMLHttpRequest`, and the request logic can be customized. diff --git a/packages/components/upload/demo/CustomRequest.vue b/packages/components/upload/demo/CustomRequest.vue new file mode 100644 index 000000000..8e8ba92eb --- /dev/null +++ b/packages/components/upload/demo/CustomRequest.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/components/upload/demo/Dragable.md b/packages/components/upload/demo/Dragable.md new file mode 100644 index 000000000..85bfa105d --- /dev/null +++ b/packages/components/upload/demo/Dragable.md @@ -0,0 +1,14 @@ +--- +title: + zh: 拖拽上传 + en: Drag and drop upload +order: 7 +--- + +## zh + +拖拽面板自定义,搭配`multiple`可以拖拽一次上传多个文件 + +## en + +Drag and drop panel customization, with `multiple`, you can drag and drop multiple files at once. diff --git a/packages/components/upload/demo/Dragable.vue b/packages/components/upload/demo/Dragable.vue new file mode 100644 index 000000000..f66e0a8c4 --- /dev/null +++ b/packages/components/upload/demo/Dragable.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/packages/components/upload/demo/FilesList.md b/packages/components/upload/demo/FilesList.md new file mode 100644 index 000000000..ff3b1ad7f --- /dev/null +++ b/packages/components/upload/demo/FilesList.md @@ -0,0 +1,14 @@ +--- +title: + zh: 已上传的文件列表 + en: List of uploaded files +order: 3 +--- + +## zh + +使用`files`设置已上传的文件,格式参照`UploadFile`. + +## en + +Use `files` to set the uploaded file, refer to `UploadFile` for the format. diff --git a/packages/components/upload/demo/FilesList.vue b/packages/components/upload/demo/FilesList.vue new file mode 100644 index 000000000..aa5205cfd --- /dev/null +++ b/packages/components/upload/demo/FilesList.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/components/upload/demo/Hooks.md b/packages/components/upload/demo/Hooks.md new file mode 100644 index 000000000..9899002eb --- /dev/null +++ b/packages/components/upload/demo/Hooks.md @@ -0,0 +1,40 @@ +--- +title: + zh: 钩子 + en: Hook +order: 9 +--- + +## zh + +提供多种钩子以扩展功能。 + +- `onSelect`:在选中文件时 + - 返回`boolean`类型表示是否添加到待上传列表 + - 支持对原文件修改返回新的`File`文件 + - 支持Promise +- `onBeforeUpload`:在上传请求前 + - 返回`boolean`类型表示是否允许发送上传请求 + - 允许对原`UploadFile`类型文件进行修改提交上传 + - 支持Promise,`resolve`时开始上传,`reject`停止 +- `onFileStatusChange`:文件状态改变时 + - 可通过参数中`status`得知当前文件状态 +- `onRequestChange`:上传请求状态改变时 + - 可通过参数中`status`得知当前请求状态 + +## en + +Provide a variety of hooks to extend the functionality. +-`onSelect`: When the file is selected + +- Return the `boolean` type to indicate whether to add to the upload list +- Support to modify the original file to return to the new `File` file +- Support Promise +-`onBeforeUpload`: before upload request +- Return `boolean` type to indicate whether upload request is allowed +- Allow to modify and upload files of the original `UploadFile` type +- Support Promise +-`onFileStatusChange`: When the file status changes +- The current file status can be known through the `status` in the parameter +-`onRequestChange`: When the upload request status changes +- The current request status can be known through the `status` in the parameter diff --git a/packages/components/upload/demo/Hooks.vue b/packages/components/upload/demo/Hooks.vue new file mode 100644 index 000000000..e0d958534 --- /dev/null +++ b/packages/components/upload/demo/Hooks.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/components/upload/demo/Icon.md b/packages/components/upload/demo/Icon.md new file mode 100644 index 000000000..22e30cca3 --- /dev/null +++ b/packages/components/upload/demo/Icon.md @@ -0,0 +1,24 @@ +--- +title: + zh: 图标 + en: Icon +order: 10 +--- + +## zh + +`IxUploadList`使用`icon`配置对应的图标,每个图标的取值说明如下: + +- 配置`true`展示该icon,采用默认图标 +- 配置`false`则不展示该icon +- 配置`string`使用新的icon +- 支持配置`VNode` + +## en + +`IxUploadList` uses `icon` to configure the corresponding icons. The value of each icon is explained as follows: + +- Configure `true` to display the icon and use the default icon +- Configure `false` to not show the icon +- Configure `string` to use the new icon +- Support configuration of `VNode` diff --git a/packages/components/upload/demo/Icon.vue b/packages/components/upload/demo/Icon.vue new file mode 100644 index 000000000..3ca6803cc --- /dev/null +++ b/packages/components/upload/demo/Icon.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/components/upload/demo/ListDisplay.md b/packages/components/upload/demo/ListDisplay.md new file mode 100644 index 000000000..a8410bf86 --- /dev/null +++ b/packages/components/upload/demo/ListDisplay.md @@ -0,0 +1,14 @@ +--- +title: + zh: 文件列表展示 + en: File list display +order: 3 +--- + +## zh + +文件列表展示形式,同时也支持自定义 + +## en + +File list display form, but also supports customization. diff --git a/packages/components/upload/demo/ListDisplay.vue b/packages/components/upload/demo/ListDisplay.vue new file mode 100644 index 000000000..77cb9d96d --- /dev/null +++ b/packages/components/upload/demo/ListDisplay.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/components/upload/demo/Manual.md b/packages/components/upload/demo/Manual.md new file mode 100644 index 000000000..07e0d5732 --- /dev/null +++ b/packages/components/upload/demo/Manual.md @@ -0,0 +1,14 @@ +--- +title: + zh: 手动上传 + en: Manual upload +order: 8 +--- + +## zh + +`beforeUpload` 返回 false 后,手动上传文件。 + +## en + +After `beforeUpload` returns false, upload the file manually. diff --git a/packages/components/upload/demo/Manual.vue b/packages/components/upload/demo/Manual.vue new file mode 100644 index 000000000..4f4c6e2cc --- /dev/null +++ b/packages/components/upload/demo/Manual.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/components/upload/demo/MaxCount.md b/packages/components/upload/demo/MaxCount.md new file mode 100644 index 000000000..41a59f26e --- /dev/null +++ b/packages/components/upload/demo/MaxCount.md @@ -0,0 +1,14 @@ +--- +title: + zh: 限制数量 + en: Limit the number +order: 6 +--- + +## zh + +使用`maxCount`限制上传数量,当为`1`时,使用用最新的上传文件替代当前 + +## en + +Use `maxCount` to limit the number of uploads, when it is `1`, use the latest uploaded file to replace the current. diff --git a/packages/components/upload/demo/MaxCount.vue b/packages/components/upload/demo/MaxCount.vue new file mode 100644 index 000000000..4f242705a --- /dev/null +++ b/packages/components/upload/demo/MaxCount.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/components/upload/demo/Operation.md b/packages/components/upload/demo/Operation.md new file mode 100644 index 000000000..134980ca3 --- /dev/null +++ b/packages/components/upload/demo/Operation.md @@ -0,0 +1,14 @@ +--- +title: + zh: 文件操作 + en: File operation +order: 13 +--- + +## zh + +支持对文件进行操作 + +## en + +Support operations on files. diff --git a/packages/components/upload/demo/Operation.vue b/packages/components/upload/demo/Operation.vue new file mode 100644 index 000000000..f8efbcb52 --- /dev/null +++ b/packages/components/upload/demo/Operation.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/components/upload/demo/StrokeColor.md b/packages/components/upload/demo/StrokeColor.md new file mode 100644 index 000000000..fb491c36b --- /dev/null +++ b/packages/components/upload/demo/StrokeColor.md @@ -0,0 +1,14 @@ +--- +title: + zh: 进度条样式 + en: Progress bar style +order: 11 +--- + +## zh + +使用`strokeColor`配置进度条色彩,配置同`IxProgress.strokeColor` + +## en + +Use `strokeColor` to configure the color of the progress bar, the configuration is the same as that of `IxProgress.strokeColor`. diff --git a/packages/components/upload/demo/StrokeColor.vue b/packages/components/upload/demo/StrokeColor.vue new file mode 100644 index 000000000..ac97ec854 --- /dev/null +++ b/packages/components/upload/demo/StrokeColor.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/components/upload/docs/Index.en.md b/packages/components/upload/docs/Index.en.md new file mode 100644 index 000000000..5870453aa --- /dev/null +++ b/packages/components/upload/docs/Index.en.md @@ -0,0 +1,31 @@ +--- +category: components +type: Data Entry +title: Upload +subtitle: +order: 0 +--- + + + +## API + +### IxUpload + +#### UploadProps + +| Name | Description | Type | Default | Global Config | Remark | +| --- | --- | --- | --- | --- | --- | +| - | - | - | - | ✅ | - | + +#### UploadSlots + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | + +#### UploadMethods + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | diff --git a/packages/components/upload/docs/Index.zh.md b/packages/components/upload/docs/Index.zh.md new file mode 100644 index 000000000..a3b7e623a --- /dev/null +++ b/packages/components/upload/docs/Index.zh.md @@ -0,0 +1,95 @@ +--- +category: components +type: 数据录入 +title: Upload +subtitle: 文件上传 +order: 0 +--- + + + +## API + +### IxUpload + +#### UploadProps + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `v-model:files` | 文件列表,必选 | `UploadFile[]` | `[]` | - | - | +| `accept` | 允许上传的文件类型,详见[原生input accept](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/input/file) | `string` | - | - | - | +| `action` | 上传文件的地址,必选 | `string \| (file: UploadFile) => Promise` | - | - | - | +| `dragable` | 是否启用拖拽上传 | `boolean` | `false` | ✅ | - | +| `disabled` | 是否禁用 | `boolean` | `false` | - | 自定义的触发按钮和自定义的文件列表,disabled需单独处理 | +| `maxCount` | 限制上传文件的数量。当为 1 时,始终用最新上传的文件代替当前文件 | `number` | - | - | - | +| `multiple` | 是否支持多选文件,开启后按住 ctrl 可选择多个文件 | `boolean` | `false` | ✅ | - | +| `directory` | 支持上传文件夹([caniuse](https://caniuse.com/#feat=input-file-directory)) | `boolean` | `false` | ✅ | - | +| `strokeColor` | 进度条色彩,同`IxProgress.strokeColor` | `ProgressGradient \| string` | `#20CC94` | ✅ | - | +| `name` | 发到后台的文件参数名 | `string` | `file` | ✅ | - | +| `withCredentials` | 请求是否携带cookie | `boolean` | `false` | ✅ | - | +| `customRequest` | 覆盖内置的上传行为,自定义上传实现 | `(option: UploadRequestOption) => { abort: () => void }` | 基于XMLHttpRequest实现 | - | - | +| `requestData` | 上传附加的参数 | `Record \| ((file: UploadFile) => Record \| Promise>)` | - | - | - | +| `requestHeaders` | 设置上传请求的请求头 | `UploadRequestHeader` | - | - | - | +| `requestMethod` | 上传请求的http method | `string` | `post` | ✅ | - | +| `onSelect` | 选中文件时钩子 | `(file: File[]) => boolean \| File[] \| Promise` | `() => true` | - | - | +| `onFileStatusChange` | 上传文件改变时的状态 | `(file: UploadFile) => void` | - | - | - | +| `onBeforeUpload` | 文件上传前的钩子,根据返回结果是否上传
返回`false`阻止上传
返回`Promise`对象`reject`时停止上传
返回`Promise`对象`resolve`时开始上传 | `(file: UploadFile) => boolean \| UploadFile \| Promise` | `() => true` | - | - | +| `onRequestChange` | 请求状态改变的钩子 | `(option: UploadRequestChangeParam) => void` | - | - | - | + +### IxUploadFiles 上传文件列表展示 + +#### UploadFilesProps + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `type` | 展示的形式 | `text \| image \| imageCard` | `text` | ✅ | - | +| `icon` | 展示的icon | `Record | `{file: true, remove: true, retry: true}` | ✅ | - | +| `onDownload` | 点击下载文件时的回调 | `(file: UploadFile) => void` | - | - | - | +| `onPreview` | 点击文件链接或预览图标时的回调 | `(file: UploadFile) => boolean \| Promise` | - | - | - | +| `onRemove` | 点击移除文件时的回调,返回boolean表示是否允许移除,支持Promise | `(file: UploadFile) => boolean \| Promise` | `() => true` | - | - | +| `onRetry` | 点击重新上传时的回调 | `(file: UploadFile) => void` | - | - | - | + +#### IxUploadSlots + +| 名称 | 说明 | 参数类型 | 备注 | +| ---------- | ------------------------ | ---------------------------------------- | ---- | +| `default` | 上传组件选择器的展示形式 | `slotProp` | - | +| `list` | 文件列表自定义渲染 | `{abort: (file: UploadFile) => void, upload: (file: UploadFile) => void}` | - | +| `tip` | 上传提示区 | - | - | + +```typescript +// 上传文件 +interface UploadFile { + uid: VKey // 唯一标识 + name: string // 文件名 + raw?: UploadRawFile + status?: 'selected' | 'uploading' | 'error' | 'success' | 'abort' // 当前状态 + error?: Error // 详细的报错信息,比如请求失败时 + errorTip?: string // 小i报错提示文本 + thumbUrl?: string // 缩略图链接 + percent?: number // 上传进度 + response?: Response // 服务端响应内容 +} + +// 自定义上传方法的参数 +interface UploadRequestOption { + onProgress?: (event: UploadProgressEvent) => void + onError?: (event: UploadRequestError | ProgressEvent, body?: T) => void + onSuccess?: (body: T) => void + filename: string + file: UploadRawFile | File + withCredentials?: boolean + action: string + requestHeaders?: UploadRequestHeader + requestMethod: UploadRequestMethod + requestData?: DataType +} + +// 请求状态改变钩子参数 +interface UploadRequestChangeOption { + file: UploadFile + status: 'loadstart' | 'progress' | 'abort' | 'error' | 'loadend' + res?: Response + e?: ProgressEvent +} +``` diff --git a/packages/components/upload/index.ts b/packages/components/upload/index.ts new file mode 100644 index 000000000..4908ced39 --- /dev/null +++ b/packages/components/upload/index.ts @@ -0,0 +1,33 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadComponent, UploadListComponent } from './src/types' + +import UploadList from './src/List' +import Upload from './src/Upload' + +const IxUpload = Upload as unknown as UploadComponent +const IxUploadList = UploadList as unknown as UploadListComponent + +export { IxUpload, IxUploadList } + +export type { + UploadRawFile, + UploadListType, + UploadRequestMethod, + UploadRequestStatus, + UploadFileStatus, + UploadProgressEvent, + UploadFile, + UploadRequestOption, + UploadRequestChangeOption, + UploadIconType, + UploadInstance, + UploadPublicProps as UploadProps, + UploadListInstance, + UploadListPublicProps as UploadListProps, +} from './src/types' diff --git a/packages/components/upload/src/List.tsx b/packages/components/upload/src/List.tsx new file mode 100644 index 000000000..324901923 --- /dev/null +++ b/packages/components/upload/src/List.tsx @@ -0,0 +1,48 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadListProps } from './types' +import type { UploadListConfig } from '@idux/components/config' + +import { computed, defineComponent, h, inject, watchEffect } from 'vue' + +import { useGlobalConfig } from '@idux/components/config' + +import IxUploadImageCardList from './component/ImageCardList' +import IxUploadImageList from './component/ImageList' +import IxUploadTextList from './component/TextList' +import { useSelectorVisible } from './composables/useDisplay' +import { UploadToken, uploadToken } from './token' +import { uploadListProps } from './types' + +const cpmMap = { + text: IxUploadTextList, + image: IxUploadImageList, + imageCard: IxUploadImageCardList, +} as const + +export default defineComponent({ + name: 'IxUploadList', + props: uploadListProps, + setup(props) { + const config = useGlobalConfig('uploadList') + const listType = useListType(props, config) + const { props: uploadProps, setSelectorVisible } = inject(uploadToken, { + props: {}, + setSelectorVisible: () => {}, + } as unknown as UploadToken) + const [outerSelector] = useSelectorVisible(uploadProps, listType) + + watchEffect(() => setSelectorVisible(outerSelector.value)) + + return () => h(cpmMap[listType.value], { ...props }) + }, +}) + +function useListType(props: UploadListProps, config: UploadListConfig) { + return computed(() => props.type ?? config.listType) +} diff --git a/packages/components/upload/src/Upload.tsx b/packages/components/upload/src/Upload.tsx new file mode 100644 index 000000000..857dc56a2 --- /dev/null +++ b/packages/components/upload/src/Upload.tsx @@ -0,0 +1,56 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { Ref } from 'vue' + +import { defineComponent, provide, ref } from 'vue' + +import { useControlledProp } from '@idux/cdk/utils' + +import FileSelector from './component/Selector' +import { useCmpClasses } from './composables/useDisplay' +import { useRequest } from './composables/useRequest' +import { uploadToken } from './token' +import { uploadProps } from './types' + +export default defineComponent({ + name: 'IxUpload', + props: uploadProps, + setup(props, { slots }) { + const cpmClasses = useCmpClasses() + const [showSelector, setSelectorVisible] = useShowSelector() + const [, onUpdateFiles] = useControlledProp(props, 'files', []) + const { fileUploading, abort, startUpload, upload } = useRequest(props) + provide(uploadToken, { + props, + fileUploading, + onUpdateFiles, + abort, + startUpload, + upload, + setSelectorVisible, + }) + + return () => ( +
+ {showSelector.value && {slots.default?.()}} + {slots.list?.({ abort, upload })} +
{slots.tip?.()}
+
+ ) + }, +}) + +function useShowSelector(): [Ref, (isShow: boolean) => void] { + const showSelector = ref(true) + + function setSelectorVisible(isShow: boolean) { + showSelector.value = isShow + } + + return [showSelector, setSelectorVisible] +} diff --git a/packages/components/upload/src/component/ImageCardList.tsx b/packages/components/upload/src/component/ImageCardList.tsx new file mode 100644 index 000000000..a4b56265c --- /dev/null +++ b/packages/components/upload/src/component/ImageCardList.tsx @@ -0,0 +1,155 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { StrokeColorType } from '../composables/useDisplay' +import type { FileOperation } from '../composables/useOperation' +import type { UploadToken } from '../token' +import type { UploadFile, UploadFileStatus, UploadProps } from '../types' +import type { IconsMap } from '../util/icon' +import type { Locale } from '@idux/components/i18n' +import type { ComputedRef } from 'vue' + +import { computed, defineComponent, inject, normalizeClass, onBeforeUnmount } from 'vue' + +import { getLocale } from '@idux/components/i18n' +import { IxIcon } from '@idux/components/icon' +import { IxProgress } from '@idux/components/progress' +import { IxTooltip } from '@idux/components/tooltip' + +import { + UseThumb, + useCmpClasses, + useIcon, + useListClasses, + useSelectorVisible, + useStrokeColor, + useThumb, +} from '../composables/useDisplay' +import { useOperation } from '../composables/useOperation' +import { uploadToken } from '../token' +import { uploadListProps } from '../types' +// import { getThumbNode } from '../util/file' +import { renderOprIcon } from '../util/icon' +import { showDownload, showErrorTip, showPreview, showProgress, showRetry } from '../util/visible' +import FileSelector from './Selector' + +export default defineComponent({ + name: 'IxUploadImageCardList', + props: uploadListProps, + setup(listProps) { + const { + props: uploadProps, + upload, + abort, + onUpdateFiles, + } = inject(uploadToken, { + props: { files: [] }, + upload: () => {}, + abort: () => {}, + onUpdateFiles: () => {}, + } as unknown as UploadToken) + const icons = useIcon(listProps) + const cpmClasses = useCmpClasses() + const listClasses = useListClasses(uploadProps, 'imageCard') + const files = computed(() => uploadProps.files) + const locale = getLocale('upload') + const strokeColor = useStrokeColor(uploadProps) + const [, imageCardVisible] = useSelectorVisible(uploadProps, 'imageCard') + const showSelector = useShowSelector(uploadProps, files, imageCardVisible) + const { getThumbNode, revokeAll } = useThumb() + const fileOperation = useOperation(files, listProps, uploadProps, { abort, upload, onUpdateFiles }) + const selectorNode = renderSelector(cpmClasses) + + onBeforeUnmount(revokeAll) + + return () => ( +
    + {showSelector.value && selectorNode} + {files.value.map(file => renderItem(file, icons, cpmClasses, fileOperation, strokeColor, locale, getThumbNode))} +
+ ) + }, +}) + +function renderItem( + file: UploadFile, + icons: ComputedRef, + cpmClasses: ComputedRef, + fileOperation: FileOperation, + strokeColor: ComputedRef, + locale: ComputedRef, + getThumbNode: UseThumb['getThumbNode'], +) { + const fileClasses = normalizeClass([`${cpmClasses.value}-file`, `${cpmClasses.value}-file-${file.status}`]) + const uploadStatusNode = renderUploadStatus(file, locale, cpmClasses) + const thumbNode = getThumbNode(file) + const { retryNode, downloadNode, removeNode, previewNode } = renderOprIcon( + file, + icons, + cpmClasses, + fileOperation, + locale, + ) + return ( + +
  • + {showUploadStatus(file.status) ? uploadStatusNode : thumbNode} +
    + {showPreview(file.status) && previewNode} + {showRetry(file.status) && retryNode} + {showDownload(file.status) && downloadNode} + {removeNode} +
    +
  • +
    + ) +} + +function renderUploadStatus(file: UploadFile, locale: ComputedRef, cpmClasses: ComputedRef) { + const statusTitle = { + error: locale.value.error, + uploading: locale.value.uploading, + } as Record + const curTitle = file.status && statusTitle[file.status!] + return ( +
    + {curTitle &&
    {curTitle}
    } + {showProgress(file.status, file.percent) && ( + + )} +
    + ) +} + +function renderSelector(cpmClasses: ComputedRef) { + return ( + + + + ) +} + +function showUploadStatus(status?: UploadFileStatus) { + return status && ['uploading', 'error'].includes(status) +} + +function useShowSelector( + uploadProps: UploadProps, + files: ComputedRef, + imageCardVisible: ComputedRef, +) { + return computed(() => { + const countLimit = !uploadProps.maxCount || files.value.length < uploadProps.maxCount + return countLimit && imageCardVisible.value + }) +} diff --git a/packages/components/upload/src/component/ImageList.tsx b/packages/components/upload/src/component/ImageList.tsx new file mode 100644 index 000000000..e6c7aeaef --- /dev/null +++ b/packages/components/upload/src/component/ImageList.tsx @@ -0,0 +1,109 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { StrokeColorType, UseThumb } from '../composables/useDisplay' +import type { FileOperation } from '../composables/useOperation' +import type { UploadToken } from '../token' +import type { UploadFile } from '../types' +import type { IconsMap } from '../util/icon' +import type { Locale } from '@idux/components/i18n' +import type { ComputedRef } from 'vue' + +import { computed, defineComponent, inject, normalizeClass, onBeforeUnmount } from 'vue' + +import { getLocale } from '@idux/components/i18n' +import { IxProgress } from '@idux/components/progress' +import { IxTooltip } from '@idux/components/tooltip' + +import { useCmpClasses, useIcon, useListClasses, useStrokeColor, useThumb } from '../composables/useDisplay' +import { useOperation } from '../composables/useOperation' +import { uploadToken } from '../token' +import { uploadListProps } from '../types' +// import { getThumbNode } from '../util/file' +import { renderIcon, renderOprIcon } from '../util/icon' +import { showDownload, showErrorTip, showPreview, showProgress, showRetry } from '../util/visible' + +export default defineComponent({ + name: 'IxUploadImageList', + props: uploadListProps, + setup(listProps) { + const { + props: uploadProps, + upload, + abort, + onUpdateFiles, + } = inject(uploadToken, { + props: { files: [] }, + upload: () => {}, + abort: () => {}, + onUpdateFiles: () => {}, + } as unknown as UploadToken) + const icons = useIcon(listProps) + const cpmClasses = useCmpClasses() + const listClasses = useListClasses(uploadProps, 'image') + const files = computed(() => uploadProps.files) + const locale = getLocale('upload') + const strokeColor = useStrokeColor(uploadProps) + const { getThumbNode, revokeAll } = useThumb() + const fileOperation = useOperation(files, listProps, uploadProps, { abort, upload, onUpdateFiles }) + + onBeforeUnmount(revokeAll) + + return () => + uploadProps.files.length > 0 && ( +
      + {uploadProps.files.map(file => + renderItem(file, icons, cpmClasses, fileOperation, strokeColor, locale, getThumbNode), + )} +
    + ) + }, +}) + +function renderItem( + file: UploadFile, + icons: ComputedRef, + cpmClasses: ComputedRef, + fileOperation: FileOperation, + strokeColor: ComputedRef, + locale: ComputedRef, + getThumbNode: UseThumb['getThumbNode'], +) { + const fileClasses = normalizeClass([`${cpmClasses.value}-file`, `${cpmClasses.value}-file-${file.status}`]) + const fileNameClasses = normalizeClass([`${cpmClasses.value}-name`, `${cpmClasses.value}-name-pointer`]) + const errorTipNode = renderIcon('exclamation-circle', { class: `${cpmClasses.value}-icon-error` }) + const { retryNode, downloadNode, removeNode } = renderOprIcon(file, icons, cpmClasses, fileOperation, locale) + return ( +
  • +
    + {getThumbNode(file)} + showPreview(file.status) && fileOperation.preview(file)} + title={file.name} + > + {file.name} + +
    +
    + {showErrorTip(file.status, file.errorTip) && errorTipNode} + {showRetry(file.status) && retryNode} + {showDownload(file.status) && downloadNode} + {removeNode} +
    + {showProgress(file.status, file.percent) && ( + + )} +
  • + ) +} diff --git a/packages/components/upload/src/component/Selector.tsx b/packages/components/upload/src/component/Selector.tsx new file mode 100644 index 000000000..b36916892 --- /dev/null +++ b/packages/components/upload/src/component/Selector.tsx @@ -0,0 +1,220 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadDrag } from '../composables/useDrag' +import type { UploadToken } from '../token' +import type { UploadFileStatus, UploadProps } from '../types' +import type { UploadConfig } from '@idux/components/config' +import type { ComputedRef, Ref, ShallowRef } from 'vue' + +import { computed, defineComponent, inject, nextTick, normalizeClass, ref, shallowRef, watch } from 'vue' + +import { callEmit } from '@idux/cdk/utils' +import { useGlobalConfig } from '@idux/components/config' + +import { useCmpClasses } from '../composables/useDisplay' +import { useDrag } from '../composables/useDrag' +import { uploadToken } from '../token' +import { getFileInfo, getFilesAcceptAllow, getFilesCountAllow } from '../util/file' + +export default defineComponent({ + name: 'IxUploadSelector', + setup(props, { slots }) { + const { + props: uploadProps, + onUpdateFiles, + startUpload, + } = inject(uploadToken, { + props: {}, + onUpdateFiles: () => {}, + startUpload: () => {}, + } as unknown as UploadToken) + const cpmClasses = useCmpClasses() + const config = useGlobalConfig('upload') + const dir = useDir(uploadProps, config) + const multiple = useMultiple(uploadProps, config) + const dragable = useDragable(uploadProps, config) + const accept = useAccept(uploadProps) + const maxCount = computed(() => uploadProps.maxCount ?? 0) + const { + allowDrag, + dragOver, + filesSelected: dragFilesSelected, + onDrop, + onDragOver, + onDragLeave, + } = useDrag(uploadProps) + const [filesSelected, updateFilesSelected] = useFilesSelected(dragFilesSelected, allowDrag) + const filesReady = useFilesAllowed(uploadProps, filesSelected, accept, maxCount) + const fileInputRef: Ref = ref(null) + const inputClasses = computed(() => `${cpmClasses.value}-input`) + const selectorClasses = useSelectorClasses(uploadProps, cpmClasses, dragable, dragOver) + + syncUploadHandle(uploadProps, filesReady, onUpdateFiles, startUpload) + + return () => { + return ( +
    onClick(fileInputRef, uploadProps)} + onDragover={onDragOver} + onDrop={onDrop} + onDragleave={onDragLeave} + > + e.stopPropagation()} + onChange={() => onSelect(fileInputRef, updateFilesSelected)} + /> + {slots.default?.()} +
    + ) + } + }, +}) + +function useSelectorClasses( + props: UploadProps, + cpmClasses: ComputedRef, + dragable: ComputedRef, + dragOver: Ref, +) { + return computed(() => + normalizeClass({ + [`${cpmClasses.value}-selector`]: true, + [`${cpmClasses.value}-selector-drag`]: dragable.value, + [`${cpmClasses.value}-selector-disabled`]: props.disabled, + [`${cpmClasses.value}-selector-dragover`]: dragOver.value, + }), + ) +} + +function useDir(props: UploadProps, config: UploadConfig) { + const directoryCfg = { directory: 'directory', webkitdirectory: 'webkitdirectory' } + return computed(() => (props.directory ?? config.directory ? directoryCfg : {})) +} + +function useAccept(props: UploadProps) { + return computed(() => + props.accept + ?.split(',') + .map(type => type.trim()) + .filter(type => type), + ) +} + +function useMultiple(props: UploadProps, config: UploadConfig) { + return computed(() => props.multiple ?? config.multiple) +} + +function useDragable(props: UploadProps, config: UploadConfig) { + return computed(() => props.dragable ?? config.dragable) +} + +function useFilesSelected( + dragFilesSelected: UploadDrag['filesSelected'], + allowDrag: UploadDrag['allowDrag'], +): [ShallowRef, (files: File[]) => void] { + const filesSelected: ShallowRef = shallowRef([]) + + watch(dragFilesSelected, files => { + if (allowDrag.value) { + filesSelected.value = files + } + }) + + function updateFilesSelected(files: File[]) { + filesSelected.value = files + } + + return [filesSelected, updateFilesSelected] +} + +function useFilesAllowed( + uploadProps: UploadProps, + filesSelected: ShallowRef, + accept: ComputedRef, + maxCount: ComputedRef, +) { + const filesAllowed: ShallowRef = shallowRef([]) + + watch(filesSelected, files => { + const filesCheckAccept = getFilesAcceptAllow(files, accept.value) + filesAllowed.value = getFilesCountAllow(filesCheckAccept, uploadProps.files.length, maxCount.value) + }) + + return filesAllowed +} + +// 选中文件变化就处理上传 +function syncUploadHandle( + uploadProps: UploadProps, + filesReady: ShallowRef, + onUpdateFiles: UploadToken['onUpdateFiles'], + startUpload: UploadToken['startUpload'], +) { + watch(filesReady, async files => { + if (files.length === 0) { + return + } + const filesAfterHandle = uploadProps.onSelect ? await callEmit(uploadProps.onSelect, files) : files + const filesReadyUpload = getFilesHandled(filesAfterHandle!, files) + const filesFormat = getFormatFiles(filesReadyUpload, uploadProps, 'selected') + const filesIds = filesFormat.map(file => file.uid) + if (uploadProps.maxCount === 1) { + callEmit(onUpdateFiles, filesFormat) + } else { + callEmit(onUpdateFiles, uploadProps.files.concat(filesFormat)) + } + + // 需要用props的files,已做了响应式处理,对status属性改变能够触发更新 + await nextTick(() => { + uploadProps.files + .filter(item => filesIds.includes(item.uid)) + .forEach(file => { + startUpload(file) + }) + }) + }) +} + +function onClick(fileInputRef: Ref, props: UploadProps) { + if (props.disabled || !fileInputRef.value) { + return + } + fileInputRef.value.value = '' + fileInputRef.value.click() +} + +function onSelect(fileInputRef: Ref, updateFilesSelected: (files: File[]) => void) { + const files = Array.prototype.slice.call(fileInputRef.value?.files ?? []) as File[] + updateFilesSelected(files) +} + +// 文件对象初始化 +function getFormatFiles(files: File[], props: UploadProps, status: UploadFileStatus) { + return files.map(item => { + const fileInfo = getFileInfo(item, { status }) + callEmit(props.onFileStatusChange, fileInfo) + return fileInfo + }) +} + +function getFilesHandled(handleResult: boolean | File[], allowedFiles: File[]) { + if (handleResult === true) { + return allowedFiles + } + if (handleResult === false) { + return [] + } + return handleResult +} diff --git a/packages/components/upload/src/component/TextList.tsx b/packages/components/upload/src/component/TextList.tsx new file mode 100644 index 000000000..173007315 --- /dev/null +++ b/packages/components/upload/src/component/TextList.tsx @@ -0,0 +1,102 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { StrokeColorType } from '../composables/useDisplay' +import type { FileOperation } from '../composables/useOperation' +import type { UploadToken } from '../token' +import type { UploadFile } from '../types' +import type { IconsMap } from '../util/icon' +import type { Locale } from '@idux/components/i18n' +import type { ComputedRef } from 'vue' + +import { computed, defineComponent, inject, normalizeClass } from 'vue' + +import { getLocale } from '@idux/components/i18n' +import { IxProgress } from '@idux/components/progress' +import { IxTooltip } from '@idux/components/tooltip' + +import { useCmpClasses, useIcon, useListClasses, useStrokeColor } from '../composables/useDisplay' +import { useOperation } from '../composables/useOperation' +import { uploadToken } from '../token' +import { uploadListProps } from '../types' +import { renderIcon, renderOprIcon } from '../util/icon' +import { showDownload, showErrorTip, showPreview, showProgress, showRetry } from '../util/visible' + +export default defineComponent({ + name: 'IxUploadTextList', + props: uploadListProps, + setup(listProps) { + const { + props: uploadProps, + upload, + abort, + onUpdateFiles, + } = inject(uploadToken, { + props: { files: [] }, + upload: () => {}, + abort: () => {}, + onUpdateFiles: () => {}, + } as unknown as UploadToken) + const icons = useIcon(listProps) + const cpmClasses = useCmpClasses() + const listClasses = useListClasses(uploadProps, 'text') + const files = computed(() => uploadProps.files) + const locale = getLocale('upload') + const strokeColor = useStrokeColor(uploadProps) + const fileOperation = useOperation(files, listProps, uploadProps, { abort, upload, onUpdateFiles }) + + return () => + files.value.length > 0 && ( +
      + {files.value.map(file => renderItem(file, icons, cpmClasses, fileOperation, strokeColor, locale))} +
    + ) + }, +}) + +function renderItem( + file: UploadFile, + icons: ComputedRef, + cpmClasses: ComputedRef, + fileOperation: FileOperation, + strokeColor: ComputedRef, + locale: ComputedRef, +) { + const fileClasses = normalizeClass([`${cpmClasses.value}-file`, `${cpmClasses.value}-file-${file.status}`]) + const fileNameClasses = normalizeClass([`${cpmClasses.value}-name`, `${cpmClasses.value}-name-pointer`]) + const errorTipNode = renderIcon('exclamation-circle', { class: `${cpmClasses.value}-icon-error` }) + const { retryNode, downloadNode, removeNode } = renderOprIcon(file, icons, cpmClasses, fileOperation, locale) + return ( +
  • + + {renderIcon(icons.value.file, { class: `${cpmClasses.value}-icon-file` })} + showPreview(file.status) && fileOperation.preview(file)} + title={file.name} + > + {file.name} + + + + {showErrorTip(file.status, file.errorTip) && errorTipNode} + {showRetry(file.status) && retryNode} + {showDownload(file.status) && downloadNode} + {removeNode} + + {showProgress(file.status, file.percent) && ( + + )} +
  • + ) +} diff --git a/packages/components/upload/src/composables/useDisplay.ts b/packages/components/upload/src/composables/useDisplay.ts new file mode 100644 index 000000000..021ab5a12 --- /dev/null +++ b/packages/components/upload/src/composables/useDisplay.ts @@ -0,0 +1,101 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadListProps, UploadListType, UploadProps } from '../types' +import type { IconsMap } from '../util/icon' +import type { ProgressGradient } from '@idux/components/progress' +import type { ComputedRef, ShallowRef } from 'vue' + +import { VNode, computed, h, isProxy, normalizeClass, shallowRef } from 'vue' + +import { useGlobalConfig } from '@idux/components/config' +import { isImage } from '@idux/components/upload/src/util/file' + +import { UploadFile } from '../types' +import { getIcons } from '../util/icon' + +export type StrokeColorType = ProgressGradient | string + +export function useCmpClasses(): ComputedRef { + const commonPrefix = useGlobalConfig('common') + return computed(() => `${commonPrefix.prefixCls}-upload`) +} + +export function useListClasses(props: UploadProps, type: UploadListType): ComputedRef { + const cpmClasses = useCmpClasses() + return computed(() => + normalizeClass([ + `${cpmClasses.value}-list`, + `${cpmClasses.value}-list-${type}`, + { [`${cpmClasses.value}-list-disabled`]: props.disabled }, + ]), + ) +} + +export function useIcon(props: UploadListProps): ComputedRef { + const uploadListConfig = useGlobalConfig('uploadList') + return computed(() => getIcons(props.icon ?? uploadListConfig.icon)) +} + +export function useStrokeColor(props: UploadProps): ComputedRef { + const uploadConfig = useGlobalConfig('upload') + return computed(() => props.strokeColor ?? uploadConfig.strokeColor) +} + +export function useSelectorVisible( + props: UploadProps, + listType: ComputedRef | UploadListType, +): ComputedRef[] { + // imageCard自带selector,drag统一用外部 + const outerSelector = computed( + () => + props.dragable || + (isProxy(listType) ? (listType as ComputedRef).value !== 'imageCard' : listType !== 'imageCard'), + ) + const imageCardSelector = computed(() => !outerSelector.value) + return [outerSelector, imageCardSelector] +} + +export interface UseThumb { + revokeList: ShallowRef<(() => void)[]> + getThumbNode: (file: UploadFile) => VNode | null + revokeAll: () => void +} + +export function useThumb(): UseThumb { + const revokeList: ShallowRef<(() => void)[]> = shallowRef([]) + + const getThumbNode = (file: UploadFile): VNode | null => { + if (!file.thumbUrl && file.raw && isImage(file.raw)) { + file.thumbUrl = window.URL.createObjectURL(file.raw) + // 用于释放缩略图引用 + revokeList.value.push(() => { + window.URL.revokeObjectURL(file.thumbUrl!) + Reflect.deleteProperty(file, 'thumbUrl') + }) + } + if (!file.thumbUrl) { + return null + } + return h('img', { + src: file.thumbUrl, + alt: file.name, + style: { height: '100%', width: '100%' }, + }) + } + + const revokeAll = () => { + revokeList.value.forEach(revokeFn => revokeFn()) + revokeList.value = [] + } + + return { + revokeList, + getThumbNode, + revokeAll, + } +} diff --git a/packages/components/upload/src/composables/useDrag.ts b/packages/components/upload/src/composables/useDrag.ts new file mode 100644 index 000000000..be03caac9 --- /dev/null +++ b/packages/components/upload/src/composables/useDrag.ts @@ -0,0 +1,60 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadProps } from '../types' +import type { ComputedRef, Ref, ShallowRef } from 'vue' + +import { computed, ref, shallowRef } from 'vue' + +export interface UploadDrag { + allowDrag: ComputedRef + dragOver: Ref + filesSelected: ShallowRef + onDrop: (e: DragEvent) => void + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void +} + +export function useDrag(props: UploadProps): UploadDrag { + const dragOver = ref(false) + const filesSelected: ShallowRef = shallowRef([]) + const allowDrag = computed(() => !!props.dragable && !props.disabled) + + function onDrop(e: DragEvent) { + e.preventDefault() + if (!allowDrag.value) { + return + } + dragOver.value = false + filesSelected.value = Array.prototype.slice.call(e.dataTransfer?.files ?? []) as File[] + } + + function onDragOver(e: DragEvent) { + e.preventDefault() + if (!allowDrag.value) { + return + } + dragOver.value = true + } + + function onDragLeave(e: DragEvent) { + e.preventDefault() + if (!allowDrag.value) { + return + } + dragOver.value = false + } + + return { + allowDrag, + dragOver, + filesSelected, + onDrop, + onDragOver, + onDragLeave, + } +} diff --git a/packages/components/upload/src/composables/useOperation.ts b/packages/components/upload/src/composables/useOperation.ts new file mode 100644 index 000000000..cd9557d67 --- /dev/null +++ b/packages/components/upload/src/composables/useOperation.ts @@ -0,0 +1,89 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadToken } from '../token' +import type { UploadFile, UploadListProps, UploadProps } from '../types' +import type { ComputedRef } from 'vue' + +import { callEmit } from '@idux/cdk/utils' + +import { getTargetFile, getTargetFileIndex } from '../util/file' + +export interface FileOperation { + abort: (file: UploadFile) => void + retry: (file: UploadFile) => void + download: (file: UploadFile) => void + preview: (file: UploadFile) => void + remove: (file: UploadFile) => void +} + +export function useOperation( + files: ComputedRef, + listProps: UploadListProps, + uploadProps: UploadProps, + opr: Pick, +): FileOperation { + const abort = (file: UploadFile) => { + opr.abort(file) + } + + const retry = (file: UploadFile) => { + if (uploadProps.disabled) { + return + } + opr.upload(file) + callEmit(listProps.onRetry, file) + } + + const download = (file: UploadFile) => { + if (uploadProps.disabled) { + return + } + callEmit(listProps.onDownload, file) + } + + const preview = (file: UploadFile) => { + if (uploadProps.disabled) { + return + } + callEmit(listProps.onPreview, file) + } + + const remove = async (file: UploadFile) => { + if (uploadProps.disabled) { + return + } + const curFile = getTargetFile(file, files.value) + if (!curFile) { + return + } + const allRemove = callEmit(listProps.onRemove, curFile) + if (allRemove === false) { + return + } + if (allRemove instanceof Promise) { + const result = await allRemove + if (result === false) { + return + } + } + if (curFile.status === 'uploading') { + abort(curFile) + } + const preFiles = [...files.value] + preFiles.splice(getTargetFileIndex(curFile, files.value), 1) + opr.onUpdateFiles(preFiles) + } + + return { + abort, + retry, + download, + preview, + remove, + } +} diff --git a/packages/components/upload/src/composables/useRequest.ts b/packages/components/upload/src/composables/useRequest.ts new file mode 100644 index 000000000..1c8207d8f --- /dev/null +++ b/packages/components/upload/src/composables/useRequest.ts @@ -0,0 +1,172 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadFile, UploadProgressEvent, UploadProps, UploadRequestOption } from '../types' +import type { VKey } from '@idux/cdk/utils' +import type { Ref } from 'vue' + +import { ref } from 'vue' + +import { isFunction, isUndefined } from 'lodash-es' + +import { callEmit, throwError } from '@idux/cdk/utils' +import { useGlobalConfig } from '@idux/components/config' + +import { getTargetFile, getTargetFileIndex, setFileStatus } from '../util/file' +import defaultUpload from '../util/request' + +export interface UploadRequest { + fileUploading: Ref + abort: (file: UploadFile) => void + startUpload: (file: UploadFile) => void + upload: (file: UploadFile) => void +} + +export function useRequest(props: UploadProps): UploadRequest { + const fileUploading: Ref = ref([]) + const aborts = new Map void>([]) + const config = useGlobalConfig('upload') + + function abort(file: UploadFile): void { + const curFile = getTargetFile(file, props.files) + if (!curFile) { + return + } + const curAbort = aborts.get(curFile.uid) + curAbort?.() + setFileStatus(curFile, 'abort', props.onFileStatusChange) + fileUploading.value.splice(getTargetFileIndex(curFile, fileUploading.value), 1) + aborts.delete(curFile.uid) + props.onRequestChange && + callEmit(props.onRequestChange, { + status: 'abort', + file: { ...curFile }, + }) + } + + async function startUpload(file: UploadFile): Promise { + if (isUndefined(props.onBeforeUpload)) { + await upload(file) + return + } + const before = callEmit(props.onBeforeUpload, file) + if (before instanceof Promise) { + try { + const result = await before + if (result === true) { + await upload(file) + return + } + if (typeof result === 'object' && result) { + await upload(result) + } + } catch (e) { + setFileStatus(file, 'error', props.onFileStatusChange) + } + } else if (before === true) { + await upload(file) + } else if (typeof before === 'object') { + await upload(before) + } + } + + async function upload(file: UploadFile) { + if (!file.raw) { + file.error = new Error('file error') + setFileStatus(file, 'error', props.onFileStatusChange) + } + const action = await getAction(props, file) + const requestData = await getRequestData(props, file) + const requestOption = { + file: file.raw, + filename: props.name ?? config.name, + withCredentials: props.withCredentials ?? config.withCredentials, + action: action, + requestHeaders: props.requestHeaders ?? {}, + requestMethod: props.requestMethod ?? config.requestMethod, + requestData: requestData, + onProgress: (e: UploadProgressEvent) => _onProgress(e, file), + onError: (error: Error) => _onError(error, file), + onSuccess: (res: Response) => _onSuccess(res, file), + } as UploadRequestOption + const uploadHttpRequest = props.customRequest ?? config.customRequest ?? defaultUpload + + setFileStatus(file, 'uploading', props.onFileStatusChange) + props.onRequestChange && + callEmit(props.onRequestChange, { + status: 'loadstart', + file: { ...file }, + }) + file.percent = 0 + aborts.set(file.uid, uploadHttpRequest(requestOption)?.abort ?? (() => {})) + fileUploading.value.push(file) + } + + function _onProgress(e: UploadProgressEvent, file: UploadFile): void { + const curFile = getTargetFile(file, props.files) + if (!curFile) { + return + } + curFile.percent = e.percent ?? 0 + props.onRequestChange && + callEmit(props.onRequestChange, { + status: 'progress', + file: { ...curFile }, + e, + }) + } + + function _onError(error: Error, file: UploadFile): void { + const curFile = getTargetFile(file, props.files) + if (!curFile) { + return + } + fileUploading.value.splice(getTargetFileIndex(curFile, fileUploading.value), 1) + setFileStatus(curFile, 'error', props.onFileStatusChange) + curFile.error = error + props.onRequestChange && + callEmit(props.onRequestChange, { + file: { ...curFile }, + status: 'error', + }) + } + + function _onSuccess(res: Response, file: UploadFile): void { + const curFile = getTargetFile(file, props.files) + if (!curFile) { + return + } + curFile.response = res + props.onRequestChange && + callEmit(props.onRequestChange, { + status: 'loadend', + file: { ...curFile }, + }) + setFileStatus(curFile, 'success', props.onFileStatusChange) + fileUploading.value.splice(getTargetFileIndex(curFile, fileUploading.value), 1) + } + + return { + fileUploading, + abort, + startUpload, + upload, + } +} + +async function getAction(props: UploadProps, file: UploadFile) { + if (!props.action) { + throwError('components/upload', 'action not found.') + } + const action = isFunction(props.action) ? await props.action(file) : props.action + return action +} + +async function getRequestData(props: UploadProps, file: UploadFile) { + const requestData = isFunction(props.requestData) ? await props.requestData(file) : props.requestData ?? {} + return requestData +} diff --git a/packages/components/upload/src/token.ts b/packages/components/upload/src/token.ts new file mode 100644 index 000000000..2b7af79f8 --- /dev/null +++ b/packages/components/upload/src/token.ts @@ -0,0 +1,18 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadRequest } from './composables/useRequest' +import type { UploadFile, UploadProps } from './types' +import type { InjectionKey } from 'vue' + +export type UploadToken = { + props: UploadProps + onUpdateFiles: (file: UploadFile[]) => void + setSelectorVisible: (isShow: boolean) => void +} & UploadRequest + +export const uploadToken: InjectionKey = Symbol('UploadToken') diff --git a/packages/components/upload/src/types.ts b/packages/components/upload/src/types.ts new file mode 100644 index 000000000..05a68b819 --- /dev/null +++ b/packages/components/upload/src/types.ts @@ -0,0 +1,105 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { IxInnerPropTypes, IxPublicPropTypes, VKey } from '@idux/cdk/utils' +import type { ProgressGradient } from '@idux/components/progress' +import type { DefineComponent, HTMLAttributes, VNode } from 'vue' + +import { IxPropTypes } from '@idux/cdk/utils' + +type DataType = Record +export type UploadRequestHeader = Record +export type UploadRequestMethod = 'POST' | 'PUT' | 'PATCH' | 'post' | 'put' | 'patch' +export type UploadRequestStatus = 'loadstart' | 'progress' | 'abort' | 'error' | 'loadend' +export type UploadFileStatus = 'selected' | 'uploading' | 'error' | 'success' | 'abort' +export type UploadListType = 'text' | 'image' | 'imageCard' +export type UploadIconType = 'file' | 'preview' | 'download' | 'remove' | 'retry' +export interface UploadProgressEvent extends ProgressEvent { + percent?: number +} +export interface UploadRequestError extends Error { + status?: number + method?: UploadRequestMethod + url?: string +} +export interface UploadRawFile extends File { + uid: VKey +} +export interface UploadFile { + uid: VKey + name: string + raw?: UploadRawFile + status?: UploadFileStatus + error?: Error + errorTip?: string + thumbUrl?: string + percent?: number + response?: Response +} +export interface UploadRequestOption { + onProgress?: (event: UploadProgressEvent) => void + onError?: (event: UploadRequestError | ProgressEvent, body?: T) => void + onSuccess?: (body: T) => void + filename: string + file: UploadRawFile | File + withCredentials?: boolean + action: string + requestHeaders?: UploadRequestHeader + requestMethod: UploadRequestMethod + requestData?: DataType +} +export interface UploadRequestChangeOption { + file: UploadFile + status: UploadRequestStatus + response?: Response + e?: ProgressEvent +} + +export const uploadProps = { + files: IxPropTypes.array().isRequired, + accept: IxPropTypes.string, + action: IxPropTypes.oneOfType([String, IxPropTypes.func<(file: UploadFile) => Promise>()]).isRequired, + dragable: IxPropTypes.bool, + directory: IxPropTypes.bool, + disabled: IxPropTypes.bool, + maxCount: IxPropTypes.number, + multiple: IxPropTypes.bool, + strokeColor: IxPropTypes.oneOfType([String, IxPropTypes.object()]), + name: IxPropTypes.string, + customRequest: IxPropTypes.func<(option: UploadRequestOption) => { abort: () => void }>(), + withCredentials: IxPropTypes.bool, + requestData: IxPropTypes.oneOfType DataType | Promise)>([ + Object, + IxPropTypes.func<(file: UploadFile) => DataType | Promise>(), + ]), + requestHeaders: IxPropTypes.object(), + requestMethod: IxPropTypes.oneOf(['POST', 'PUT', 'PATCH', 'post', 'put', 'patch']), + 'onUpdate:files': IxPropTypes.emit<(fileList: UploadFile[]) => void>(), + onSelect: IxPropTypes.emit<(file: File[]) => boolean | File[] | Promise>(), + onBeforeUpload: IxPropTypes.emit<(file: UploadFile) => boolean | UploadFile | Promise>(), + onFileStatusChange: IxPropTypes.emit<(file: UploadFile) => void>(), + onRequestChange: IxPropTypes.emit<(option: UploadRequestChangeOption) => void>(), +} +export type UploadProps = IxInnerPropTypes +export type UploadPublicProps = IxPublicPropTypes +export type UploadComponent = DefineComponent & UploadPublicProps> +export type UploadInstance = InstanceType> + +export const uploadListProps = { + type: IxPropTypes.oneOf(['text', 'image', 'imageCard']), + icon: IxPropTypes.object>>(), + onDownload: IxPropTypes.emit<(file: UploadFile) => void>(), + onPreview: IxPropTypes.emit<(file: UploadFile) => void>(), + onRemove: IxPropTypes.emit<(file: UploadFile) => boolean | Promise>(), + onRetry: IxPropTypes.emit<(file: UploadFile) => void>(), +} +export type UploadListProps = IxInnerPropTypes +export type UploadListPublicProps = IxPublicPropTypes +export type UploadListComponent = DefineComponent< + Omit & UploadListPublicProps +> +export type UploadListInstance = InstanceType> diff --git a/packages/components/upload/src/util/file.ts b/packages/components/upload/src/util/file.ts new file mode 100644 index 000000000..ac730eec4 --- /dev/null +++ b/packages/components/upload/src/util/file.ts @@ -0,0 +1,84 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadFile, UploadFileStatus } from '../types' + +import { callEmit, uniqueId } from '@idux/cdk/utils' + +export function getFileInfo(file: File, options: Partial = {}): UploadFile { + const uid = uniqueId() + return { + uid, + name: file.name, + raw: Object.assign(file, { uid }), + percent: 0, + ...options, + } +} + +export function getTargetFile(file: UploadFile, files: UploadFile[]): UploadFile | undefined { + const matchKey = file.uid !== undefined ? 'uid' : 'name' + return files.find(item => item[matchKey] === file[matchKey]) +} + +export function getTargetFileIndex(file: UploadFile, files: UploadFile[]): number { + const matchKey = file.uid !== undefined ? 'uid' : 'name' + return files.findIndex(item => item[matchKey] === file[matchKey]) +} + +export function isImage(file: File): boolean { + return !!file.type && file.type.startsWith('image/') +} + +export function setFileStatus( + file: UploadFile, + status: UploadFileStatus, + onFileStatusChange?: ((file: UploadFile) => void) | ((file: UploadFile) => void)[], +): void { + file.status = status + onFileStatusChange && callEmit(onFileStatusChange, file) +} + +export function getFilesAcceptAllow(filesSelected: File[], accept?: string[]): File[] { + if (!accept || accept.length === 0) { + return filesSelected + } + return filesSelected.filter(file => { + const extension = file.name.indexOf('.') > -1 ? `.${file.name.split('.').pop()}` : '' + const baseType = file.type.replace(/\/.*$/, '') + return accept.some(type => { + if (type.startsWith('.')) { + return extension === type + } + if (/\/\*$/.test(type)) { + return baseType === type.replace(/\/\*$/, '') + } + if (/^[^/]+\/[^/]+$/.test(type)) { + return file.type === type + } + return false + }) + }) +} + +export function getFilesCountAllow(filesSelected: File[], curFilesCount: number, maxCount?: number): File[] { + if (!maxCount) { + return filesSelected + } + // 当为 1 时,始终用最新上传的文件代替当前文件 + if (maxCount === 1) { + return filesSelected.slice(0, 1) + } + const remainder = maxCount - curFilesCount + if (remainder <= 0) { + return [] + } + if (remainder >= filesSelected.length) { + return filesSelected + } + return filesSelected.slice(0, remainder) +} diff --git a/packages/components/upload/src/util/icon.ts b/packages/components/upload/src/util/icon.ts new file mode 100644 index 000000000..1a01c895c --- /dev/null +++ b/packages/components/upload/src/util/icon.ts @@ -0,0 +1,99 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { FileOperation } from '../composables/useOperation' +import type { UploadFile, UploadIconType } from '../types' +import type { Locale } from '@idux/components/i18n' +import type { ComputedRef, VNode } from 'vue' + +import { h } from 'vue' + +import { isString } from 'lodash-es' + +import { IxIcon } from '@idux/components/icon' + +const iconMap = { + file: 'paper-clip', + preview: 'zoom-in', + download: 'download', + remove: 'delete', + retry: 'edit', +} as const + +type IconNodeType = string | boolean | VNode +type Opr = 'previewNode' | 'retryNode' | 'downloadNode' | 'removeNode' + +export type IconsMap = Partial>> + +export type OprIcons = Record + +export function getIconNode(icon: Exclude): VNode | null { + if (icon === false) { + return null + } + if (isString(icon)) { + return h(IxIcon, { name: icon }) + } + return icon +} + +export function getIcons(iconProp: Partial>): IconsMap { + const iconFormat = {} as IconsMap + let icon: UploadIconType + for (icon in iconProp) { + // 默认值 + if (iconProp[icon] === true) { + iconFormat[icon] = iconMap[icon] + } else { + iconFormat[icon] = iconProp[icon] as Exclude + } + } + return iconFormat +} + +export function renderIcon(icon: IconsMap[keyof IconsMap] | undefined, props?: Record): VNode | null { + if (!icon) { + return null + } + return h('span', props, [getIconNode(icon)]) +} + +export function renderOprIcon( + file: UploadFile, + icons: ComputedRef, + cpmClasses: ComputedRef, + fileOperation: FileOperation, + locale: ComputedRef, +): OprIcons { + const previewNode = renderIcon(icons.value.preview, { + class: `${cpmClasses.value}-icon-opr ${cpmClasses.value}-icon-preview`, + onClick: () => fileOperation.preview(file), + title: locale.value.preview, + }) + const retryNode = renderIcon(icons.value.retry, { + class: `${cpmClasses.value}-icon-opr ${cpmClasses.value}-icon-retry`, + onClick: () => fileOperation.retry(file), + title: locale.value.retry, + }) + const downloadNode = renderIcon(icons.value.download, { + class: `${cpmClasses.value}-icon-opr ${cpmClasses.value}-icon-download`, + onClick: () => fileOperation.download(file), + title: locale.value.download, + }) + const removeNode = renderIcon(icons.value.remove, { + class: `${cpmClasses.value}-icon-opr ${cpmClasses.value}-icon-remove`, + onClick: () => fileOperation.remove(file), + title: locale.value.remove, + }) + + return { + previewNode, + retryNode, + downloadNode, + removeNode, + } +} diff --git a/packages/components/upload/src/util/request.ts b/packages/components/upload/src/util/request.ts new file mode 100644 index 000000000..7e7dccff4 --- /dev/null +++ b/packages/components/upload/src/util/request.ts @@ -0,0 +1,112 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +/** + * @license + * clone from https://github.com/react-component/upload/blob/master/src/request.ts + */ + +import type { UploadProgressEvent, UploadRequestError, UploadRequestOption } from '../types' + +type UploadReturnType = { + abort: () => void +} + +function getError(option: UploadRequestOption, xhr: XMLHttpRequest) { + const msg = `cannot ${option.requestMethod} ${option.action} ${xhr.status}'` + const err = new Error(msg) as UploadRequestError + err.status = xhr.status + err.method = option.requestMethod + err.url = option.action + return err +} + +function getBody(xhr: XMLHttpRequest) { + const text = xhr.responseText || xhr.response + if (!text) { + return text + } + + try { + return JSON.parse(text) + } catch (e) { + return text + } +} + +export default function upload(option: UploadRequestOption): UploadReturnType { + const xhr = new XMLHttpRequest() + + if (option.onProgress && xhr.upload) { + xhr.upload.onprogress = function progress(e: UploadProgressEvent) { + if (e.total > 0) { + e.percent = (e.loaded / e.total) * 100 + } + option.onProgress!(e) + } + } + + const formData = new FormData() + + if (option.requestData) { + Object.keys(option.requestData).forEach(key => { + const value = option.requestData![key] + // support key-value array data + if (Array.isArray(value)) { + value.forEach(item => { + // { list: [ 11, 22 ] } + // formData.append('list[]', 11); + formData.append(`${key}[]`, item) + }) + return + } + + formData.append(key, value as string | Blob) + }) + } + + formData.append(option.filename, option.file, option.file.name) + + xhr.onerror = function error(e) { + option.onError?.(e) + } + + xhr.onload = function onload() { + // allow success when 2xx status + if (xhr.status < 200 || xhr.status >= 300) { + return option.onError?.(getError(option, xhr), getBody(xhr)) + } + + return option.onSuccess?.(getBody(xhr)) + } + + xhr.open(option.requestMethod, option.action, true) + + if (option.withCredentials && 'withCredentials' in xhr) { + xhr.withCredentials = true + } + + const headers = option.requestHeaders || {} + + if (headers['X-Requested-With'] !== null) { + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') + } + + Object.keys(headers).forEach(h => { + if (headers[h] !== null) { + xhr.setRequestHeader(h, headers[h]) + } + }) + + xhr.send(formData) + + return { + abort() { + xhr.abort() + }, + } +} diff --git a/packages/components/upload/src/util/visible.ts b/packages/components/upload/src/util/visible.ts new file mode 100644 index 000000000..63da75149 --- /dev/null +++ b/packages/components/upload/src/util/visible.ts @@ -0,0 +1,30 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { UploadFileStatus } from '../types' + +import { isNumber } from 'lodash-es' + +export function showProgress(status?: UploadFileStatus, percent?: number): boolean { + return status === 'uploading' && isNumber(percent) +} + +export function showErrorTip(status?: UploadFileStatus, errorTip?: string): boolean { + return status === 'error' && !!errorTip +} + +export function showRetry(status?: UploadFileStatus): boolean { + return status === 'error' +} + +export function showDownload(status?: UploadFileStatus): boolean { + return status === 'success' +} + +export function showPreview(status?: UploadFileStatus): boolean { + return status === 'success' +} diff --git a/packages/components/upload/style/index.less b/packages/components/upload/style/index.less new file mode 100644 index 000000000..a54cecbba --- /dev/null +++ b/packages/components/upload/style/index.less @@ -0,0 +1,35 @@ +@import "./list.less"; +@import "./mixin.less"; + +.@{upload-prefix} { + + &-selector { + display: inline-block; + cursor: pointer; + + &-drag { + border-radius: @upload-border-radius; + border: @upload-selector-drag-border; + .base-transition(); + + &:hover { + border: @upload-selector-dragover-border; + } + } + + &-dragover { + border: @upload-selector-dragover-border; + } + + &-disabled { + .upload-disabled-cursor(); + .upload-disabled-bg-color(); + .upload-disabled-color(); + .upload-disabled-border-color(); + } + } + + &-input { + display: none; + } +} diff --git a/packages/components/upload/style/index.ts b/packages/components/upload/style/index.ts new file mode 100644 index 000000000..9b1835dc1 --- /dev/null +++ b/packages/components/upload/style/index.ts @@ -0,0 +1,4 @@ +import '../../style/index.less' +import './index.less' + +// style dependencies diff --git a/packages/components/upload/style/list.less b/packages/components/upload/style/list.less new file mode 100644 index 000000000..f30e798af --- /dev/null +++ b/packages/components/upload/style/list.less @@ -0,0 +1,255 @@ +@import '../../style/mixins/ellipsis.less'; + +.list-card() { + height: @upload-list-image-card-height; + width: @upload-list-image-card-width; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + margin: @upload-list-image-card-margin; +} + +.@{upload-prefix}-list { + margin: @upload-list-margin; + + .@{upload-prefix}-icon { + + &-error { + color: @color-error; + } + } + + &-text, &-image { + .@{upload-prefix}-file { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + position: relative; + + .@{upload-prefix}-progress { + position: absolute; + bottom: -1px; + left: 0; + } + + .@{upload-prefix}-name { + + &-pointer { + cursor: pointer; + } + } + + &:hover { + background-color: @color-graphite-l50; + } + + &-error { + .@{upload-prefix}-name { + color: @color-error; + } + } + + &-success { + .@{upload-prefix}-name { + color: @color-blue-base; + } + } + + .@{upload-prefix}-icon-wrap { + max-width: @upload-icon-wrap-max-width; + + .@{upload-prefix}-icon-opr { + cursor: pointer; + margin: @upload-icon-margin; + color: @color-graphite-base; + + &:first-child { + margin: 0; + } + + &:hover { + color: @color-graphite-d10; + } + } + } + } + + &.@{upload-prefix}-list-disabled { + + .@{upload-prefix}-name, .@{upload-prefix}-icon-opr { + .upload-disabled-color(); + .upload-disabled-cursor(); + } + } + } + + &-text { + border: @upload-list-text-border; + border-radius: @upload-border-radius; + + .@{upload-prefix}-file { + border-bottom: @upload-file-border-bottom; + + .@{upload-prefix}-text-info { + flex: 1; + display: inline-flex; + max-width: @upload-list-name-max-width; + + .@{upload-prefix}-name { + display: inline-block; + flex: 1; + .ellipsis(); + } + } + + &:last-child { + border: 0; + } + } + + .@{upload-prefix}-icon-file { + margin: @upload-icon-file-margin; + } + } + + &-image { + .@{upload-prefix}-file { + border: @upload-file-border-bottom; + border-radius: @upload-border-radius; + margin: @upload-list-image-margin; + + &:first-child { + margin: 0; + } + } + + .@{upload-prefix}-thumb-info { + display: inline-flex; + align-items: center; + flex: 1; + max-width: @upload-list-name-max-width; + + .@{upload-prefix}-name { + display: inline-block; + flex: 1; + .ellipsis(); + } + + .@{upload-prefix}-thumb { + display: inline-block; + height: @upload-list-image-thumb-height; + width: @upload-list-image-thumb-width; + margin: @upload-list-image-thumb-margin; + } + } + } + + &-imageCard { + display: flex; + flex-wrap: wrap; + + .@{upload-prefix}-selector { + .list-card(); + + font-size: @upload-list-image-card-selector-font-size; + color: @color-graphite-l20; + border: 1px dashed @color-graphite-l30; + background-color: @color-graphite-l50; + + &:hover { + color: @upload-list-image-card-selector-color-hover; + } + + &-uploading { + border-style: solid; + } + } + + .@{upload-prefix}-file { + .list-card(); + + border-radius: @upload-border-radius; + border: 1px solid @color-graphite-l30; + background-color: @color-graphite-l50; + + .@{upload-prefix}-status { + color: @color-graphite-base; + min-width: @upload-list-image-card-status-min-width; + + .@{idux-prefix}-progress { + display: flex; + margin-top: @upload-list-image-card-status-progress-margin; + } + } + + &-error { + .@{upload-prefix}-status { + color: @color-error; + } + } + + .@{upload-prefix}-icon { + .base-transition(); + + display: flex; + justify-content: space-evenly; + width: @upload-list-image-card-icon-wrap-width; + position: absolute; + z-index: @zindex-l1-2; + opacity: 0; + color: @color-white; + + &-opr { + cursor: pointer; + } + } + + &::after { + .base-transition(); + + content: ''; + display: inline-block; + height: 100%; + width: 100%; + position: absolute; + z-index: @zindex-l1-1; + background-color: @upload-list-image-card-bg-color; + left: 0; + top: 0; + opacity: 0; + border-radius: @upload-border-radius; + } + + &:hover { + + &::after { + opacity: 0.6; + } + + .@{upload-prefix}-icon { + opacity: 1; + } + } + } + + &.@{upload-prefix}-list-disabled { + + .@{upload-prefix}-selector { + .upload-disabled-color(); + .upload-disabled-bg-color(); + .upload-disabled-cursor(); + } + + .@{upload-prefix}-icon-opr { + .upload-disabled-cursor(); + } + + } + } +} + +.@{upload-prefix}-tip { + margin: @upload-tip-margin; +} diff --git a/packages/components/upload/style/mixin.less b/packages/components/upload/style/mixin.less new file mode 100644 index 000000000..e706070df --- /dev/null +++ b/packages/components/upload/style/mixin.less @@ -0,0 +1,41 @@ +.upload-disabled-cursor() { + cursor: not-allowed; + + > * { + pointer-events: none; + } +} + +.upload-disabled-color() { + + &, + &:hover, + &:focus, + &:active { + color: @disabled-color; + } +} + +.upload-disabled-bg-color() { + + &, + &:hover, + &:focus, + &:active { + background-color: @disabled-bg-color; + } +} + +.upload-disabled-border-color() { + + &, + &:hover, + &:focus, + &:active { + border-color: @color-graphite-l20; + } +} + +.base-transition() { + transition: all .3s; +} diff --git a/packages/components/upload/style/themes/default.less b/packages/components/upload/style/themes/default.less new file mode 100644 index 000000000..9a6507b70 --- /dev/null +++ b/packages/components/upload/style/themes/default.less @@ -0,0 +1,34 @@ +@import '../../../style/themes/default.less'; +@import '../index.less'; + +@upload-list-margin: 8px 0 0; +@upload-border-radius: 2px; + +@upload-selector-drag-border: 1px dashed @color-graphite-l20; +@upload-selector-dragover-border: 1px dashed @color-blue; + +@upload-list-text-border: 1px solid @color-graphite-l30; +@upload-list-name-max-width: calc(100% - 74px); + +@upload-list-image-thumb-width: 48px; +@upload-list-image-thumb-height: 48px; +@upload-list-image-thumb-margin: 0 8px 0 0; +@upload-list-image-margin: 8px 0 0; + +@upload-list-image-card-height: 96px; +@upload-list-image-card-width: 96px; +@upload-list-image-card-margin: 0 8px 8px 0; +@upload-list-image-card-bg-color: @color-black; +@upload-list-image-card-icon-wrap-width: 100%; +@upload-list-image-card-selector-font-size: 24px; +@upload-list-image-card-selector-color-hover: #458FFF; +@upload-list-image-card-status-min-width: 60px; +@upload-list-image-card-status-progress-margin: 8px 0 0; + +@upload-file-border-bottom: 1px solid @color-graphite-l30; + +@upload-icon-wrap-max-width: 120px; +@upload-icon-margin: 0 0 0 16px; +@upload-icon-file-margin: 0 8px 0 0; + +@upload-tip-margin: 8px 0 0; diff --git a/packages/components/upload/style/themes/default.ts b/packages/components/upload/style/themes/default.ts new file mode 100644 index 000000000..8aaddc579 --- /dev/null +++ b/packages/components/upload/style/themes/default.ts @@ -0,0 +1,5 @@ +// style dependencies +import '@idux/components/style/core/default' +import '@idux/components/icon/style/themes/default' + +import './default.less'