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:
dailz
2026-04-20 10:39:48 +08:00
parent f5e021d652
commit c197b9622b
4 changed files with 553 additions and 0 deletions

View 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>

View 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>

View 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>

View 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' })
}
}