feat(web): add file manager views with chunked upload and file picker
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
124
web/src/views/Files/FilePicker.vue
Normal file
124
web/src/views/Files/FilePicker.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { listFiles, listFolders } from '@/api/files'
|
||||||
|
import type { FileResponse, FolderResponse } from '@/types/files'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
multiple?: boolean
|
||||||
|
max?: number
|
||||||
|
}>(), {
|
||||||
|
multiple: true,
|
||||||
|
max: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'select': [files: FileResponse[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentFolderId = ref<number | null>(null)
|
||||||
|
const breadcrumbs = ref<Array<{ id: number | null; name: string }>>([{ id: null, name: '全部文件' }])
|
||||||
|
const folders = ref<FolderResponse[]>([])
|
||||||
|
const files = ref<FileResponse[]>([])
|
||||||
|
const selectedFileIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val: boolean) => emit('update:modelValue', val),
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val) {
|
||||||
|
currentFolderId.value = null
|
||||||
|
breadcrumbs.value = [{ id: null, name: '全部文件' }]
|
||||||
|
selectedFileIds.value = []
|
||||||
|
fetchContent()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchContent = async () => {
|
||||||
|
try {
|
||||||
|
const [foldersResp, filesResp] = await Promise.all([
|
||||||
|
listFolders({ parent_id: currentFolderId.value ?? undefined }),
|
||||||
|
listFiles({ folder_id: currentFolderId.value ?? undefined, page_size: 100 }),
|
||||||
|
])
|
||||||
|
folders.value = foldersResp.data || []
|
||||||
|
files.value = filesResp.data?.files || []
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToFolder = (folder: FolderResponse) => {
|
||||||
|
currentFolderId.value = folder.id
|
||||||
|
breadcrumbs.value.push({ id: folder.id, name: folder.name })
|
||||||
|
fetchContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToBreadcrumb = (index: number) => {
|
||||||
|
const target = breadcrumbs.value[index]
|
||||||
|
currentFolderId.value = target.id
|
||||||
|
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
|
||||||
|
fetchContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const selected = files.value.filter(f => selectedFileIds.value.includes(f.id))
|
||||||
|
emit('select', selected)
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="dialogVisible" title="选择文件" width="700px" @close="dialogVisible = false">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item
|
||||||
|
v-for="(crumb, index) in breadcrumbs"
|
||||||
|
:key="index"
|
||||||
|
@click="navigateToBreadcrumb(index)"
|
||||||
|
>
|
||||||
|
<span style="cursor: pointer">{{ crumb.name }}</span>
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
|
||||||
|
<!-- Folders -->
|
||||||
|
<div v-if="folders.length" style="margin-top: 12px">
|
||||||
|
<div
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
style="padding: 8px; cursor: pointer; border-bottom: 1px solid #f0f0f0"
|
||||||
|
@click="navigateToFolder(folder)"
|
||||||
|
>
|
||||||
|
📁 {{ folder.name }} ({{ folder.file_count }} 文件)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Files with checkboxes -->
|
||||||
|
<div v-if="files.length" style="margin-top: 12px; max-height: 400px; overflow-y: auto">
|
||||||
|
<el-checkbox-group v-model="selectedFileIds">
|
||||||
|
<div v-for="file in files" :key="file.id" style="padding: 6px 0">
|
||||||
|
<el-checkbox :label="file.id" :value="file.id">
|
||||||
|
{{ file.name }}
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-else-if="!folders.length" description="暂无文件" />
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<template #footer>
|
||||||
|
<span>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleConfirm">
|
||||||
|
确认 ({{ selectedFileIds.length }})
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
254
web/src/views/Files/List.vue
Normal file
254
web/src/views/Files/List.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { listFiles, deleteFile, downloadFileUrl, listFolders, createFolder, deleteFolder } from '@/api/files'
|
||||||
|
import UploadButton from './UploadButton.vue'
|
||||||
|
import type { FileResponse, FolderResponse } from '@/types/files'
|
||||||
|
|
||||||
|
const files = ref<FileResponse[]>([])
|
||||||
|
const folders = ref<FolderResponse[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const loading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const breadcrumbs = ref<Array<{ id: number | null; name: string }>>([{ id: null, name: '全部文件' }])
|
||||||
|
const currentFolderId = computed(() => {
|
||||||
|
const last = breadcrumbs.value[breadcrumbs.value.length - 1]
|
||||||
|
return last?.id ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCreateFolderDialog = ref(false)
|
||||||
|
const newFolderName = ref('')
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||||
|
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFiles = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const isSearching = searchQuery.value.trim() !== ''
|
||||||
|
const params: { folder_id?: number; page?: number; page_size?: number; search?: string } = {
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
}
|
||||||
|
if (isSearching) {
|
||||||
|
params.search = searchQuery.value.trim()
|
||||||
|
} else {
|
||||||
|
params.folder_id = currentFolderId.value ?? undefined
|
||||||
|
}
|
||||||
|
const resp = await listFiles(params)
|
||||||
|
files.value = resp.data?.files || []
|
||||||
|
total.value = resp.data?.total || 0
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFolders = async () => {
|
||||||
|
if (searchQuery.value.trim()) {
|
||||||
|
folders.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await listFolders({ parent_id: currentFolderId.value ?? undefined })
|
||||||
|
folders.value = resp.data || []
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = () => {
|
||||||
|
fetchFiles()
|
||||||
|
fetchFolders()
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToFolder = (folder: FolderResponse) => {
|
||||||
|
breadcrumbs.value.push({ id: folder.id, name: folder.name })
|
||||||
|
currentPage.value = 1
|
||||||
|
searchQuery.value = ''
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToBreadcrumb = (index: number) => {
|
||||||
|
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
|
||||||
|
currentPage.value = 1
|
||||||
|
searchQuery.value = ''
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchClear = () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = (file: FileResponse) => {
|
||||||
|
window.open(downloadFileUrl(file.id), '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteFile = async (file: FileResponse) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除文件 "${file.name}" 吗?`, '删除确认', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteFile(file.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch {
|
||||||
|
// User cancelled or error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteFolder = async (folder: FolderResponse) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除文件夹 "${folder.name}" 吗?只能删除空文件夹。`, '删除确认', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteFolder(folder.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch {
|
||||||
|
// User cancelled or error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateFolder = async () => {
|
||||||
|
const name = newFolderName.value.trim()
|
||||||
|
if (!name) {
|
||||||
|
ElMessage.warning('请输入文件夹名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createFolder({ name, parent_id: currentFolderId.value ?? undefined })
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
showCreateFolderDialog.value = false
|
||||||
|
newFolderName.value = ''
|
||||||
|
fetchData()
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="padding: 20px">
|
||||||
|
<h2>文件管理</h2>
|
||||||
|
|
||||||
|
<!-- Breadcrumb + Actions -->
|
||||||
|
<div style="display: flex; align-items: center; margin: 16px 0; gap: 12px; flex-wrap: wrap">
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item
|
||||||
|
v-for="(crumb, index) in breadcrumbs"
|
||||||
|
:key="index"
|
||||||
|
@click="navigateToBreadcrumb(index)"
|
||||||
|
>
|
||||||
|
<span style="cursor: pointer">{{ crumb.name }}</span>
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
|
||||||
|
<div style="flex: 1" />
|
||||||
|
|
||||||
|
<UploadButton :folder-id="currentFolderId ?? undefined" @uploaded="fetchData" />
|
||||||
|
|
||||||
|
<el-button @click="showCreateFolderDialog = true">新建文件夹</el-button>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索文件"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearchClear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Folders -->
|
||||||
|
<div v-if="folders.length" style="margin-bottom: 12px">
|
||||||
|
<div
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
style="display: flex; align-items: center; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f5f5f5"
|
||||||
|
>
|
||||||
|
<span style="flex: 1" @click="navigateToFolder(folder)">
|
||||||
|
📁 {{ folder.name }} ({{ folder.file_count }} 文件, {{ folder.subfolder_count }} 子文件夹)
|
||||||
|
</span>
|
||||||
|
<el-button text type="danger" size="small" @click.stop="handleDeleteFolder(folder)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Files Table -->
|
||||||
|
<el-table :data="files" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="name" label="文件名" min-width="200" />
|
||||||
|
<el-table-column label="大小" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatSize(row.size) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="mime_type" label="类型" width="150" />
|
||||||
|
<el-table-column label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text type="primary" size="small" @click="handleDownload(row)">下载</el-button>
|
||||||
|
<el-button text type="danger" size="small" @click="handleDeleteFile(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div style="margin-top: 16px; display: flex; justify-content: flex-end">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Folder Dialog -->
|
||||||
|
<el-dialog v-model="showCreateFolderDialog" title="新建文件夹" width="400px">
|
||||||
|
<el-input v-model="newFolderName" placeholder="请输入文件夹名称" @keyup.enter="handleCreateFolder" />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreateFolderDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleCreateFolder">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
141
web/src/views/Files/UploadButton.vue
Normal file
141
web/src/views/Files/UploadButton.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { initUpload, uploadChunk, completeUpload, cancelUpload } from '@/api/files'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
folderId?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
uploaded: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const stage = ref<'idle' | 'hashing' | 'uploading' | 'done'>('idle')
|
||||||
|
const hashingProgress = ref(0)
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const sessionId = ref<number | null>(null)
|
||||||
|
|
||||||
|
let currentWorker: Worker | null = null
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (currentWorker) {
|
||||||
|
currentWorker.terminate()
|
||||||
|
currentWorker = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const triggerUpload = () => {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileSelected = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
input.value = ''
|
||||||
|
|
||||||
|
if (file.size > 500 * 1024 * 1024) {
|
||||||
|
ElMessage.warning('文件过大,当前仅支持 500MB 以内的文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stage.value = 'hashing'
|
||||||
|
hashingProgress.value = 0
|
||||||
|
|
||||||
|
const sha256 = await computeSHA256(file)
|
||||||
|
|
||||||
|
stage.value = 'uploading'
|
||||||
|
uploadProgress.value = 0
|
||||||
|
|
||||||
|
const resp = await initUpload({
|
||||||
|
file_name: file.name,
|
||||||
|
file_size: file.size,
|
||||||
|
sha256,
|
||||||
|
chunk_size: 16777216,
|
||||||
|
folder_id: props.folderId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const inner = resp.data
|
||||||
|
if (inner && 'chunk_size' in inner && 'total_chunks' in inner) {
|
||||||
|
sessionId.value = inner.id
|
||||||
|
const chunkSize = inner.chunk_size
|
||||||
|
const totalChunks = inner.total_chunks
|
||||||
|
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
const start = i * chunkSize
|
||||||
|
const end = Math.min(start + chunkSize, file.size)
|
||||||
|
const chunk = file.slice(start, end)
|
||||||
|
await uploadChunk(inner.id, i, chunk)
|
||||||
|
uploadProgress.value = (i + 1) / totalChunks
|
||||||
|
}
|
||||||
|
|
||||||
|
await completeUpload(inner.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.value = 'done'
|
||||||
|
emit('uploaded')
|
||||||
|
} catch {
|
||||||
|
if (sessionId.value) {
|
||||||
|
try { await cancelUpload(sessionId.value) } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
ElMessage.error('上传失败')
|
||||||
|
} finally {
|
||||||
|
stage.value = 'idle'
|
||||||
|
hashingProgress.value = 0
|
||||||
|
uploadProgress.value = 0
|
||||||
|
sessionId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeSHA256 = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const worker = new Worker(new URL('./sha256.worker.ts', import.meta.url), { type: 'module' })
|
||||||
|
currentWorker = worker
|
||||||
|
|
||||||
|
worker.onmessage = (e: MessageEvent) => {
|
||||||
|
const data = e.data as { sha256?: string; progress?: number; error?: string }
|
||||||
|
if (data.error) {
|
||||||
|
worker.terminate()
|
||||||
|
currentWorker = null
|
||||||
|
reject(new Error(data.error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.sha256) {
|
||||||
|
worker.terminate()
|
||||||
|
currentWorker = null
|
||||||
|
resolve(data.sha256)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.progress !== undefined) {
|
||||||
|
hashingProgress.value = data.progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.onerror = (e: ErrorEvent) => {
|
||||||
|
worker.terminate()
|
||||||
|
currentWorker = null
|
||||||
|
reject(new Error(e.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.postMessage({ file })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<input ref="fileInput" type="file" style="display: none" @change="onFileSelected" />
|
||||||
|
<el-button :disabled="stage !== 'idle'" @click="triggerUpload">上传文件</el-button>
|
||||||
|
<div v-if="stage === 'hashing'" style="margin-top: 8px">
|
||||||
|
<span>计算哈希中...</span>
|
||||||
|
<el-progress :percentage="Math.round(hashingProgress * 100)" :stroke-width="10" />
|
||||||
|
</div>
|
||||||
|
<div v-if="stage === 'uploading'" style="margin-top: 8px">
|
||||||
|
<span>上传中...</span>
|
||||||
|
<el-progress :percentage="Math.round(uploadProgress * 100)" :stroke-width="10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
34
web/src/views/Files/sha256.worker.ts
Normal file
34
web/src/views/Files/sha256.worker.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export {}
|
||||||
|
declare const self: {
|
||||||
|
onmessage: ((e: MessageEvent) => void) | null
|
||||||
|
postMessage: (message: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (e: MessageEvent) => {
|
||||||
|
const file = e.data.file as File
|
||||||
|
try {
|
||||||
|
// Read entire file into ArrayBuffer (with progress reporting)
|
||||||
|
const CHUNK_READ_SIZE = 16 * 1024 * 1024 // 16MB chunks for progress reporting
|
||||||
|
const buffer = new ArrayBuffer(file.size)
|
||||||
|
const view = new Uint8Array(buffer)
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
while (offset < file.size) {
|
||||||
|
const end = Math.min(offset + CHUNK_READ_SIZE, file.size)
|
||||||
|
const slice = file.slice(offset, end)
|
||||||
|
const chunkBuffer = await slice.arrayBuffer()
|
||||||
|
view.set(new Uint8Array(chunkBuffer), offset)
|
||||||
|
offset = end
|
||||||
|
self.postMessage({ progress: offset / file.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA256 on entire buffer at once
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
const sha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
|
||||||
|
self.postMessage({ sha256, progress: 1 })
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ error: error instanceof Error ? error.message : 'SHA256 computation failed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user