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