feat:更换企业授权
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 3m41s

This commit is contained in:
luo
2026-06-22 10:36:33 +08:00
parent 866a2587b3
commit d3b5f55c64
34 changed files with 2478 additions and 3335 deletions

View File

@@ -0,0 +1,404 @@
<template>
<div
ref="rootRef"
class="voucher-upload"
tabindex="0"
@click="focusUploadArea"
@paste="handlePaste"
>
<ElUpload
ref="uploadRef"
class="voucher-upload__control"
drag
multiple
:auto-upload="false"
:on-change="handleFileChange"
:on-remove="handleRemoveFile"
list-type="picture"
>
<ElIcon class="voucher-upload__icon"><UploadFilled /></ElIcon>
<div class="voucher-upload__text">{{ voucherName }}拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">
支持多张图片或附件最多上传 {{ maxCount }} 个文件点击上传区域后可粘贴文件
</div>
</template>
<template #file="{ file }">
<div class="voucher-upload__file-item">
<img
v-if="isUploadImage(file)"
class="voucher-upload__file-cover"
:src="getUploadFileUrl(file)"
alt=""
/>
<div v-else class="voucher-upload__file-cover voucher-upload__file-cover--default">
<ElIcon><Document /></ElIcon>
</div>
<span class="voucher-upload__file-name" :title="file.name">{{ file.name }}</span>
<button
class="voucher-upload__file-remove"
type="button"
aria-label="移除文件"
@click.stop="removeUploadFile(file)"
>
<ElIcon><Close /></ElIcon>
</button>
</div>
</template>
</ElUpload>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { genFileId } from 'element-plus'
import { Close, Document, UploadFilled } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus'
import { StorageService } from '@/api/modules'
interface Props {
modelValue?: string
voucherName?: string
maxCount?: number
}
const props = withDefaults(defineProps<Props>(), {
voucherName: '凭证',
maxCount: 5
})
const emit = defineEmits<{
'update:modelValue': [value: string | undefined]
'uploading-change': [value: boolean]
change: [value: string | undefined]
}>()
const rootRef = ref<HTMLElement>()
const uploadRef = ref<UploadInstance>()
const uploadingCount = ref(0)
const voucherFileKeyMap = new Map<number, string>()
const removedUploadUids = new Set<number>()
const fileObjectUrlMap = new Map<number, string>()
const selectedUploadUids = new Set<number>()
let activeUploadBatch = 0
const getMaxCount = () => Math.max(1, props.maxCount)
const showMaxCountWarning = () => {
ElMessage.warning(`最多只能上传 ${getMaxCount()} 个文件`)
}
const emitVoucherKeys = () => {
const keys = Array.from(voucherFileKeyMap.values())
const value = keys.length ? keys.join(',') : undefined
emit('update:modelValue', value)
emit('change', value)
}
const setUploadingCount = (count: number) => {
uploadingCount.value = Math.max(0, count)
emit('uploading-change', uploadingCount.value > 0)
}
const clearFiles = (emitValue = true) => {
activeUploadBatch += 1
setUploadingCount(0)
voucherFileKeyMap.clear()
removedUploadUids.clear()
selectedUploadUids.clear()
revokeAllFileObjectUrls()
uploadRef.value?.clearFiles()
if (emitValue) {
emitVoucherKeys()
}
}
const revokeFileObjectUrl = (uid: number) => {
const url = fileObjectUrlMap.get(uid)
if (!url) return
URL.revokeObjectURL(url)
fileObjectUrlMap.delete(uid)
}
const revokeAllFileObjectUrls = () => {
fileObjectUrlMap.forEach((url) => URL.revokeObjectURL(url))
fileObjectUrlMap.clear()
}
const isUploadImage = (file: UploadFile) => {
if (file.raw?.type?.startsWith('image/')) return true
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name)
}
const getUploadFileUrl = (file: UploadFile) => {
if (file.url) return file.url
if (!file.raw) return ''
const cachedUrl = fileObjectUrlMap.get(file.uid)
if (cachedUrl) return cachedUrl
const url = URL.createObjectURL(file.raw)
fileObjectUrlMap.set(file.uid, url)
return url
}
const removeUploadFile = (uploadFile: UploadFile) => {
uploadRef.value?.handleRemove(uploadFile)
}
const handleFileChange = async (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!selectedUploadUids.has(uploadFile.uid)) {
if (selectedUploadUids.size >= getMaxCount()) {
showMaxCountWarning()
removeUploadFile(uploadFile)
return
}
selectedUploadUids.add(uploadFile.uid)
}
const uploadBatch = activeUploadBatch
removedUploadUids.delete(uploadFile.uid)
setUploadingCount(uploadingCount.value + 1)
try {
ElMessage.info(`正在上传${props.voucherName}...`)
const contentType = file.type || 'application/octet-stream'
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
removeUploadFile(uploadFile)
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, contentType)
if (uploadBatch !== activeUploadBatch || removedUploadUids.has(uploadFile.uid)) {
return
}
voucherFileKeyMap.set(uploadFile.uid, file_key)
emitVoucherKeys()
ElMessage.success('上传成功')
} catch (error: any) {
if (uploadBatch !== activeUploadBatch) return
console.error(`上传${props.voucherName}失败:`, error)
ElMessage.error(error?.message || '上传失败,请重试')
voucherFileKeyMap.delete(uploadFile.uid)
emitVoucherKeys()
removeUploadFile(uploadFile)
} finally {
if (uploadBatch === activeUploadBatch) {
setUploadingCount(uploadingCount.value - 1)
}
}
}
const handleRemoveFile = (uploadFile: UploadFile) => {
removedUploadUids.add(uploadFile.uid)
selectedUploadUids.delete(uploadFile.uid)
revokeFileObjectUrl(uploadFile.uid)
voucherFileKeyMap.delete(uploadFile.uid)
emitVoucherKeys()
}
const focusUploadArea = () => {
nextTick(() => rootRef.value?.focus())
}
const getExtensionByMimeType = (mimeType: string) => {
const extensionMap: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'application/pdf': 'pdf',
'text/plain': 'txt'
}
return extensionMap[mimeType] || 'bin'
}
const normalizeClipboardFile = (file: File, index: number) => {
if (file.name) return file
const extension = getExtensionByMimeType(file.type)
return new File([file], `clipboard-${Date.now()}-${index + 1}.${extension}`, {
type: file.type || 'application/octet-stream'
})
}
const startUploadFile = (file: File) => {
const uploadRawFile = file as UploadRawFile
uploadRawFile.uid = genFileId()
uploadRef.value?.handleStart(uploadRawFile)
}
const getClipboardFiles = (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) return []
const files = Array.from(clipboardData.files || [])
if (files.length) return files
return Array.from(clipboardData.items || [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => !!file)
}
const handlePaste = (event: ClipboardEvent) => {
if (event.defaultPrevented) return
if (!isUploadVisible()) return
const files = getClipboardFiles(event)
if (!files.length) return
event.preventDefault()
const remainingCount = getMaxCount() - selectedUploadUids.size
if (remainingCount <= 0) {
showMaxCountWarning()
return
}
if (files.length > remainingCount) {
showMaxCountWarning()
}
files.slice(0, remainingCount).map(normalizeClipboardFile).forEach(startUploadFile)
}
const isUploadVisible = () => {
const element = rootRef.value
return (
!!element &&
!!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
)
}
onMounted(() => {
document.addEventListener('paste', handlePaste)
})
onBeforeUnmount(() => {
document.removeEventListener('paste', handlePaste)
revokeAllFileObjectUrls()
})
defineExpose({
clearFiles
})
</script>
<style scoped lang="scss">
.voucher-upload {
width: 100%;
outline: none;
&:focus-within,
&:focus {
:deep(.el-upload-dragger) {
border-color: var(--el-color-primary);
}
}
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 24px 16px;
border-radius: 8px;
}
:deep(.el-upload-list__item) {
height: auto;
padding: 8px;
line-height: normal;
}
&__icon {
margin-bottom: 12px;
font-size: 28px;
color: var(--el-color-primary);
}
&__text {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
&__file-item {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
min-width: 0;
}
&__file-cover {
flex: 0 0 auto;
width: 42px;
height: 42px;
object-fit: cover;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
}
&__file-cover--default {
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
&__file-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--el-text-color-regular);
text-overflow: ellipsis;
white-space: nowrap;
}
&__file-remove {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 50%;
&:hover {
color: var(--el-color-danger);
background: var(--el-fill-color-light);
}
}
}
</style>