Skip to content

Commit 0e174e2

Browse files
committed
feat!: 新增 FaFileUpload 组件,移除 FileUpload 组件
1 parent b153bda commit 0e174e2

File tree

3 files changed

+231
-144
lines changed

3 files changed

+231
-144
lines changed

‎src/components/FileUpload/index.vue‎

Lines changed: 0 additions & 143 deletions
This file was deleted.

‎src/types/components.d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ declare module 'vue' {
2121
FaDivider: typeof import('./../ui/components/FaDivider/index.vue')['default']
2222
FaDrawer: typeof import('./../ui/components/FaDrawer/index.vue')['default']
2323
FaDropdown: typeof import('./../ui/components/FaDropdown/index.vue')['default']
24+
FaFileUpload: typeof import('./../ui/components/FaFileUpload/index.vue')['default']
2425
FaFixedActionBar: typeof import('./../ui/components/FaFixedActionBar/index.vue')['default']
2526
FaHoverCard: typeof import('./../ui/components/FaHoverCard/index.vue')['default']
2627
FaIcon: typeof import('./../ui/components/FaIcon/index.vue')['default']
@@ -47,7 +48,6 @@ declare module 'vue' {
4748
FaTabs: typeof import('./../ui/components/FaTabs/index.vue')['default']
4849
FaToast: typeof import('./../ui/components/FaToast/index.vue')['default']
4950
FaTooltip: typeof import('./../ui/components/FaTooltip/index.vue')['default']
50-
FileUpload: typeof import('./../components/FileUpload/index.vue')['default']
5151
RouterLink: typeof import('vue-router')['RouterLink']
5252
RouterView: typeof import('vue-router')['RouterView']
5353
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<script setup lang="ts">
2+
import axios from 'axios'
3+
import { filesize } from 'filesize'
4+
import { toast } from 'vue-sonner'
5+
6+
defineOptions({
7+
name: 'FaFileUpload',
8+
})
9+
10+
const props = withDefaults(defineProps<{
11+
action: string
12+
method?: string
13+
headers?: Headers | Record<string, any>
14+
data?: Record<string, any>
15+
name?: string
16+
afterUpload?: (response: any) => string | Promise<string>
17+
multiple?: boolean
18+
ext?: string[]
19+
size?: number
20+
max?: number
21+
hideTips?: boolean
22+
disabled?: boolean
23+
}>(), {
24+
method: 'post',
25+
headers: () => ({}),
26+
data: () => ({}),
27+
name: 'file',
28+
multiple: false,
29+
ext: () => [],
30+
size: 10 * 1024 * 1024,
31+
max: 0,
32+
hideTips: false,
33+
disabled: false,
34+
})
35+
36+
const emits = defineEmits<{
37+
onSuccess: [response: any, file: File]
38+
onClick: [fileItem: FileItem, index: number]
39+
}>()
40+
41+
const fileList = defineModel<FileItem[]>('modelValue', { required: true })
42+
43+
export interface FileItem {
44+
name: string
45+
size: number
46+
url?: string
47+
status?: 'uploading' | 'success' | 'error'
48+
progress?: number
49+
file?: File
50+
}
51+
52+
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
53+
const isDragging = ref(false)
54+
55+
function handleDragOver(e: DragEvent) {
56+
e.preventDefault()
57+
if (props.disabled) {
58+
return
59+
}
60+
isDragging.value = true
61+
}
62+
63+
function handleDragLeave(e: DragEvent) {
64+
e.preventDefault()
65+
isDragging.value = false
66+
}
67+
68+
function handleDrop(e: DragEvent) {
69+
e.preventDefault()
70+
if (props.disabled) {
71+
return
72+
}
73+
isDragging.value = false
74+
if (e.dataTransfer?.files) {
75+
onSelectFile(e.dataTransfer.files)
76+
}
77+
}
78+
79+
function onSelectFile(files: FileList | File[] | null) {
80+
if (!files) {
81+
return
82+
}
83+
const selectedFiles = Array.from(files)
84+
// 数量限制
85+
const remain = props.max > 0 ? props.max - fileList.value.length : selectedFiles.length
86+
const filesToAdd: File[] = []
87+
for (const file of selectedFiles.slice(0, remain)) {
88+
// 类型校验
89+
if (props.ext.length > 0) {
90+
const ext = file.name.split('.').pop()?.toLowerCase()
91+
if (!props.ext.map(e => e.toLowerCase()).includes(ext || '')) {
92+
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式`)
93+
continue
94+
}
95+
}
96+
// 大小校验
97+
if (props.size > 0) {
98+
if (file.size > props.size) {
99+
toast.error(`上传文件大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`)
100+
continue
101+
}
102+
}
103+
filesToAdd.push(file)
104+
}
105+
fileInputRef.value!.value = ''
106+
filesToAdd.forEach(file => uploadFile(file))
107+
}
108+
109+
function uploadFile(file: File, index?: number) {
110+
const formData = new FormData()
111+
Object.entries(props.data).forEach(([key, value]) => {
112+
formData.append(key, value)
113+
})
114+
formData.append(props.name, file)
115+
let headersObj: Record<string, any> = {}
116+
if (props.headers instanceof Headers) {
117+
props.headers.forEach((value, key) => {
118+
headersObj[key] = value
119+
})
120+
}
121+
else {
122+
headersObj = { ...props.headers }
123+
}
124+
if (index === undefined) {
125+
fileList.value.push({
126+
name: file.name,
127+
size: file.size,
128+
status: 'uploading',
129+
progress: 0,
130+
file,
131+
})
132+
}
133+
const currentFileIndex = index ?? fileList.value.length - 1
134+
axios({
135+
url: props.action,
136+
method: props.method,
137+
headers: headersObj,
138+
data: formData,
139+
onUploadProgress: (progressEvent) => {
140+
if (progressEvent.total) {
141+
fileList.value[currentFileIndex].progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
142+
}
143+
},
144+
})
145+
.then(async (response) => {
146+
const url = await props.afterUpload?.(response.data)
147+
if (url) {
148+
fileList.value[currentFileIndex].url = url
149+
}
150+
emits('onSuccess', response.data, file)
151+
fileList.value[currentFileIndex].status = 'success'
152+
})
153+
.catch(() => {
154+
fileList.value[currentFileIndex].status = 'error'
155+
})
156+
}
157+
158+
function removeFile(idx: number) {
159+
fileList.value.splice(idx, 1)
160+
}
161+
</script>
162+
163+
<template>
164+
<div class="space-y-2">
165+
<button
166+
class="h-40 w-full flex flex-col cursor-pointer items-center justify-center border border-2 rounded-lg border-dashed bg-transparent p-4 transition-all"
167+
:class="{
168+
'border-primary bg-primary/5': isDragging,
169+
'opacity-50 cursor-not-allowed': props.disabled || (props.max > 0 && fileList.length >= props.max),
170+
}"
171+
:disabled="props.disabled || (props.max > 0 && fileList.length >= props.max)"
172+
@dragover="handleDragOver"
173+
@dragleave="handleDragLeave"
174+
@drop="handleDrop"
175+
@click="fileInputRef?.click()"
176+
>
177+
<FaIcon name="i-icon-park-outline:upload" class="mb-2 text-2xl text-card-foreground/50" />
178+
<div class="text-sm text-card-foreground/70">
179+
将文件拖到此处,或<span class="cursor-pointer text-primary font-bold">点击上传</span>
180+
</div>
181+
<input ref="fileInputRef" type="file" :multiple="props.multiple" :disabled="props.disabled" class="hidden" @change="e => onSelectFile((e.target as HTMLInputElement).files)">
182+
</button>
183+
<div v-if="!props.hideTips && !props.disabled" class="flex flex-wrap gap-1 text-xs text-card-foreground/50 empty:hidden">
184+
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
185+
{{ `支持 ${props.ext.join(' / ')} 格式` }}
186+
</div>
187+
<div v-if="props.size > 0" class="after:content-[';_'] last:after:content-empty">
188+
{{ `大小不超过 ${filesize(props.size, { standard: 'jedec' })}` }}
189+
</div>
190+
<div v-if="props.max > 0" class="after:content-[';_'] last:after:content-empty">
191+
{{ `数量不超过 ${props.max} 个` }}
192+
</div>
193+
</div>
194+
<div v-if="fileList.length > 0" class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
195+
<div
196+
v-for="(item, index) in fileList"
197+
:key="item.name + index"
198+
class="group/file-upload-item relative flex items-center gap-2 border rounded-lg py-2 pe-2 ps-3"
199+
:class="[
200+
item.status === 'error' ? 'border-red-500 bg-red-500/10' : '',
201+
]"
202+
@click="emits('onClick', item, index)"
203+
>
204+
<div class="flex-1 truncate">
205+
<div class="truncate text-sm font-medium">
206+
{{ item.name }}
207+
</div>
208+
<div class="text-xs text-card-foreground/50">
209+
{{ filesize(item.size, { standard: 'jedec' }) }}
210+
</div>
211+
</div>
212+
<div v-if="item.status === 'uploading'" class="pointer-events-none absolute inset-0 z-0 bg-primary/5" :style="{ width: `${item.progress}%` }" />
213+
<div v-else-if="item.status === 'success'" class="size-10 flex-center">
214+
<FaIcon name="i-ix:upload-success" class="text-lg text-green-600" />
215+
</div>
216+
<div v-else-if="item.status === 'error'" class="size-10 flex-center">
217+
<FaIcon name="i-ix:upload-fail" class="text-lg text-red-600" />
218+
</div>
219+
<FaButtonGroup v-if="!props.disabled" class="absolute inset-e-2 top-1/2 opacity-0 transition-opacity -translate-y-1/2 group-hover/file-upload-item:opacity-100">
220+
<FaButton v-if="item.status === 'error'" variant="outline" size="icon" @click.stop="uploadFile(item.file!, index)">
221+
<FaIcon name="i-icon-park-outline:upload" class="cursor-pointer text-lg" />
222+
</FaButton>
223+
<FaButton v-if="item.status !== 'uploading'" variant="outline" size="icon" @click.stop="removeFile(index)">
224+
<FaIcon name="i-icon-park-outline:delete" class="cursor-pointer text-lg text-red-500" />
225+
</FaButton>
226+
</FaButtonGroup>
227+
</div>
228+
</div>
229+
</div>
230+
</template>

0 commit comments

Comments
 (0)