25 changed files with 2093 additions and 13 deletions
@ -1,15 +1,15 @@ |
|||
<script lang="ts" setup> |
|||
import { Page } from '@vben/common-ui'; |
|||
|
|||
import { ContainerTable } from '@abp/oss'; |
|||
import { BlobViewPage } from '@abp/blob-management'; |
|||
|
|||
defineOptions({ |
|||
name: 'Vben5OssContainers', |
|||
name: 'Vben5BlobFiles', |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<Page> |
|||
<ContainerTable /> |
|||
<BlobViewPage /> |
|||
</Page> |
|||
</template> |
|||
@ -1,15 +1,15 @@ |
|||
<script lang="ts" setup> |
|||
import { Page } from '@vben/common-ui'; |
|||
|
|||
import { ObjectPage } from '@abp/oss'; |
|||
import { BlobContainerTable } from '@abp/blob-management'; |
|||
|
|||
defineOptions({ |
|||
name: 'Vben5OssObjects', |
|||
name: 'Vben5BlobContainers', |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<Page auto-content-height> |
|||
<ObjectPage /> |
|||
<Page> |
|||
<BlobContainerTable /> |
|||
</Page> |
|||
</template> |
|||
@ -0,0 +1,50 @@ |
|||
{ |
|||
"name": "@abp/blob-management", |
|||
"version": "10.0.2", |
|||
"homepage": "https://github.com/colinin/abp-next-admin", |
|||
"bugs": "https://github.com/colinin/abp-next-admin/issues", |
|||
"repository": { |
|||
"type": "git", |
|||
"url": "git+https://github.com/colinin/abp-next-admin.git", |
|||
"directory": "packages/@abp/blob-management" |
|||
}, |
|||
"license": "MIT", |
|||
"type": "module", |
|||
"sideEffects": [ |
|||
"**/*.css" |
|||
], |
|||
"exports": { |
|||
".": { |
|||
"types": "./src/index.ts", |
|||
"default": "./src/index.ts" |
|||
}, |
|||
"./global": { |
|||
"types": "./global.d.ts" |
|||
} |
|||
}, |
|||
"dependencies": { |
|||
"@abp/components": "workspace:*", |
|||
"@abp/core": "workspace:*", |
|||
"@abp/features": "workspace:*", |
|||
"@abp/request": "workspace:*", |
|||
"@abp/ui": "workspace:*", |
|||
"@ant-design/icons-vue": "catalog:", |
|||
"@vben/access": "workspace:*", |
|||
"@vben/common-ui": "workspace:*", |
|||
"@vben/hooks": "workspace:*", |
|||
"@vben/icons": "workspace:*", |
|||
"@vben/layouts": "workspace:*", |
|||
"@vben/locales": "workspace:*", |
|||
"@vben/stores": "workspace:*", |
|||
"@vben/utils": "workspace:*", |
|||
"@vue-office/docx": "catalog:", |
|||
"@vue-office/excel": "catalog:", |
|||
"@vue-office/pdf": "catalog:", |
|||
"@vue-office/pptx": "catalog:", |
|||
"ant-design-vue": "catalog:", |
|||
"vue": "catalog:", |
|||
"vue-demi": "catalog:", |
|||
"vue-simple-uploader": "catalog:", |
|||
"vxe-table": "catalog:" |
|||
} |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
export { useBlobContainersApi } from './useBlobContainersApi'; |
|||
export { useBlobsApi } from './useBlobsApi'; |
|||
@ -0,0 +1,52 @@ |
|||
import type { PagedResultDto } from '@abp/core'; |
|||
|
|||
import type { |
|||
BlobContainerCreateDto, |
|||
BlobContainerDto, |
|||
BlobContainerGetPagedListInput, |
|||
} from '../types/containers'; |
|||
|
|||
import { useRequest } from '@abp/request'; |
|||
|
|||
export function useBlobContainersApi() { |
|||
const { cancel, request } = useRequest(); |
|||
|
|||
function deleteApi(id: string): Promise<void> { |
|||
return request(`/api/blob-management/containers/${id}`, { |
|||
method: 'DELETE', |
|||
}); |
|||
} |
|||
|
|||
function getApi(id: string): Promise<BlobContainerDto> { |
|||
return request<BlobContainerDto>(`/api/blob-management/containers/${id}`, { |
|||
method: 'GET', |
|||
}); |
|||
} |
|||
|
|||
function getPagedListApi( |
|||
input?: BlobContainerGetPagedListInput, |
|||
): Promise<PagedResultDto<BlobContainerDto>> { |
|||
return request<PagedResultDto<BlobContainerDto>>( |
|||
`/api/blob-management/containers`, |
|||
{ |
|||
method: 'GET', |
|||
params: input, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
function createApi(input: BlobContainerCreateDto): Promise<BlobContainerDto> { |
|||
return request<BlobContainerDto>(`/api/blob-management/containers`, { |
|||
method: 'POST', |
|||
data: input, |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
cancel, |
|||
createApi, |
|||
deleteApi, |
|||
getApi, |
|||
getPagedListApi, |
|||
}; |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
import type { PagedResultDto } from '@abp/core'; |
|||
|
|||
import type { |
|||
BlobDto, |
|||
BlobFolderCreateDto, |
|||
BlobGetPagedListInput, |
|||
} from '../types/blobs'; |
|||
|
|||
import { useRequest } from '@abp/request'; |
|||
|
|||
import { BlobType } from '../types/blobs'; |
|||
|
|||
export function useBlobsApi() { |
|||
const { cancel, request } = useRequest(); |
|||
|
|||
function createFolderApi(input: BlobFolderCreateDto): Promise<BlobDto> { |
|||
return request<BlobDto>(`/api/blob-management/blobs/folder`, { |
|||
method: 'POST', |
|||
data: input, |
|||
}); |
|||
} |
|||
|
|||
function deleteApi(id: string): Promise<void> { |
|||
return request(`/api/blob-management/blobs/${id}`, { |
|||
method: 'DELETE', |
|||
}); |
|||
} |
|||
|
|||
function getApi(id: string): Promise<BlobDto> { |
|||
return request<BlobDto>(`/api/blob-management/blobs/${id}`, { |
|||
method: 'GET', |
|||
}); |
|||
} |
|||
|
|||
function getContentApi(id: string): Promise<Blob> { |
|||
return request<Blob>(`/api/blob-management/blobs/uid/${id}/content`, { |
|||
method: 'GET', |
|||
responseType: 'blob', |
|||
}); |
|||
} |
|||
|
|||
function getFolderPagedListApi( |
|||
input: BlobGetPagedListInput, |
|||
): Promise<PagedResultDto<BlobDto>> { |
|||
return getPagedListApi({ |
|||
...input, |
|||
type: BlobType.Folder, |
|||
}); |
|||
} |
|||
|
|||
function getFilePagedListApi( |
|||
input: BlobGetPagedListInput, |
|||
): Promise<PagedResultDto<BlobDto>> { |
|||
return getPagedListApi({ |
|||
...input, |
|||
type: BlobType.File, |
|||
}); |
|||
} |
|||
|
|||
function getPagedListApi( |
|||
input: BlobGetPagedListInput, |
|||
): Promise<PagedResultDto<BlobDto>> { |
|||
return request<PagedResultDto<BlobDto>>(`/api/blob-management/blobs`, { |
|||
method: 'GET', |
|||
params: input, |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
cancel, |
|||
createFolderApi, |
|||
deleteApi, |
|||
getApi, |
|||
getContentApi, |
|||
getFolderPagedListApi, |
|||
getFilePagedListApi, |
|||
getPagedListApi, |
|||
}; |
|||
} |
|||
@ -0,0 +1,411 @@ |
|||
<script setup lang="ts"> |
|||
import type { BlobDto } from '../../types/blobs'; |
|||
|
|||
import { defineAsyncComponent, ref, watch } from 'vue'; |
|||
|
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
import { downloadFileFromUrl } from '@vben/utils'; |
|||
|
|||
import { Button, Empty, message, Spin } from 'ant-design-vue'; |
|||
|
|||
import { useBlobsApi } from '../../api/useBlobsApi'; |
|||
|
|||
type FileType = 'excel' | 'image' | 'pdf' | 'ppt' | 'text' | 'word'; |
|||
|
|||
const VueOfficeDocx = defineAsyncComponent({ |
|||
loader: async () => { |
|||
const res = await import('@vue-office/docx/lib/v3/index.js'); |
|||
import('@vue-office/docx/lib/v3/index.css'); |
|||
return res.default; |
|||
}, |
|||
loadingComponent: Spin, |
|||
delay: 200, |
|||
}); |
|||
|
|||
const VueOfficeExcel = defineAsyncComponent({ |
|||
loader: async () => { |
|||
const res = await import('@vue-office/excel/lib/v3/index.js'); |
|||
import('@vue-office/excel/lib/v3/index.css'); |
|||
return res.default; |
|||
}, |
|||
loadingComponent: Spin, |
|||
delay: 200, |
|||
}); |
|||
|
|||
const VueOfficePdf = defineAsyncComponent({ |
|||
loader: () => import('@vue-office/pdf/lib/v3/index.js'), |
|||
loadingComponent: Spin, |
|||
delay: 200, |
|||
}); |
|||
|
|||
const VueOfficePptx = defineAsyncComponent({ |
|||
loader: () => import('@vue-office/pptx/lib/v3/index.js'), |
|||
loadingComponent: Spin, |
|||
delay: 200, |
|||
}); |
|||
|
|||
const Image = defineAsyncComponent({ |
|||
loader: async () => { |
|||
const res = await import('ant-design-vue'); |
|||
return res.Image; |
|||
}, |
|||
loadingComponent: Spin, |
|||
delay: 200, |
|||
}); |
|||
|
|||
const modalTitle = ref(''); |
|||
const isPreview = ref(false); |
|||
const isLoading = ref(false); |
|||
const blob = ref<BlobDto>(); |
|||
const blobData = ref<string>(); |
|||
const blobText = ref<string>(); |
|||
const blobType = ref<FileType>(); |
|||
const errorMessage = ref<string>(''); |
|||
const excelComponentKey = ref(0); // 只对 Excel 组件使用 key |
|||
|
|||
const { getContentApi } = useBlobsApi(); |
|||
|
|||
// 文件类型配置 |
|||
const allowFiles: Record<FileType, string[]> = { |
|||
pdf: ['.pdf'], |
|||
word: ['.doc', '.docx'], |
|||
excel: ['.xls', '.xlsx'], |
|||
ppt: ['.ppt', '.pptx'], |
|||
text: ['.txt', '.text', '.log', '.md', '.json', '.xml', '.yaml', '.yml'], |
|||
image: [ |
|||
'.bmp', |
|||
'.png', |
|||
'.jpg', |
|||
'.jpeg', |
|||
'.webp', |
|||
'.tif', |
|||
'.tiff', |
|||
'.gif', |
|||
'.svg', |
|||
], |
|||
}; |
|||
|
|||
// 文件大小限制(默认50MB) |
|||
const MAX_FILE_SIZE = 50 * 1024 * 1024; |
|||
|
|||
// 判断文件类型 |
|||
function getFileType(): FileType | undefined { |
|||
const fileName = blob.value!.name.toLocaleLowerCase(); |
|||
for (const [type, extensions] of Object.entries(allowFiles)) { |
|||
if (extensions.some((ext) => fileName.endsWith(ext))) { |
|||
return type as FileType; |
|||
} |
|||
} |
|||
return undefined; |
|||
} |
|||
|
|||
// 检查文件是否允许预览 |
|||
function isAllowPreview(): boolean { |
|||
return !!getFileType(); |
|||
} |
|||
|
|||
// 检查文件大小 |
|||
function isFileSizeValid(content: Blob): boolean { |
|||
if (content.size > MAX_FILE_SIZE) { |
|||
errorMessage.value = $t('BlobManagement.Blobs:FileTooLarge', { |
|||
size: MAX_FILE_SIZE / 1024 / 1024, |
|||
}); |
|||
return false; |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
function onError(error?: string) { |
|||
message.error(`${$t('BlobManagement.Blobs:PreviewError')}: ${error}`); |
|||
} |
|||
|
|||
// 清理资源 |
|||
function cleanup() { |
|||
if (blobData.value) { |
|||
URL.revokeObjectURL(blobData.value); |
|||
blobData.value = undefined; |
|||
} |
|||
blobText.value = ''; |
|||
blobType.value = undefined; |
|||
isPreview.value = false; |
|||
errorMessage.value = ''; |
|||
} |
|||
|
|||
const [Modal, modalApi] = useVbenModal({ |
|||
showCancelButton: false, |
|||
showConfirmButton: false, |
|||
fullscreen: true, |
|||
fullscreenButton: false, |
|||
onCancel: () => { |
|||
cleanup(); |
|||
}, |
|||
onOpenChange: async (isOpen) => { |
|||
if (isOpen) { |
|||
await onInit(); |
|||
} else { |
|||
cleanup(); |
|||
} |
|||
}, |
|||
}); |
|||
|
|||
async function onInit() { |
|||
try { |
|||
isLoading.value = true; |
|||
isPreview.value = false; |
|||
errorMessage.value = ''; |
|||
modalApi.lock(); |
|||
|
|||
// 重置状态 |
|||
cleanup(); |
|||
|
|||
// 获取数据 |
|||
blob.value = modalApi.getData<BlobDto>(); |
|||
if (!blob.value) { |
|||
errorMessage.value = $t('BlobManagement.Blobs:NoData'); |
|||
return; |
|||
} |
|||
|
|||
modalTitle.value = `${$t('BlobManagement.Blobs:Preview')} - ${blob.value.name}`; |
|||
|
|||
// 检查是否支持预览 |
|||
if (!isAllowPreview()) { |
|||
errorMessage.value = $t('BlobManagement.BlobCanNotPreviewMessage'); |
|||
return; |
|||
} |
|||
|
|||
// 获取文件内容 |
|||
const content = await getContentApi(blob.value.id); |
|||
|
|||
// 检查文件大小 |
|||
if (!isFileSizeValid(content)) { |
|||
return; |
|||
} |
|||
|
|||
// 创建对象URL |
|||
blobData.value = URL.createObjectURL(content); |
|||
|
|||
// 设置文件类型并处理内容 |
|||
blobType.value = getFileType(); |
|||
|
|||
await processFileContent(content); |
|||
isPreview.value = true; |
|||
} catch (error) { |
|||
console.error('Preview error:', error); |
|||
errorMessage.value = $t('BlobManagement.Blobs:PreviewError'); |
|||
message.error(errorMessage.value); |
|||
} finally { |
|||
isLoading.value = false; |
|||
modalApi.unlock(); |
|||
} |
|||
} |
|||
|
|||
async function processFileContent(content: Blob) { |
|||
// 处理文本文件 |
|||
if (blobType.value === 'text') { |
|||
try { |
|||
blobText.value = await content.text(); |
|||
// 限制文本显示长度(避免大文件导致页面卡顿) |
|||
const maxLength = 100_000; // 10万字符 |
|||
if (blobText.value.length > maxLength) { |
|||
blobText.value = `${blobText.value.slice(0, Math.max(0, maxLength))}\n\n... ${$t( |
|||
'BlobManagement.Blobs:TextTruncated', |
|||
)}`; |
|||
message.warning($t('BlobManagement.Blobs:TextTooLarge')); |
|||
} |
|||
} catch (error) { |
|||
console.error('Read text error:', error); |
|||
throw new Error('Failed to read text file'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 下载原始文件 |
|||
function handleDownload() { |
|||
if (!blob.value) return; |
|||
downloadFileFromUrl({ |
|||
fileName: blob.value.name, |
|||
source: `/api/blob-management/blobs/uid/${blob.value.id}/content`, |
|||
}); |
|||
} |
|||
|
|||
// 监听blobData变化,确保资源清理 |
|||
watch(blobData, (newVal, oldVal) => { |
|||
if (oldVal && oldVal !== newVal) { |
|||
URL.revokeObjectURL(oldVal); |
|||
} |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<Modal :title="modalTitle"> |
|||
<div class="preview-container"> |
|||
<!-- 加载状态 --> |
|||
<div v-if="isLoading" class="flex h-96 items-center justify-center"> |
|||
<Spin :tip="$t('AbpUi.LoadingWithThreeDot')" /> |
|||
</div> |
|||
|
|||
<!-- 错误提示 --> |
|||
<div v-else-if="errorMessage" class="error-message"> |
|||
<Empty :description="errorMessage"> |
|||
<template #image> |
|||
<span class="text-6xl">📄</span> |
|||
</template> |
|||
</Empty> |
|||
</div> |
|||
|
|||
<!-- 预览内容 --> |
|||
<template v-else> |
|||
<!-- 不支持预览 --> |
|||
<Empty |
|||
v-if="!isPreview" |
|||
:description="$t('BlobManagement.BlobCanNotPreviewMessage')" |
|||
> |
|||
<template #image> |
|||
<span class="text-6xl">🔍</span> |
|||
</template> |
|||
</Empty> |
|||
|
|||
<!-- Word预览 --> |
|||
<div v-else-if="blobType === 'word'" class="office-preview-wrapper"> |
|||
<VueOfficeDocx :src="blobData" @error="onError" /> |
|||
</div> |
|||
|
|||
<!-- Excel预览 - 单独处理,添加key强制重新渲染 --> |
|||
<div v-else-if="blobType === 'excel'" class="excel-preview-wrapper"> |
|||
<VueOfficeExcel |
|||
:key="excelComponentKey" |
|||
:src="blobData" |
|||
@error="onError" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- PDF预览 - 不做任何处理,保持原样 --> |
|||
<VueOfficePdf |
|||
v-else-if="blobType === 'pdf'" |
|||
:src="blobData" |
|||
@error="onError" |
|||
/> |
|||
|
|||
<!-- PPT预览 --> |
|||
<div v-else-if="blobType === 'ppt'" class="office-preview-wrapper"> |
|||
<VueOfficePptx :src="blobData" @error="onError" /> |
|||
</div> |
|||
|
|||
<!-- 图片预览 --> |
|||
<div v-else-if="blobType === 'image'" class="image-preview"> |
|||
<Image |
|||
:src="blobData" |
|||
:alt="blob?.name" |
|||
class="max-h-[80vh] max-w-full object-contain" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- 文本预览 --> |
|||
<div v-else-if="blobType === 'text'" class="text-preview"> |
|||
<div |
|||
class="whitespace-pre-wrap rounded bg-gray-50 p-4 font-mono text-sm dark:bg-gray-800" |
|||
> |
|||
{{ blobText }} |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<!-- 下载按钮 --> |
|||
<div v-if="blob" class="download-button"> |
|||
<Button type="primary" @click="handleDownload"> |
|||
{{ $t('BlobManagement.Blobs:Download') }} |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
</Modal> |
|||
</template> |
|||
|
|||
<style scoped lang="scss"> |
|||
.preview-container { |
|||
position: relative; |
|||
min-height: 400px; |
|||
|
|||
.error-message { |
|||
padding: 40px 20px; |
|||
text-align: center; |
|||
} |
|||
|
|||
.office-preview-wrapper { |
|||
width: 100%; |
|||
|
|||
:deep(> div) { |
|||
width: 100%; |
|||
} |
|||
} |
|||
|
|||
// Excel 单独包装器样式 |
|||
.excel-preview-wrapper { |
|||
width: 100%; |
|||
min-height: 500px; |
|||
|
|||
:deep(> div) { |
|||
width: 100%; |
|||
height: 100%; |
|||
min-height: 500px; |
|||
} |
|||
} |
|||
|
|||
.image-preview { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
min-height: 400px; |
|||
overflow: auto; |
|||
background-color: #f5f5f5; |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
.text-preview { |
|||
max-height: 70vh; |
|||
overflow: auto; |
|||
|
|||
:deep(.whitespace-pre-wrap) { |
|||
word-break: normal; |
|||
overflow-wrap: break-word; |
|||
white-space: pre-wrap; |
|||
} |
|||
} |
|||
|
|||
.download-button { |
|||
position: sticky; |
|||
right: 20px; |
|||
bottom: 20px; |
|||
z-index: 10; |
|||
float: right; |
|||
margin-top: 20px; |
|||
} |
|||
} |
|||
</style> |
|||
|
|||
<style lang="scss"> |
|||
.vben-modal { |
|||
.vben-modal__body { |
|||
height: auto !important; |
|||
max-height: calc(90vh - 110px); |
|||
padding: 20px; |
|||
overflow-y: auto; |
|||
} |
|||
} |
|||
|
|||
.excel-preview-wrapper { |
|||
.vue-office-excel { |
|||
width: 100% !important; |
|||
height: 100% !important; |
|||
min-height: 500px !important; |
|||
|
|||
iframe, |
|||
canvas, |
|||
.office-viewer { |
|||
width: 100% !important; |
|||
height: 100% !important; |
|||
min-height: 500px !important; |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,301 @@ |
|||
<script setup lang="ts"> |
|||
import type { VxeGridListeners, VxeGridProps } from '@abp/ui'; |
|||
|
|||
import type { BlobDto } from '../../types/blobs'; |
|||
|
|||
import { defineAsyncComponent, h, nextTick, ref, watch } from 'vue'; |
|||
|
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
import { downloadFileFromUrl } from '@vben/utils'; |
|||
|
|||
import { formatToDateTime, isNullOrWhiteSpace } from '@abp/core'; |
|||
import { useVbenVxeGrid } from '@abp/ui'; |
|||
import { |
|||
DeleteOutlined, |
|||
DownloadOutlined, |
|||
EyeOutlined, |
|||
UploadOutlined, |
|||
} from '@ant-design/icons-vue'; |
|||
import { Button, Card, message, Modal } from 'ant-design-vue'; |
|||
|
|||
import { useBlobsApi } from '../../api/useBlobsApi'; |
|||
import { BlobType } from '../../types/blobs'; |
|||
|
|||
defineOptions({ |
|||
name: 'FileList', |
|||
}); |
|||
|
|||
const props = defineProps<{ |
|||
containerId: string; |
|||
folderId?: string; |
|||
}>(); |
|||
|
|||
const kbUnit = 1 * 1024; |
|||
const mbUnit = kbUnit * 1024; |
|||
const gbUnit = mbUnit * 1024; |
|||
|
|||
const { deleteApi, getPagedListApi } = useBlobsApi(); |
|||
|
|||
const selectedKeys = ref<string[]>([]); |
|||
|
|||
const gridOptions: VxeGridProps<BlobDto> = { |
|||
columns: [ |
|||
{ |
|||
align: 'left', |
|||
field: 'name', |
|||
minWidth: 150, |
|||
sortable: true, |
|||
title: $t('BlobManagement.DisplayName:Name'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'type', |
|||
formatter: ({ row }) => { |
|||
if (row.type === BlobType.Folder) { |
|||
return $t('BlobManagement.BlobType:Folder'); |
|||
} |
|||
return $t('BlobManagement.BlobType:File'); |
|||
}, |
|||
minWidth: 150, |
|||
sortable: true, |
|||
title: $t('BlobManagement.DisplayName:BlobType'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'size', |
|||
formatter: ({ cellValue }) => { |
|||
const size = Number(cellValue); |
|||
if (size > gbUnit) { |
|||
let gb = Math.round(size / gbUnit); |
|||
if (gb < 1) { |
|||
gb = 1; |
|||
} |
|||
return `${gb} GB`; |
|||
} |
|||
if (size > mbUnit) { |
|||
let mb = Math.round(size / mbUnit); |
|||
if (mb < 1) { |
|||
mb = 1; |
|||
} |
|||
return `${mb} MB`; |
|||
} |
|||
let kb = Math.round(size / kbUnit); |
|||
if (kb < 1) { |
|||
kb = 1; |
|||
} |
|||
return `${kb} KB`; |
|||
}, |
|||
minWidth: 150, |
|||
sortable: true, |
|||
title: $t('BlobManagement.DisplayName:Size'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'creationTime', |
|||
formatter({ cellValue }) { |
|||
return cellValue ? formatToDateTime(cellValue) : ''; |
|||
}, |
|||
minWidth: 120, |
|||
sortable: true, |
|||
title: $t('BlobManagement.DisplayName:CreationTime'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'lastModificationTime', |
|||
formatter({ cellValue }) { |
|||
return cellValue ? formatToDateTime(cellValue) : ''; |
|||
}, |
|||
minWidth: 120, |
|||
sortable: true, |
|||
title: $t('BlobManagement.DisplayName:LastModificationTime'), |
|||
}, |
|||
{ |
|||
field: 'action', |
|||
fixed: 'right', |
|||
slots: { default: 'action' }, |
|||
title: $t('AbpUi.Actions'), |
|||
width: 260, |
|||
}, |
|||
], |
|||
exportConfig: {}, |
|||
keepSource: true, |
|||
proxyConfig: { |
|||
ajax: { |
|||
query: async ({ page, sort }) => { |
|||
const sorting = sort.order ? `${sort.field} ${sort.order}` : undefined; |
|||
return await getPagedListApi({ |
|||
containerId: props.containerId, |
|||
maxResultCount: page.pageSize, |
|||
parentId: props.folderId, |
|||
sorting, |
|||
skipCount: (page.currentPage - 1) * page.pageSize, |
|||
}); |
|||
}, |
|||
}, |
|||
autoLoad: false, |
|||
response: { |
|||
total: 'totalCount', |
|||
list: 'items', |
|||
}, |
|||
}, |
|||
sortConfig: { |
|||
remote: true, |
|||
}, |
|||
toolbarConfig: { |
|||
custom: true, |
|||
export: false, |
|||
refresh: false, |
|||
zoom: true, |
|||
}, |
|||
}; |
|||
|
|||
const gridEvents: VxeGridListeners<BlobDto> = { |
|||
checkboxAll: (params) => { |
|||
selectedKeys.value = params.records.map((record) => record.name); |
|||
}, |
|||
checkboxChange: (params) => { |
|||
selectedKeys.value = params.records.map((record) => record.name); |
|||
}, |
|||
sortChange: () => { |
|||
gridApi.query(); |
|||
}, |
|||
}; |
|||
|
|||
const [BlobFileUploadModal, uploadModalApi] = useVbenModal({ |
|||
connectedComponent: defineAsyncComponent( |
|||
() => import('./BlobFileUploadModal.vue'), |
|||
), |
|||
}); |
|||
const [BlobFilePreviewModal, previewModalApi] = useVbenModal({ |
|||
connectedComponent: defineAsyncComponent( |
|||
() => import('./BlobFilePreviewModal.vue'), |
|||
), |
|||
}); |
|||
|
|||
const [Grid, gridApi] = useVbenVxeGrid({ |
|||
gridEvents, |
|||
gridOptions, |
|||
}); |
|||
|
|||
function onUpload() { |
|||
uploadModalApi.setData({ |
|||
containerId: props.containerId, |
|||
folderId: props.folderId, |
|||
}); |
|||
uploadModalApi.open(); |
|||
} |
|||
|
|||
function onDownload(row: BlobDto) { |
|||
Modal.confirm({ |
|||
centered: true, |
|||
content: $t('BlobManagement.BlobWellDownloadMessage', [row.name]), |
|||
onCancel: () => {}, |
|||
onOk: () => { |
|||
downloadFileFromUrl({ |
|||
fileName: row.name, |
|||
source: `/api/blob-management/blobs/uid/${row.id}/content`, |
|||
}); |
|||
}, |
|||
title: $t('AbpUi.AreYouSure'), |
|||
}); |
|||
} |
|||
|
|||
function onPreview(row: BlobDto) { |
|||
previewModalApi.setData(row); |
|||
previewModalApi.open(); |
|||
} |
|||
|
|||
function onDelete(row: BlobDto) { |
|||
Modal.confirm({ |
|||
centered: true, |
|||
content: $t('AbpUi.ItemWillBeDeletedMessageWithFormat', [row.name]), |
|||
onCancel: () => {}, |
|||
onOk: async () => { |
|||
await deleteApi(row.id); |
|||
message.success($t('AbpUi.DeletedSuccessfully')); |
|||
await gridApi.query(); |
|||
}, |
|||
title: $t('AbpUi.AreYouSure'), |
|||
}); |
|||
} |
|||
|
|||
watch( |
|||
() => props.containerId, |
|||
(containerId) => { |
|||
nextTick(() => { |
|||
gridApi.setGridOptions({ |
|||
toolbarConfig: { |
|||
refresh: !isNullOrWhiteSpace(containerId), |
|||
}, |
|||
}); |
|||
if (!isNullOrWhiteSpace(containerId)) { |
|||
gridApi.query(); |
|||
} |
|||
}); |
|||
}, |
|||
); |
|||
|
|||
watch( |
|||
() => props.folderId, |
|||
(_) => { |
|||
nextTick(() => { |
|||
if (!isNullOrWhiteSpace(props.containerId)) { |
|||
gridApi.query(); |
|||
} |
|||
}); |
|||
}, |
|||
); |
|||
</script> |
|||
|
|||
<template> |
|||
<Card :title="$t('BlobManagement.Blobs:Files')"> |
|||
<Grid> |
|||
<template #toolbar-tools> |
|||
<Button |
|||
v-if="props.containerId" |
|||
:icon="h(UploadOutlined)" |
|||
type="primary" |
|||
@click="onUpload" |
|||
> |
|||
{{ $t('BlobManagement.Blobs:UploadFile') }} |
|||
</Button> |
|||
</template> |
|||
<template #action="{ row }"> |
|||
<div class="flex flex-row"> |
|||
<Button |
|||
v-if="row.type === BlobType.File" |
|||
:icon="h(EyeOutlined)" |
|||
block |
|||
type="link" |
|||
@click="onPreview(row)" |
|||
> |
|||
{{ $t('BlobManagement.Blobs:Preview') }} |
|||
</Button> |
|||
<Button |
|||
v-if="row.type === BlobType.File" |
|||
:icon="h(DownloadOutlined)" |
|||
block |
|||
type="link" |
|||
@click="onDownload(row)" |
|||
> |
|||
{{ $t('BlobManagement.Blobs:Download') }} |
|||
</Button> |
|||
<Button |
|||
:icon="h(DeleteOutlined)" |
|||
danger |
|||
block |
|||
type="link" |
|||
@click="onDelete(row)" |
|||
> |
|||
{{ $t('AbpUi.Delete') }} |
|||
</Button> |
|||
</div> |
|||
</template> |
|||
</Grid> |
|||
</Card> |
|||
<BlobFilePreviewModal /> |
|||
<BlobFileUploadModal @file-uploaded="() => gridApi.query()" /> |
|||
</template> |
|||
|
|||
<style lang="scss" scoped></style> |
|||
@ -0,0 +1,339 @@ |
|||
<script setup lang="ts"> |
|||
import type { VxeComponentStyleType } from 'vxe-table'; |
|||
|
|||
import { h, ref, useTemplateRef, watch } from 'vue'; |
|||
import uploader from 'vue-simple-uploader'; |
|||
import 'vue-simple-uploader/dist/style.css'; |
|||
|
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
import { useRefresh } from '@vben/hooks'; |
|||
import { $t } from '@vben/locales'; |
|||
import { useAccessStore } from '@vben/stores'; |
|||
|
|||
import { isNullOrWhiteSpace } from '@abp/core'; |
|||
import { |
|||
CaretRightOutlined, |
|||
DeleteOutlined, |
|||
PauseOutlined, |
|||
} from '@ant-design/icons-vue'; |
|||
import { Button, Tag, Tooltip } from 'ant-design-vue'; |
|||
import { VxeColumn, VxeTable } from 'vxe-table'; |
|||
|
|||
const emits = defineEmits<{ |
|||
(event: 'fileUploaded', file: any): void; |
|||
}>(); |
|||
const Uploader = uploader.Uploader; |
|||
const UploaderDrop = uploader.UploaderDrop; |
|||
const UploaderList = uploader.UploaderList; |
|||
const UploaderUnsupport = uploader.UploaderUnsupport; |
|||
|
|||
interface ModalState { |
|||
containerId: string; |
|||
folderId?: string; |
|||
} |
|||
|
|||
const selectBtn = useTemplateRef<any>('selectBtn'); |
|||
const uploaderWrap = useTemplateRef<any>('uploaderWrap'); |
|||
|
|||
const { refresh } = useRefresh(); |
|||
const accessStore = useAccessStore(); |
|||
|
|||
const [Modal, modalApi] = useVbenModal({ |
|||
class: 'w-1/2', |
|||
closeOnClickModal: false, |
|||
closeOnPressEscape: false, |
|||
draggable: true, |
|||
footer: false, |
|||
onCancel: () => { |
|||
uploaderWrap.value?.uploader.cancel(); |
|||
}, |
|||
onOpenChange: (isOpen) => { |
|||
if (isOpen) { |
|||
onInit(); |
|||
} |
|||
}, |
|||
}); |
|||
|
|||
const options = ref({ |
|||
chunkRetryInterval: null, |
|||
headers: {}, |
|||
initialPaused: true, |
|||
maxChunkRetries: 3, |
|||
permanentErrors: [400, 401, 403, 404, 415, 500, 501], |
|||
processParams: (_params: any, _file: any) => {}, |
|||
processResponse: (response: any, cb: any) => { |
|||
if (!isNullOrWhiteSpace(response)) { |
|||
const error = JSON.parse(response); |
|||
if (error.code !== '0') { |
|||
cb(true, error.message); |
|||
return; |
|||
} |
|||
} |
|||
cb(null, response); |
|||
}, |
|||
successStatuses: [200, 201, 202, 204, 205], |
|||
target: '/api/blob-management/blobs/file/chunk', |
|||
testChunks: false, |
|||
withCredentials: false, |
|||
}); |
|||
|
|||
function onInit() { |
|||
const state = modalApi.getData<ModalState>(); |
|||
options.value = { |
|||
...options.value, |
|||
headers: { |
|||
Authorization: accessStore.accessToken, |
|||
}, |
|||
processParams: (params: any, file: any) => { |
|||
params.containerId = state.containerId; |
|||
params.name = file.name; |
|||
if (state.folderId) { |
|||
params.parentId = state.folderId; |
|||
} |
|||
return params; |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
function onSelectFiles() { |
|||
selectBtn.value?.click(); |
|||
} |
|||
|
|||
function onResume(file: any) { |
|||
if (file.error) { |
|||
file.errorMsg = ''; |
|||
file.retry(); |
|||
} else { |
|||
file.resume(); |
|||
} |
|||
} |
|||
|
|||
function onPause(file: any) { |
|||
file.pause(); |
|||
} |
|||
|
|||
function onDelete(file: any) { |
|||
file.cancel(); |
|||
} |
|||
|
|||
function onFileSubmitted(_: any, files: any[]) { |
|||
files.forEach((f) => { |
|||
f.paused = true; |
|||
f.completed = false; |
|||
f.progress = 0; |
|||
f.progressText = '0 %'; |
|||
}); |
|||
} |
|||
|
|||
function onUploadProgress(_: any, file: any) { |
|||
if (file._prevUploadedSize) { |
|||
const progress = Math.floor((file._prevUploadedSize / file.size) * 100); |
|||
file.progress = progress; |
|||
file.progressText = `${progress} %`; |
|||
file.completed = progress === 100; |
|||
} |
|||
} |
|||
|
|||
function onUploadError(_rootFile: any, file: any, message: any, chunk: any) { |
|||
if (chunk?.xhr?.status === 401) { |
|||
// 401 错误代码刷新页面, 由axios拦截器刷新token |
|||
refresh(); |
|||
} else { |
|||
file.errorMsg = message; |
|||
} |
|||
} |
|||
|
|||
function onUploadSuccess(_rootFile: any, file: any) { |
|||
emits('fileUploaded', file); |
|||
} |
|||
|
|||
function formatSize(size: number) { |
|||
if (size < 1024) { |
|||
return `${size.toFixed(0)} bytes`; |
|||
} else if (size < 1024 * 1024) { |
|||
return `${(size / 1024).toFixed(0)} KB`; |
|||
} else if (size < 1024 * 1024 * 1024) { |
|||
return `${(size / 1024 / 1024).toFixed(1)} MB`; |
|||
} else { |
|||
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`; |
|||
} |
|||
} |
|||
|
|||
function rowStyle(params: any): VxeComponentStyleType { |
|||
if (params.row.error) { |
|||
return { |
|||
background: '#fff1f0', |
|||
color: '#f5222d', |
|||
fontWeight: '500', |
|||
}; |
|||
} |
|||
|
|||
const progress = Math.min(100, Math.max(0, params.row.progress || 0)); |
|||
|
|||
const getGradientByProgress = (progress: number) => { |
|||
if (progress === 0) { |
|||
return { |
|||
start: 'rgba(64, 169, 255, 0.1)', |
|||
end: 'rgba(64, 169, 255, 0.05)', |
|||
}; |
|||
} |
|||
|
|||
if (progress <= 30) { |
|||
return { |
|||
start: 'rgba(24, 144, 255, 0.15)', |
|||
end: 'rgba(24, 144, 255, 0.05)', |
|||
}; |
|||
} |
|||
|
|||
if (progress <= 70) { |
|||
return { |
|||
start: 'rgba(0, 199, 228, 0.15)', |
|||
end: 'rgba(0, 199, 228, 0.05)', |
|||
}; |
|||
} |
|||
|
|||
if (progress < 100) { |
|||
return { |
|||
start: 'rgba(82, 196, 26, 0.2)', |
|||
end: 'rgba(82, 196, 26, 0.05)', |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
start: 'rgba(82, 196, 26, 0.25)', |
|||
end: 'rgba(82, 196, 26, 0.1)', |
|||
}; |
|||
}; |
|||
|
|||
const { start, end } = getGradientByProgress(progress); |
|||
|
|||
if (progress === 100 && !params.row.error) { |
|||
return { |
|||
background: |
|||
'linear-gradient(135deg, rgba(82, 196, 26, 0.15) 0%, rgba(82, 196, 26, 0.05) 100%)', |
|||
position: 'relative', |
|||
transition: 'all 0.3s ease', |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
background: `linear-gradient( |
|||
90deg, |
|||
${start} 0%, |
|||
${end} ${progress}%, |
|||
transparent ${progress}%, |
|||
transparent 100% |
|||
)`, |
|||
backgroundSize: '100% 100%', |
|||
transition: 'background 0.3s cubic-bezier(0.4, 0, 0.2, 1)', |
|||
position: 'relative', |
|||
}; |
|||
} |
|||
|
|||
watch( |
|||
() => [selectBtn.value, uploaderWrap.value], |
|||
([button, wrap]) => { |
|||
if (button && wrap) { |
|||
wrap.uploader.assignBrowse(button); |
|||
} |
|||
}, |
|||
); |
|||
</script> |
|||
|
|||
<template> |
|||
<Modal :title="$t('BlobManagement.Blobs:UploadFile')"> |
|||
<Uploader |
|||
:options="options" |
|||
ref="uploaderWrap" |
|||
@file-error="onUploadError" |
|||
@file-progress="onUploadProgress" |
|||
@file-success="onUploadSuccess" |
|||
@files-submitted="onFileSubmitted" |
|||
> |
|||
<UploaderUnsupport /> |
|||
<UploaderDrop> |
|||
<div class="flex flex-row gap-2"> |
|||
<input ref="selectBtn" style="display: none" /> |
|||
<Button type="primary" @click="onSelectFiles"> |
|||
{{ $t('BlobManagement.Blobs:SelectFile') }} |
|||
</Button> |
|||
</div> |
|||
</UploaderDrop> |
|||
<UploaderList> |
|||
<template #default="{ fileList }"> |
|||
<VxeTable :data="fileList" :row-style="rowStyle"> |
|||
<VxeColumn type="seq" width="70" /> |
|||
<VxeColumn |
|||
field="name" |
|||
:title="$t('BlobManagement.DisplayName:Name')" |
|||
/> |
|||
<VxeColumn |
|||
field="size" |
|||
:title="$t('BlobManagement.DisplayName:Size')" |
|||
width="100" |
|||
> |
|||
<template #default="{ row }"> |
|||
<span>{{ formatSize(row.size) }}</span> |
|||
</template> |
|||
</VxeColumn> |
|||
<VxeColumn |
|||
field="status" |
|||
:title="$t('BlobManagement.DisplayName:UploadStatus')" |
|||
width="180" |
|||
> |
|||
<template #default="{ row }"> |
|||
<Tooltip v-if="row.error" :title="row.errorMsg"> |
|||
<Tag color="red"> |
|||
{{ $t('BlobManagement.UploadStatus:Error') }} |
|||
</Tag> |
|||
</Tooltip> |
|||
<Tag v-else-if="row.paused" color="orange"> |
|||
{{ $t('BlobManagement.UploadStatus:Pause') }} |
|||
</Tag> |
|||
<Tag v-else-if="row.completed" color="green"> |
|||
{{ $t('BlobManagement.UploadStatus:Completed') }} |
|||
</Tag> |
|||
<span v-else>{{ |
|||
`${row.progressText} ${formatSize(row.averageSpeed)}/s` |
|||
}}</span> |
|||
</template> |
|||
</VxeColumn> |
|||
<VxeColumn |
|||
fixed="right" |
|||
field="action" |
|||
:title="$t('AbpUi.Actions')" |
|||
width="100" |
|||
> |
|||
<template #default="{ row }"> |
|||
<div class="flex flex-row"> |
|||
<div v-if="!row.completed"> |
|||
<Button |
|||
v-if="row.paused || row.error" |
|||
:icon="h(CaretRightOutlined)" |
|||
@click="onResume(row)" |
|||
type="link" |
|||
/> |
|||
<Button |
|||
v-else |
|||
:icon="h(PauseOutlined)" |
|||
@click="onPause(row)" |
|||
type="link" |
|||
/> |
|||
</div> |
|||
<Button |
|||
:icon="h(DeleteOutlined)" |
|||
@click="onDelete(row)" |
|||
type="link" |
|||
danger |
|||
/> |
|||
</div> |
|||
</template> |
|||
</VxeColumn> |
|||
</VxeTable> |
|||
</template> |
|||
</UploaderList> |
|||
</Uploader> |
|||
</Modal> |
|||
</template> |
|||
|
|||
<style scoped></style> |
|||
@ -0,0 +1,65 @@ |
|||
<script setup lang="ts"> |
|||
import type { BlobDto } from '../../types/blobs'; |
|||
|
|||
import { useVbenForm, useVbenModal } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { message } from 'ant-design-vue'; |
|||
|
|||
import { useBlobsApi } from '../../api/useBlobsApi'; |
|||
|
|||
interface ModalState { |
|||
containerId: string; |
|||
parentId?: string; |
|||
} |
|||
|
|||
const emits = defineEmits<{ |
|||
(event: 'change', data: BlobDto): void; |
|||
}>(); |
|||
|
|||
const { createFolderApi } = useBlobsApi(); |
|||
|
|||
const [Form, formApi] = useVbenForm({ |
|||
handleSubmit: onSubmit, |
|||
schema: [ |
|||
{ |
|||
component: 'Input', |
|||
fieldName: 'name', |
|||
label: $t('BlobManagement.DisplayName:Name'), |
|||
rules: 'required', |
|||
}, |
|||
], |
|||
showDefaultActions: false, |
|||
}); |
|||
|
|||
const [Modal, modalApi] = useVbenModal({ |
|||
onConfirm: async () => { |
|||
await formApi.validateAndSubmitForm(); |
|||
}, |
|||
}); |
|||
|
|||
async function onSubmit(values: Record<string, any>) { |
|||
try { |
|||
const state = modalApi.getData<ModalState>(); |
|||
modalApi.setState({ submitting: true }); |
|||
const dto = await createFolderApi({ |
|||
containerId: state.containerId, |
|||
parentId: state.parentId, |
|||
name: values.name, |
|||
}); |
|||
message.success($t('AbpUi.SavedSuccessfully')); |
|||
emits('change', dto); |
|||
modalApi.close(); |
|||
} finally { |
|||
modalApi.setState({ submitting: false }); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Modal :title="$t('BlobManagement.Blobs:CreateFolder')"> |
|||
<Form /> |
|||
</Modal> |
|||
</template> |
|||
|
|||
<style scoped></style> |
|||
@ -0,0 +1,283 @@ |
|||
<script setup lang="ts"> |
|||
import type { EventDataNode, Key } from 'ant-design-vue/es/vc-tree/interface'; |
|||
import type { TreeProps } from 'ant-design-vue/es/vc-tree/props'; |
|||
|
|||
import type { BlobDto } from '../../types'; |
|||
import type { BlobContainerDto } from '../../types/containers'; |
|||
|
|||
import { defineAsyncComponent, nextTick, onMounted, ref, watch } from 'vue'; |
|||
|
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { Button, Card, DirectoryTree, Empty, Select } from 'ant-design-vue'; |
|||
|
|||
import { useBlobContainersApi } from '../../api/useBlobContainersApi'; |
|||
import { useBlobsApi } from '../../api/useBlobsApi'; |
|||
|
|||
const emits = defineEmits<{ |
|||
(event: 'containerChange', id: string): void; |
|||
(event: 'folderChange', id?: string): void; |
|||
}>(); |
|||
|
|||
interface Folder { |
|||
children?: Folder[]; |
|||
id: string; |
|||
isLeaf?: boolean; |
|||
key: string; |
|||
name: string; |
|||
path: string; |
|||
title: string; |
|||
} |
|||
|
|||
const { getPagedListApi: getContainersApi } = useBlobContainersApi(); |
|||
const { getFolderPagedListApi: getFoldersApi } = useBlobsApi(); |
|||
|
|||
const rootFolder: Folder = { |
|||
id: '', |
|||
isLeaf: false, |
|||
key: './', |
|||
name: './', |
|||
path: '/', |
|||
title: $t('BlobManagement.Blobs:RootFolder'), |
|||
children: [], |
|||
}; |
|||
|
|||
const containerId = ref<string>(''); |
|||
const loadedFolders = ref<string[]>([]); |
|||
const expandedFolders = ref<string[]>([]); |
|||
const selectedFolders = ref<string[]>([]); |
|||
const containers = ref<BlobContainerDto[]>([]); |
|||
const folders = ref<Folder[]>([ |
|||
{ |
|||
...rootFolder, |
|||
}, |
|||
]); |
|||
|
|||
const isLoadingContainers = ref(false); |
|||
const isLoadingFolders = ref(false); |
|||
|
|||
const [BlobFolderModal, modalApi] = useVbenModal({ |
|||
connectedComponent: defineAsyncComponent( |
|||
() => import('./BlobFolderModal.vue'), |
|||
), |
|||
}); |
|||
|
|||
const clearTreeState = () => { |
|||
folders.value = [ |
|||
{ |
|||
...rootFolder, |
|||
}, |
|||
]; |
|||
loadedFolders.value = []; |
|||
expandedFolders.value = []; |
|||
selectedFolders.value = []; |
|||
isLoadingFolders.value = false; |
|||
}; |
|||
|
|||
const onLoadChildFolders: TreeProps['loadData'] = async (treeNode) => { |
|||
if (isLoadingContainers.value || !containerId.value) { |
|||
return; |
|||
} |
|||
|
|||
const nodeKey = treeNode.key?.toString(); |
|||
if (!nodeKey) return; |
|||
|
|||
if (loadedFolders.value.includes(nodeKey)) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isLoadingFolders.value = true; |
|||
const children = await getFolders(containerId.value, treeNode.dataRef?.id); |
|||
|
|||
if (treeNode.dataRef && containerId.value) { |
|||
treeNode.dataRef.children = children; |
|||
folders.value = [...folders.value]; |
|||
loadedFolders.value = [...loadedFolders.value, nodeKey]; |
|||
} |
|||
} catch (error) { |
|||
console.error('Failed to load folders:', error); |
|||
if (treeNode.dataRef) { |
|||
treeNode.dataRef.children = []; |
|||
} |
|||
} finally { |
|||
isLoadingFolders.value = false; |
|||
} |
|||
}; |
|||
|
|||
async function onInit() { |
|||
try { |
|||
isLoadingContainers.value = true; |
|||
const { items } = await getContainersApi({ |
|||
maxResultCount: 1000, |
|||
}); |
|||
containers.value = items; |
|||
} catch (error) { |
|||
console.error('Failed to load containers:', error); |
|||
} finally { |
|||
isLoadingContainers.value = false; |
|||
} |
|||
} |
|||
|
|||
async function getFolders(containerId: string, parentId?: string) { |
|||
if (!containerId) { |
|||
return []; |
|||
} |
|||
|
|||
try { |
|||
const { items } = await getFoldersApi({ |
|||
containerId, |
|||
parentId, |
|||
}); |
|||
return items.map((folder) => { |
|||
return { |
|||
isLeaf: false, |
|||
id: folder.id, |
|||
key: folder.id, |
|||
name: folder.name, |
|||
path: folder.path, |
|||
title: folder.name, |
|||
children: [], |
|||
}; |
|||
}); |
|||
} catch (error) { |
|||
console.error('Failed to get folders:', error); |
|||
return []; |
|||
} |
|||
} |
|||
|
|||
function onFolderExpand( |
|||
_: Key[], |
|||
info: { |
|||
expanded: boolean; |
|||
node: EventDataNode; |
|||
}, |
|||
) { |
|||
if (!info.expanded) { |
|||
const keys = [...loadedFolders.value]; |
|||
const findIndex = keys.lastIndexOf(info.node?.key?.toString()); |
|||
if (findIndex !== -1) { |
|||
keys.splice(findIndex, 1); |
|||
loadedFolders.value = keys; |
|||
} |
|||
} |
|||
} |
|||
|
|||
function onFolderChange(folderId?: string) { |
|||
emits('folderChange', folderId); |
|||
} |
|||
|
|||
async function onContainerChange(newContainerId: string) { |
|||
clearTreeState(); |
|||
emits('containerChange', newContainerId); |
|||
await nextTick(); |
|||
} |
|||
|
|||
function onCreate() { |
|||
if (!containerId.value) { |
|||
return; |
|||
} |
|||
modalApi.setData({ |
|||
containerId: containerId.value, |
|||
parentId: selectedFolders.value[0], |
|||
}); |
|||
modalApi.open(); |
|||
} |
|||
|
|||
function onFolderCreated(blob: BlobDto) { |
|||
const parentId = selectedFolders.value[0]; |
|||
if (parentId) { |
|||
const index = loadedFolders.value.indexOf(parentId); |
|||
if (index !== -1) { |
|||
loadedFolders.value.splice(index, 1); |
|||
} |
|||
|
|||
if (expandedFolders.value.includes(parentId)) { |
|||
const parentNode = findNodeById(folders.value, parentId); |
|||
if (parentNode && parentNode.children) { |
|||
parentNode.children = []; |
|||
folders.value = [...folders.value]; |
|||
} |
|||
} |
|||
} |
|||
|
|||
emits('folderChange', blob.id); |
|||
} |
|||
|
|||
function findNodeById(nodes: Folder[], id: string): Folder | null { |
|||
for (const node of nodes) { |
|||
if (node.id === id) { |
|||
return node; |
|||
} |
|||
if (node.children) { |
|||
const found = findNodeById(node.children, id); |
|||
if (found) return found; |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
watch(containerId, (newVal, oldVal) => { |
|||
if (newVal !== oldVal && newVal) { |
|||
onContainerChange(newVal); |
|||
} |
|||
}); |
|||
|
|||
onMounted(onInit); |
|||
</script> |
|||
|
|||
<template> |
|||
<Card :title="$t('BlobManagement.Blobs:Folder')" class="h-full"> |
|||
<div class="flex h-full flex-col gap-2"> |
|||
<Select |
|||
:placeholder="$t('BlobManagement.Blobs:SelectContainer')" |
|||
:options="containers" |
|||
:field-names="{ label: 'name', value: 'id' }" |
|||
:loading="isLoadingContainers" |
|||
v-model:value="containerId" |
|||
@change="(e) => onContainerChange(e!.toString())" |
|||
/> |
|||
<Button |
|||
v-if="containerId" |
|||
block |
|||
type="primary" |
|||
ghost |
|||
:disabled="isLoadingFolders" |
|||
@click="onCreate" |
|||
> |
|||
{{ $t('BlobManagement.Blobs:CreateFolder') }} |
|||
</Button> |
|||
<div class="h-[calc(100%-100px)] overflow-auto"> |
|||
<!-- 添加 key 属性强制重新渲染树组件 --> |
|||
<DirectoryTree |
|||
v-if="containerId" |
|||
:key="containerId" |
|||
block-node |
|||
:expand-action="false" |
|||
v-model:expanded-keys="expandedFolders" |
|||
v-model:selected-keys="selectedFolders" |
|||
:loaded-keys="loadedFolders" |
|||
:tree-data="folders" |
|||
:load-data="onLoadChildFolders" |
|||
@select="(_, info) => onFolderChange(info.node?.dataRef?.id)" |
|||
@expand="onFolderExpand" |
|||
/> |
|||
<Empty v-else /> |
|||
</div> |
|||
</div> |
|||
</Card> |
|||
<BlobFolderModal @change="onFolderCreated" /> |
|||
</template> |
|||
|
|||
<style scoped lang="scss"> |
|||
:deep(.ant-tree) { |
|||
.ant-tree-title { |
|||
white-space: normal; |
|||
} |
|||
} |
|||
|
|||
:deep(.ant-card-body) { |
|||
height: calc(100% - 56px); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,45 @@ |
|||
<script setup lang="ts"> |
|||
import { reactive, ref } from 'vue'; |
|||
|
|||
import { ColPage } from '@vben/common-ui'; |
|||
|
|||
import BlobFileTable from './BlobFileTable.vue'; |
|||
import BlobFolderTree from './BlobFolderTree.vue'; |
|||
|
|||
const containerId = ref<string>(''); |
|||
const folderId = ref<string>(); |
|||
|
|||
const props = reactive({ |
|||
leftCollapsible: false, |
|||
leftMaxWidth: 30, |
|||
leftMinWidth: 20, |
|||
leftWidth: 30, |
|||
resizable: true, |
|||
rightWidth: 70, |
|||
splitHandle: true, |
|||
splitLine: true, |
|||
}); |
|||
|
|||
function onContainerChange(val: string) { |
|||
containerId.value = val; |
|||
folderId.value = undefined; |
|||
} |
|||
|
|||
function onFolderChange(val?: string) { |
|||
folderId.value = val; |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<ColPage auto-content-height v-bind="props"> |
|||
<template #left> |
|||
<BlobFolderTree |
|||
@container-change="onContainerChange" |
|||
@folder-change="onFolderChange" |
|||
/> |
|||
</template> |
|||
<BlobFileTable :container-id="containerId" :folder-id="folderId" /> |
|||
</ColPage> |
|||
</template> |
|||
|
|||
<style scoped></style> |
|||
@ -0,0 +1,59 @@ |
|||
<script setup lang="ts"> |
|||
import type { BlobContainerDto } from '../../types/containers'; |
|||
|
|||
import { useVbenForm, useVbenModal } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { message } from 'ant-design-vue'; |
|||
|
|||
import { useBlobContainersApi } from '../../api/useBlobContainersApi'; |
|||
|
|||
defineOptions({ |
|||
name: 'BlobContainerModal', |
|||
}); |
|||
|
|||
const emits = defineEmits<{ |
|||
(event: 'change', data: BlobContainerDto): void; |
|||
}>(); |
|||
|
|||
const { cancel, createApi } = useBlobContainersApi(); |
|||
|
|||
const [Form, formApi] = useVbenForm({ |
|||
handleSubmit: onSubmit, |
|||
schema: [ |
|||
{ |
|||
component: 'Input', |
|||
fieldName: 'name', |
|||
label: $t('BlobManagement.DisplayName:Name'), |
|||
rules: 'required', |
|||
}, |
|||
], |
|||
showDefaultActions: false, |
|||
}); |
|||
const [Modal, modalApi] = useVbenModal({ |
|||
onCancel: cancel, |
|||
onConfirm: async () => { |
|||
await formApi.validateAndSubmitForm(); |
|||
}, |
|||
}); |
|||
|
|||
async function onSubmit(values: Record<string, any>) { |
|||
try { |
|||
modalApi.setState({ submitting: true }); |
|||
const dto = await createApi({ name: values.name }); |
|||
message.success($t('AbpUi.SavedSuccessfully')); |
|||
emits('change', dto); |
|||
modalApi.close(); |
|||
} finally { |
|||
modalApi.setState({ submitting: false }); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Modal :title="$t('BlobManagement.BlobContainers:Create')"> |
|||
<Form /> |
|||
</Modal> |
|||
</template> |
|||
|
|||
<style scoped></style> |
|||
@ -0,0 +1,189 @@ |
|||
<script setup lang="ts"> |
|||
import type { VxeGridListeners, VxeGridProps } from '@abp/ui'; |
|||
|
|||
import type { VbenFormProps } from '@vben/common-ui'; |
|||
|
|||
import type { BlobContainerDto } from '../../types/containers'; |
|||
|
|||
import { defineAsyncComponent, h, ref } from 'vue'; |
|||
|
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { formatToDateTime } from '@abp/core'; |
|||
import { useVbenVxeGrid } from '@abp/ui'; |
|||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'; |
|||
import { Button, message, Modal } from 'ant-design-vue'; |
|||
|
|||
import { useBlobContainersApi } from '../../api/useBlobContainersApi'; |
|||
|
|||
defineOptions({ |
|||
name: 'BlobContainerTable', |
|||
}); |
|||
|
|||
const { cancel, deleteApi, getPagedListApi } = useBlobContainersApi(); |
|||
|
|||
const selectedKeys = ref<string[]>([]); |
|||
|
|||
const formOptions: VbenFormProps = { |
|||
collapsed: true, |
|||
collapsedRows: 2, |
|||
commonConfig: { |
|||
componentProps: { |
|||
class: 'w-full', |
|||
}, |
|||
}, |
|||
schema: [ |
|||
{ |
|||
component: 'Input', |
|||
componentProps: { |
|||
allowClear: true, |
|||
}, |
|||
fieldName: 'filter', |
|||
formItemClass: 'col-span-3 items-baseline', |
|||
label: $t('AbpUi.Search'), |
|||
}, |
|||
], |
|||
// 控制表单是否显示折叠按钮 |
|||
showCollapseButton: true, |
|||
// 按下回车时是否提交表单 |
|||
submitOnEnter: true, |
|||
}; |
|||
|
|||
const gridOptions: VxeGridProps<BlobContainerDto> = { |
|||
columns: [ |
|||
{ |
|||
align: 'left', |
|||
field: 'name', |
|||
minWidth: 150, |
|||
sortable: true, |
|||
title: $t('BlobManagement.DisplayName:Name'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'creationTime', |
|||
formatter({ cellValue }) { |
|||
return cellValue ? formatToDateTime(cellValue) : ''; |
|||
}, |
|||
minWidth: 120, |
|||
sortable: true, |
|||
title: $t('BlobManagement.DisplayName:CreationTime'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'lastModificationTime', |
|||
formatter({ cellValue }) { |
|||
return cellValue ? formatToDateTime(cellValue) : ''; |
|||
}, |
|||
minWidth: 120, |
|||
sortable: true, |
|||
title: $t('BlobManagement.DisplayName:LastModificationTime'), |
|||
}, |
|||
{ |
|||
field: 'action', |
|||
fixed: 'right', |
|||
slots: { default: 'action' }, |
|||
title: $t('AbpUi.Actions'), |
|||
width: 150, |
|||
}, |
|||
], |
|||
exportConfig: {}, |
|||
keepSource: true, |
|||
proxyConfig: { |
|||
ajax: { |
|||
query: async ({ page, sort }, formValues) => { |
|||
const sorting = sort.order ? `${sort.field} ${sort.order}` : undefined; |
|||
return await getPagedListApi({ |
|||
sorting, |
|||
maxResultCount: page.pageSize, |
|||
skipCount: (page.currentPage - 1) * page.pageSize, |
|||
...formValues, |
|||
}); |
|||
}, |
|||
}, |
|||
response: { |
|||
total: 'totalCount', |
|||
list: 'items', |
|||
}, |
|||
}, |
|||
toolbarConfig: { |
|||
custom: true, |
|||
export: true, |
|||
refresh: { |
|||
code: 'query', |
|||
}, |
|||
zoom: true, |
|||
}, |
|||
}; |
|||
|
|||
const gridEvents: VxeGridListeners<BlobContainerDto> = { |
|||
checkboxAll: (params) => { |
|||
selectedKeys.value = params.records.map((record) => record.name); |
|||
}, |
|||
checkboxChange: (params) => { |
|||
selectedKeys.value = params.records.map((record) => record.name); |
|||
}, |
|||
sortChange: () => { |
|||
gridApi.query(); |
|||
}, |
|||
}; |
|||
|
|||
const [Grid, gridApi] = useVbenVxeGrid({ |
|||
formOptions, |
|||
gridEvents, |
|||
gridOptions, |
|||
}); |
|||
|
|||
const [BlobContainerModal, modalApi] = useVbenModal({ |
|||
connectedComponent: defineAsyncComponent( |
|||
() => import('./BlobContainerModal.vue'), |
|||
), |
|||
}); |
|||
|
|||
function onCreate() { |
|||
modalApi.setData({}); |
|||
modalApi.open(); |
|||
} |
|||
|
|||
function onDelete(row: BlobContainerDto) { |
|||
Modal.confirm({ |
|||
centered: true, |
|||
content: $t('AbpUi.ItemWillBeDeletedMessageWithFormat', [row.name]), |
|||
onCancel: () => { |
|||
cancel(); |
|||
}, |
|||
onOk: async () => { |
|||
await deleteApi(row.id); |
|||
message.success($t('AbpUi.DeletedSuccessfully')); |
|||
await gridApi.query(); |
|||
}, |
|||
title: $t('AbpUi.AreYouSure'), |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Grid :table-title="$t('BlobManagement.BlobContainers')"> |
|||
<template #toolbar-tools> |
|||
<Button :icon="h(PlusOutlined)" type="primary" @click="onCreate"> |
|||
{{ $t('BlobManagement.BlobContainers:Create') }} |
|||
</Button> |
|||
</template> |
|||
<template #action="{ row }"> |
|||
<div class="flex flex-row"> |
|||
<Button |
|||
:icon="h(DeleteOutlined)" |
|||
danger |
|||
block |
|||
type="link" |
|||
@click="onDelete(row)" |
|||
> |
|||
{{ $t('AbpUi.Delete') }} |
|||
</Button> |
|||
</div> |
|||
</template> |
|||
</Grid> |
|||
<BlobContainerModal @change="() => gridApi.query()" /> |
|||
</template> |
|||
|
|||
<style lang="scss" scoped></style> |
|||
@ -0,0 +1,2 @@ |
|||
export { default as BlobViewPage } from './blobs/BlobViewPage.vue'; |
|||
export { default as BlobContainerTable } from './containers/BlobContainerTable.vue'; |
|||
@ -0,0 +1,57 @@ |
|||
declare module 'vue-simple-uploader'; |
|||
|
|||
declare module '@vue-office/docx' { |
|||
import { DefineComponent } from 'vue'; |
|||
|
|||
const component: DefineComponent<any>; |
|||
export default component; |
|||
} |
|||
|
|||
declare module '@vue-office/docx/lib/v3/index.js' { |
|||
import { DefineComponent } from 'vue'; |
|||
|
|||
const component: DefineComponent<any>; |
|||
export default component; |
|||
} |
|||
|
|||
declare module '@vue-office/excel' { |
|||
import { DefineComponent } from 'vue'; |
|||
|
|||
const component: DefineComponent<any>; |
|||
export default component; |
|||
} |
|||
|
|||
declare module '@vue-office/excel/lib/v3/index.js' { |
|||
import { DefineComponent } from 'vue'; |
|||
|
|||
const component: DefineComponent<any>; |
|||
export default component; |
|||
} |
|||
|
|||
declare module '@vue-office/pdf' { |
|||
import { DefineComponent } from 'vue'; |
|||
|
|||
const component: DefineComponent<any>; |
|||
export default component; |
|||
} |
|||
|
|||
declare module '@vue-office/pdf/lib/v3/index.js' { |
|||
import { DefineComponent } from 'vue'; |
|||
|
|||
const component: DefineComponent<any>; |
|||
export default component; |
|||
} |
|||
|
|||
declare module '@vue-office/pptx' { |
|||
import { DefineComponent } from 'vue'; |
|||
|
|||
const component: DefineComponent<any>; |
|||
export default component; |
|||
} |
|||
|
|||
declare module '@vue-office/pptx/lib/v3/index.js' { |
|||
import { DefineComponent } from 'vue'; |
|||
|
|||
const component: DefineComponent<any>; |
|||
export default component; |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
export * from './api'; |
|||
export * from './components'; |
|||
export * from './types'; |
|||
@ -0,0 +1,78 @@ |
|||
import type { |
|||
ExtensibleAuditedEntityDto, |
|||
PagedAndSortedResultRequestDto, |
|||
} from '@abp/core'; |
|||
|
|||
export enum BlobType { |
|||
File = 1, |
|||
Folder = 0, |
|||
} |
|||
|
|||
interface BlobDto extends ExtensibleAuditedEntityDto<string> { |
|||
contentType?: string; |
|||
downloadCount: number; |
|||
expirationTime?: Date; |
|||
name: string; |
|||
path: string; |
|||
size: number; |
|||
type: BlobType; |
|||
} |
|||
|
|||
interface BlobFolderCreateBaseDto { |
|||
name: string; |
|||
parentId?: string; |
|||
} |
|||
|
|||
interface BlobFileCreateBaseDto { |
|||
compareMd5?: string; |
|||
file: Blob | File; |
|||
name: string; |
|||
parentId?: string; |
|||
} |
|||
|
|||
interface BlobFileChunkCreateBaseDto extends BlobFileCreateBaseDto { |
|||
chunkNumber: number; |
|||
chunkSize: number; |
|||
currentChunkSize: number; |
|||
totalChunks: number; |
|||
} |
|||
|
|||
interface BlobFileCreateDto extends BlobFileCreateBaseDto { |
|||
containerId: string; |
|||
} |
|||
|
|||
interface BlobFileChunkCreateDto extends BlobFileChunkCreateBaseDto { |
|||
containerId: string; |
|||
} |
|||
|
|||
interface BlobFolderCreateDto extends BlobFolderCreateBaseDto { |
|||
containerId: string; |
|||
} |
|||
|
|||
interface BlobDownloadByNameInput { |
|||
blobName: string; |
|||
containerName: string; |
|||
} |
|||
|
|||
interface BlobGetPagedListInputBase extends PagedAndSortedResultRequestDto { |
|||
contentType?: string; |
|||
parentId?: string; |
|||
type?: BlobType; |
|||
} |
|||
|
|||
interface BlobGetPagedListInput extends BlobGetPagedListInputBase { |
|||
containerId: string; |
|||
} |
|||
|
|||
export type { |
|||
BlobDownloadByNameInput, |
|||
BlobDto, |
|||
BlobFileChunkCreateBaseDto, |
|||
BlobFileChunkCreateDto, |
|||
BlobFileCreateBaseDto, |
|||
BlobFileCreateDto, |
|||
BlobFolderCreateBaseDto, |
|||
BlobFolderCreateDto, |
|||
BlobGetPagedListInput, |
|||
BlobGetPagedListInputBase, |
|||
}; |
|||
@ -0,0 +1,24 @@ |
|||
import type { |
|||
ExtensibleAuditedEntityDto, |
|||
IHasConcurrencyStamp, |
|||
PagedAndSortedResultRequestDto, |
|||
} from '@abp/core'; |
|||
|
|||
interface BlobContainerDto |
|||
extends ExtensibleAuditedEntityDto<string>, IHasConcurrencyStamp { |
|||
name: string; |
|||
} |
|||
|
|||
interface BlobContainerCreateDto { |
|||
name: string; |
|||
} |
|||
|
|||
interface BlobContainerGetPagedListInput extends PagedAndSortedResultRequestDto { |
|||
filter?: string; |
|||
} |
|||
|
|||
export type { |
|||
BlobContainerCreateDto, |
|||
BlobContainerDto, |
|||
BlobContainerGetPagedListInput, |
|||
}; |
|||
@ -0,0 +1,2 @@ |
|||
export * from './blobs'; |
|||
export * from './containers'; |
|||
@ -0,0 +1,6 @@ |
|||
{ |
|||
"$schema": "https://json.schemastore.org/tsconfig", |
|||
"extends": "@vben/tsconfig/web.json", |
|||
"include": ["src"], |
|||
"exclude": ["node_modules"] |
|||
} |
|||
Loading…
Reference in new issue