feat: 完善工作区文件管理能力

This commit is contained in:
Wenjie Zhang 2026-04-30 22:07:48 +08:00
parent c5852617b1
commit 35f09f0bc8
7 changed files with 712 additions and 48 deletions

View File

@ -1,12 +1,15 @@
from __future__ import annotations
import asyncio
import contextlib
import io
import mimetypes
import shutil
from pathlib import Path, PurePosixPath
from urllib.parse import quote
from fastapi import HTTPException
import aiofiles
from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir
@ -67,6 +70,37 @@ def _sort_entries(entries: list[dict]) -> list[dict]:
return sorted(entries, key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower()))
def _validate_child_name(name: str, *, field_name: str) -> str:
clean_name = str(name or "").strip()
if not clean_name:
raise HTTPException(status_code=422, detail=f"{field_name} 不能为空")
if clean_name in {".", ".."} or "/" in clean_name or "\\" in clean_name:
raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符")
if PurePosixPath(clean_name).name != clean_name:
raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符")
return clean_name
def _resolve_parent_directory(user: User, parent_path: str) -> Path:
parent = _resolve_workspace_path(user, parent_path)
if not parent.exists():
raise HTTPException(status_code=404, detail="目标目录不存在")
if not parent.is_dir():
raise HTTPException(status_code=400, detail="目标路径不是目录")
return parent
def _resolve_new_child(root: Path, parent: Path, name: str) -> Path:
target = parent / name
try:
target.resolve(strict=False).relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
if target.exists():
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在")
return target
def _list_directory(root: Path, target: Path) -> list[dict]:
entries = [_entry_for_path(root, child) for child in target.iterdir()]
return _sort_entries(entries)
@ -107,6 +141,67 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
}
async def delete_workspace_path(*, path: str, current_user: User) -> dict:
root = _workspace_root(current_user)
target = _resolve_workspace_path(current_user, path)
if target == root:
raise HTTPException(status_code=400, detail="工作区根目录不允许删除")
if not target.exists():
raise HTTPException(status_code=404, detail="文件不存在")
try:
if target.is_dir():
await asyncio.to_thread(shutil.rmtree, target)
else:
await asyncio.to_thread(target.unlink)
except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"success": True, "path": _normalize_workspace_path(path).as_posix()}
async def create_workspace_directory(*, parent_path: str, name: str, current_user: User) -> dict:
root = _workspace_root(current_user)
directory_name = _validate_child_name(name, field_name="文件夹名")
parent = _resolve_parent_directory(current_user, parent_path)
target = _resolve_new_child(root, parent, directory_name)
try:
await asyncio.to_thread(target.mkdir)
except FileExistsError as exc:
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc
except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"success": True, "entry": _entry_for_path(root, target)}
async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_user: User) -> dict:
root = _workspace_root(current_user)
file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名")
parent = _resolve_parent_directory(current_user, parent_path)
target = _resolve_new_child(root, parent, file_name)
created_file = False
upload_completed = False
try:
async with aiofiles.open(target, "xb") as buffer:
created_file = True
while chunk := await file.read(1024 * 1024):
await buffer.write(chunk)
upload_completed = True
except FileExistsError as exc:
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc
except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
finally:
if created_file and not upload_completed and target.exists():
with contextlib.suppress(OSError):
await asyncio.to_thread(target.unlink)
return {"success": True, "entry": _entry_for_path(root, target)}
async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse:
target = _resolve_workspace_path(current_user, path)
if not target.exists():

View File

@ -1,14 +1,27 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from pydantic import BaseModel
from server.utils.auth_middleware import get_required_user
from yuxi.services.workspace_service import download_workspace_file, list_workspace_tree, read_workspace_file_content
from yuxi.services.workspace_service import (
create_workspace_directory,
delete_workspace_path,
download_workspace_file,
list_workspace_tree,
read_workspace_file_content,
upload_workspace_file,
)
from yuxi.storage.postgres.models_business import User
workspace = APIRouter(prefix="/workspace", tags=["workspace"])
class CreateWorkspaceDirectoryRequest(BaseModel):
parent_path: str
name: str
@workspace.get("/tree", response_model=dict)
async def get_workspace_tree(
path: str = Query("/", description="工作区目录路径"),
@ -25,6 +38,35 @@ async def get_workspace_file(
return await read_workspace_file_content(path=path, current_user=current_user)
@workspace.delete("/file", response_model=dict)
async def delete_workspace_file_route(
path: str = Query(..., description="工作区文件或目录路径"),
current_user: User = Depends(get_required_user),
):
return await delete_workspace_path(path=path, current_user=current_user)
@workspace.post("/directory", response_model=dict)
async def create_workspace_directory_route(
payload: CreateWorkspaceDirectoryRequest,
current_user: User = Depends(get_required_user),
):
return await create_workspace_directory(
parent_path=payload.parent_path,
name=payload.name,
current_user=current_user,
)
@workspace.post("/upload", response_model=dict)
async def upload_workspace_file_route(
parent_path: str = Form(..., description="父目录路径"),
file: UploadFile = File(..., description="上传文件"),
current_user: User = Depends(get_required_user),
):
return await upload_workspace_file(parent_path=parent_path, file=file, current_user=current_user)
@workspace.get("/download")
async def download_workspace(
path: str = Query(..., description="工作区文件路径"),

View File

@ -37,7 +37,7 @@
### 0.6.2 开发记录
<!-- 0.6.2 的内容请放在这里 -->
- 新增个人工作区预览:提供独立于对话 thread 的用户级 workspace 只读 API并增加“工作区”页面用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF知识库与团队空间入口先展示到占位层级。
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API并增加“工作区”页面用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级。
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开避免空白区域误触发。

View File

@ -1,4 +1,4 @@
import { apiGet } from './base'
import { apiDelete, apiGet, apiPost } from './base'
const buildQuery = (params) => {
const query = new URLSearchParams()
@ -20,6 +20,25 @@ export const getWorkspaceFileContent = (path) => {
return apiGet(`/api/workspace/file?${query}`)
}
export const deleteWorkspacePath = (path) => {
const query = buildQuery({ path })
return apiDelete(`/api/workspace/file?${query}`)
}
export const createWorkspaceDirectory = (parentPath, name) => {
return apiPost('/api/workspace/directory', {
parent_path: parentPath,
name
})
}
export const uploadWorkspaceFile = (parentPath, file) => {
const formData = new FormData()
formData.append('parent_path', parentPath)
formData.append('file', file)
return apiPost('/api/workspace/upload', formData)
}
export const downloadWorkspaceFile = (path) => {
const query = buildQuery({ path })
return apiGet(`/api/workspace/download?${query}`, {}, true, 'blob')

View File

@ -13,24 +13,62 @@
</button>
<span class="path-text" :title="currentPath">{{ currentPath }}</span>
</div>
<span class="entry-count">{{ entries.length }} </span>
<div class="list-actions">
<span class="entry-count">{{ entries.length }} </span>
<a-button size="small" :type="selectionMode ? 'primary' : 'default'" @click="toggleSelectionMode">
多选
</a-button>
<a-button
v-if="selectionMode"
size="small"
danger
:disabled="!selectedPaths.length"
:loading="deletingPaths.length > 0"
@click="$emit('delete-selected')"
>
删除选中
</a-button>
</div>
</div>
<div class="file-table" role="table" aria-label="工作区文件列表">
<div class="file-row table-head" role="row">
<div class="file-row table-head" :class="{ 'selection-enabled': selectionMode }" role="row">
<span v-if="selectionMode" class="selection-cell">
<a-checkbox
:checked="allSelected"
:indeterminate="partiallySelected"
:disabled="!entries.length"
aria-label="全选当前目录文件"
@change="toggleAllSelection"
/>
</span>
<span>名称</span>
<span>大小</span>
<span>修改时间</span>
<span class="action-head">操作</span>
</div>
<button
<div
v-for="entry in entries"
:key="entry.path"
type="button"
class="file-row"
:class="{ selected: selectedPath === entry.path }"
:class="{
selected: selectedPath === entry.path,
deleting: isDeleting(entry.path),
'selection-enabled': selectionMode
}"
role="row"
tabindex="0"
@click="$emit('select-entry', entry)"
@keydown.enter="$emit('select-entry', entry)"
>
<span v-if="selectionMode" class="selection-cell" @click.stop>
<a-checkbox
:checked="selectedPathSet.has(entry.path)"
:disabled="isDeleting(entry.path)"
:aria-label="`选择 ${entry.name}`"
@change="(event) => toggleEntrySelection(entry.path, event.target.checked)"
/>
</span>
<span class="name-cell">
<Folder v-if="entry.is_dir" :size="17" class="folder-icon" />
<component
@ -42,7 +80,36 @@
</span>
<span>{{ entry.is_dir ? '-' : formatFileSize(entry.size) }}</span>
<span>{{ formatRelativeTime(entry.modified_at) }}</span>
</button>
<span class="action-cell" @click.stop>
<a-dropdown :trigger="['click']">
<button
type="button"
class="more-action"
:disabled="isDeleting(entry.path)"
aria-label="更多操作"
@click.stop
>
<MoreHorizontal :size="16" />
</button>
<template #overlay>
<a-menu>
<a-menu-item v-if="!entry.is_dir" key="download" @click="$emit('download-entry', entry)">
<span class="menu-item-content">
<Download :size="14" />
<span>下载</span>
</span>
</a-menu-item>
<a-menu-item key="delete" danger @click="$emit('delete-entry', entry)">
<span class="menu-item-content">
<Trash2 :size="14" />
<span>删除</span>
</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</span>
</div>
</div>
<div v-if="loading" class="list-state">
@ -54,17 +121,65 @@
</template>
<script setup>
import { ChevronLeft, Folder } from 'lucide-vue-next'
import { computed } from 'vue'
import { ChevronLeft, Download, Folder, MoreHorizontal, Trash2 } from 'lucide-vue-next'
import { formatFileSize, formatRelativeTime, getFileIcon, getFileIconColor } from '@/utils/file_utils'
defineProps({
const props = defineProps({
entries: { type: Array, default: () => [] },
currentPath: { type: String, default: '/' },
selectedPath: { type: String, default: '' },
selectedPaths: { type: Array, default: () => [] },
deletingPaths: { type: Array, default: () => [] },
selectionMode: { type: Boolean, default: false },
loading: { type: Boolean, default: false }
})
defineEmits(['select-entry', 'go-parent'])
const emit = defineEmits([
'select-entry',
'go-parent',
'update:selectedPaths',
'update:selectionMode',
'delete-selected',
'delete-entry',
'download-entry'
])
const selectedPathSet = computed(() => new Set(props.selectedPaths))
const deletingPathSet = computed(() => new Set(props.deletingPaths))
const entryPaths = computed(() => props.entries.map((entry) => entry.path))
const allSelected = computed(() => {
return entryPaths.value.length > 0 && entryPaths.value.every((path) => selectedPathSet.value.has(path))
})
const partiallySelected = computed(() => {
return !allSelected.value && entryPaths.value.some((path) => selectedPathSet.value.has(path))
})
const isDeleting = (path) => deletingPathSet.value.has(path)
const toggleSelectionMode = () => {
const nextMode = !props.selectionMode
emit('update:selectionMode', nextMode)
if (!nextMode) {
emit('update:selectedPaths', [])
}
}
const toggleAllSelection = (event) => {
emit('update:selectedPaths', event.target.checked ? [...entryPaths.value] : [])
}
const toggleEntrySelection = (path, checked) => {
const nextSelectedPaths = new Set(props.selectedPaths)
if (checked) {
nextSelectedPaths.add(path)
} else {
nextSelectedPaths.delete(path)
}
emit('update:selectedPaths', [...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath)))
}
</script>
<style scoped lang="less">
@ -86,13 +201,18 @@ defineEmits(['select-entry', 'go-parent'])
border-bottom: 1px solid var(--gray-100);
}
.path-line {
.path-line,
.list-actions {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
}
.list-actions {
flex: 0 0 auto;
}
.path-action {
display: inline-flex;
align-items: center;
@ -139,9 +259,9 @@ defineEmits(['select-entry', 'go-parent'])
.file-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) 86px 130px;
grid-template-columns: minmax(150px, 1fr) 76px 118px 34px;
align-items: center;
gap: 12px;
gap: 10px;
width: 100%;
min-height: 38px;
padding: 0 14px;
@ -152,6 +272,10 @@ defineEmits(['select-entry', 'go-parent'])
font-size: 13px;
text-align: left;
&.selection-enabled {
grid-template-columns: 34px minmax(150px, 1fr) 76px 118px 34px;
}
&:not(.table-head) {
cursor: pointer;
}
@ -165,6 +289,10 @@ defineEmits(['select-entry', 'go-parent'])
&.selected {
box-shadow: inset 3px 0 0 var(--main-color);
}
&.deleting {
opacity: 0.62;
}
}
.table-head {
@ -178,6 +306,46 @@ defineEmits(['select-entry', 'go-parent'])
font-weight: 600;
}
.selection-cell,
.action-cell {
display: inline-flex;
align-items: center;
}
.action-head,
.action-cell {
justify-content: center;
}
.more-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--gray-500);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--gray-100);
color: var(--gray-900);
}
&:disabled {
color: var(--gray-300);
cursor: not-allowed;
}
}
.menu-item-content {
display: inline-flex;
align-items: center;
gap: 4px;
}
.name-cell {
display: flex;
align-items: center;

View File

@ -58,7 +58,7 @@ defineEmits(['select-personal', 'select-database'])
flex-direction: column;
gap: 18px;
min-width: 0;
padding: 14px calc(var(--page-padding) - 8px);
padding: 14px 10px;
border-right: 1px solid var(--gray-100);
background: var(--gray-25);
overflow-y: auto;

View File

@ -1,41 +1,79 @@
<template>
<div class="workspace-view layout-container">
<PageHeader title="工作区" :loading="loadingTree || loadingPreview" :show-border="true">
<template #info>
<span class="workspace-info">个人文件的只读预览空间</span>
</template>
<template #actions>
<a-tooltip title="即将支持">
<a-button type="primary" disabled>新建文件夹</a-button>
</a-tooltip>
<a-button :disabled="activeSourceKey !== 'personal'" @click="openCreateDirectoryModal">
新建文件夹
</a-button>
<a-button :loading="uploadingFile" :disabled="activeSourceKey !== 'personal'" @click="openUploadFilePicker">
上传文件
</a-button>
</template>
</PageHeader>
<div class="workspace-shell">
<WorkspaceSidebar
:active-key="activeSourceKey"
:databases="databases"
:loading-databases="loadingDatabases"
@select-personal="selectPersonalWorkspace"
@select-database="selectDatabase"
/>
<input ref="uploadInputRef" class="upload-input" type="file" @change="handleUploadInputChange" />
<div class="workspace-shell" :class="{ 'is-sidebar-collapsed': sidebarCollapsed }">
<div v-if="!sidebarCollapsed" class="workspace-sidebar-slot">
<button
type="button"
class="sidebar-collapse-action"
aria-label="收起工作区侧边栏"
@click="sidebarCollapsed = true"
>
<ChevronLeft :size="16" />
</button>
<WorkspaceSidebar
:active-key="activeSourceKey"
:databases="databases"
:loading-databases="loadingDatabases"
@select-personal="selectPersonalWorkspace"
@select-database="selectDatabase"
/>
</div>
<button
v-else
type="button"
class="sidebar-expand-action"
aria-label="展开工作区侧边栏"
@click="sidebarCollapsed = false"
>
<ChevronRight :size="16" />
</button>
<main
ref="workspaceMainRef"
class="workspace-main"
:class="{ 'is-inline-preview': useInlinePreview }"
:class="{ 'is-inline-preview': showInlinePreview }"
:style="workspaceMainStyle"
>
<template v-if="activeSourceKey === 'personal'">
<WorkspaceFileList
:entries="filteredEntries"
:current-path="currentPath"
:selected-path="selectedEntry?.path || ''"
:selected-paths="selectedPaths"
:deleting-paths="deletingPaths"
:selection-mode="selectionMode"
:loading="loadingTree"
@select-entry="handleSelectEntry"
@go-parent="goParentDirectory"
@update:selected-paths="selectedPaths = $event"
@update:selection-mode="handleSelectionModeChange"
@delete-selected="confirmDeleteEntries(selectedEntries)"
@delete-entry="(entry) => confirmDeleteEntries([entry])"
@download-entry="downloadEntry"
/>
<div
v-if="showInlinePreview"
class="workspace-preview-resizer"
role="separator"
aria-label="调整预览宽度"
tabindex="0"
@pointerdown="startPreviewResize"
></div>
<WorkspacePreviewPane
v-if="useInlinePreview"
v-if="showInlinePreview"
:file="previewFile"
:file-path="selectedEntry?.path || ''"
:loading="loadingPreview"
@ -50,6 +88,22 @@
</main>
</div>
<a-modal
v-model:open="createDirectoryModalVisible"
title="新建文件夹"
okText="创建"
cancelText="取消"
:confirm-loading="creatingDirectory"
@ok="createDirectory"
>
<a-input
v-model:value="newDirectoryName"
placeholder="请输入文件夹名称"
:disabled="creatingDirectory"
@keyup.enter="createDirectory"
/>
</a-modal>
<a-modal
:open="previewModalVisible && !useInlinePreview"
width="880px"
@ -74,20 +128,29 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { LibraryBig } from 'lucide-vue-next'
import { message, Modal } from 'ant-design-vue'
import { ChevronLeft, ChevronRight, LibraryBig } from 'lucide-vue-next'
import PageHeader from '@/components/shared/PageHeader.vue'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
import WorkspaceFileList from '@/components/workspace/WorkspaceFileList.vue'
import WorkspacePreviewPane from '@/components/workspace/WorkspacePreviewPane.vue'
import WorkspaceSidebar from '@/components/workspace/WorkspaceSidebar.vue'
import { databaseApi } from '@/apis/knowledge_api'
import { downloadWorkspaceFile, getWorkspaceFileContent, getWorkspaceTree } from '@/apis/workspace_api'
import {
createWorkspaceDirectory,
deleteWorkspacePath,
downloadWorkspaceFile,
getWorkspaceFileContent,
getWorkspaceTree,
uploadWorkspaceFile
} from '@/apis/workspace_api'
const activeSourceKey = ref('personal')
const currentPath = ref('/')
const entries = ref([])
const selectedEntry = ref(null)
const selectedPaths = ref([])
const selectionMode = ref(false)
const previewFile = ref(null)
const previewObjectUrl = ref('')
const previewModalVisible = ref(false)
@ -99,9 +162,25 @@ const selectedDatabase = ref(null)
const searchQuery = ref('')
const workspaceMainRef = ref(null)
const workspaceMainWidth = ref(0)
const createDirectoryModalVisible = ref(false)
const newDirectoryName = ref('')
const creatingDirectory = ref(false)
const uploadingFile = ref(false)
const uploadInputRef = ref(null)
const deletingPaths = ref([])
const sidebarCollapsed = ref(false)
const previewWidthPercent = ref(50)
const INLINE_PREVIEW_MIN_WIDTH = 960
const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
const showInlinePreview = computed(() => useInlinePreview.value && Boolean(previewFile.value))
const workspaceMainStyle = computed(() => {
if (!showInlinePreview.value) return {}
const listWidthPercent = 100 - previewWidthPercent.value
return {
gridTemplateColumns: `minmax(0, ${listWidthPercent}%) 6px minmax(280px, ${previewWidthPercent.value}%)`
}
})
const filteredEntries = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
@ -109,6 +188,11 @@ const filteredEntries = computed(() => {
return entries.value.filter((entry) => String(entry.name || '').toLowerCase().includes(keyword))
})
const selectedEntries = computed(() => {
const selectedPathSet = new Set(selectedPaths.value)
return entries.value.filter((entry) => selectedPathSet.has(entry.path))
})
const revokePreviewObjectUrl = () => {
if (!previewObjectUrl.value) return
URL.revokeObjectURL(previewObjectUrl.value)
@ -124,7 +208,8 @@ const normalizePreviewFile = async (entry, response) => {
}
if (previewType === 'image' || previewType === 'pdf') {
const blob = await downloadWorkspaceFile(entry.path)
const response = await downloadWorkspaceFile(entry.path)
const blob = await response.blob()
revokePreviewObjectUrl()
previewObjectUrl.value = URL.createObjectURL(blob)
file.previewUrl = previewObjectUrl.value
@ -133,12 +218,32 @@ const normalizePreviewFile = async (entry, response) => {
return file
}
const syncSelectedPaths = () => {
const entryPathSet = new Set(entries.value.map((entry) => entry.path))
selectedPaths.value = selectedPaths.value.filter((path) => entryPathSet.has(path))
}
const clearWorkspaceSelection = () => {
selectedPaths.value = []
}
const handleSelectionModeChange = (enabled) => {
selectionMode.value = enabled
if (!enabled) {
clearWorkspaceSelection()
}
}
const loadWorkspaceEntries = async (path = '/') => {
loadingTree.value = true
try {
const response = await getWorkspaceTree(path)
entries.value = response.entries || []
currentPath.value = path
syncSelectedPaths()
if (!selectedPaths.value.length) {
selectionMode.value = false
}
} catch (error) {
console.warn('加载工作区目录失败:', error)
message.error('加载工作区目录失败')
@ -163,12 +268,15 @@ const loadDatabases = async () => {
const selectPersonalWorkspace = async () => {
activeSourceKey.value = 'personal'
selectedDatabase.value = null
clearWorkspaceSelection()
if (!entries.value.length) {
await loadWorkspaceEntries(currentPath.value)
}
}
const selectDatabase = (database) => {
closePreview()
clearWorkspaceSelection()
selectedDatabase.value = database
activeSourceKey.value = `database:${database.db_id}`
}
@ -176,6 +284,7 @@ const selectDatabase = (database) => {
const handleSelectEntry = async (entry) => {
if (entry.is_dir) {
closePreview()
clearWorkspaceSelection()
await loadWorkspaceEntries(entry.path)
return
}
@ -223,9 +332,183 @@ const goParentDirectory = async () => {
const trimmed = currentPath.value.replace(/\/$/, '')
const parent = trimmed.slice(0, trimmed.lastIndexOf('/')) || '/'
closePreview()
clearWorkspaceSelection()
await loadWorkspaceEntries(parent)
}
const openCreateDirectoryModal = () => {
if (activeSourceKey.value !== 'personal') return
newDirectoryName.value = ''
createDirectoryModalVisible.value = true
}
const createDirectory = async () => {
if (creatingDirectory.value) return
const directoryName = newDirectoryName.value.trim()
if (!directoryName) {
message.warning('请输入文件夹名')
return
}
creatingDirectory.value = true
try {
await createWorkspaceDirectory(currentPath.value, directoryName)
await loadWorkspaceEntries(currentPath.value)
createDirectoryModalVisible.value = false
newDirectoryName.value = ''
message.success('文件夹创建成功')
} catch (error) {
console.warn('创建文件夹失败:', error)
message.error(error?.message || '创建文件夹失败')
} finally {
creatingDirectory.value = false
}
}
const openUploadFilePicker = () => {
if (activeSourceKey.value !== 'personal' || uploadingFile.value) return
if (uploadInputRef.value) {
uploadInputRef.value.value = ''
uploadInputRef.value.click()
}
}
const handleUploadInputChange = async (event) => {
const file = event.target?.files?.[0]
if (!file || uploadingFile.value) return
uploadingFile.value = true
try {
await uploadWorkspaceFile(currentPath.value, file)
await loadWorkspaceEntries(currentPath.value)
message.success('文件上传成功')
} catch (error) {
console.warn('上传文件失败:', error)
message.error(error?.message || '上传文件失败')
} finally {
uploadingFile.value = false
event.target.value = ''
}
}
const comparablePath = (path) => String(path || '/').replace(/\/$/, '') || '/'
const isSameOrChildPath = (path, targetPath) => {
const normalizedPath = comparablePath(path)
const normalizedTargetPath = comparablePath(targetPath)
return normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`)
}
const confirmDeleteEntries = (targetEntries) => {
const validEntries = (targetEntries || []).filter(Boolean)
if (!validEntries.length) return
const isBatch = validEntries.length > 1
const firstEntry = validEntries[0]
Modal.confirm({
title: isBatch
? `确认删除选中的 ${validEntries.length} 项?`
: firstEntry.is_dir
? `确认删除文件夹「${firstEntry.name}」?`
: `确认删除文件「${firstEntry.name}」?`,
content: isBatch || firstEntry.is_dir ? '将删除文件夹及其所有内容,删除后不可恢复。' : '删除后不可恢复。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: () => deleteEntries(validEntries)
})
}
const deleteEntries = async (targetEntries) => {
const paths = targetEntries.map((entry) => entry.path)
deletingPaths.value = paths
try {
await Promise.all(paths.map((path) => deleteWorkspacePath(path)))
if (selectedEntry.value && paths.some((path) => isSameOrChildPath(selectedEntry.value.path, path))) {
closePreview()
}
clearWorkspaceSelection()
await loadWorkspaceEntries(currentPath.value)
message.success(paths.length > 1 ? '选中项删除成功' : '删除成功')
} catch (error) {
console.warn('删除工作区文件失败:', error)
message.error(error?.message || '删除失败')
await loadWorkspaceEntries(currentPath.value)
} finally {
deletingPaths.value = []
}
}
const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return ''
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match && utf8Match[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch (error) {
console.warn('解析 UTF-8 文件名失败:', error)
}
}
const asciiMatch = contentDisposition.match(/filename="?([^";]+)"?/i)
if (asciiMatch && asciiMatch[1]) {
return asciiMatch[1]
}
return ''
}
const downloadEntry = async (entry) => {
if (!entry?.path || entry.is_dir) return
try {
const response = await downloadWorkspaceFile(entry.path)
const blob = await response.blob()
const contentDisposition =
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')
const filename = parseDownloadFilename(contentDisposition) || entry.name || 'download'
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.warn('下载文件失败:', error)
message.error(error?.message || '下载文件失败')
}
}
let resizePointerId = null
const stopPreviewResize = () => {
resizePointerId = null
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('pointermove', resizePreview)
window.removeEventListener('pointerup', stopPreviewResize)
}
const resizePreview = (event) => {
if (!workspaceMainRef.value || resizePointerId === null) return
const rect = workspaceMainRef.value.getBoundingClientRect()
const relativeX = event.clientX - rect.left
const nextPreviewPercent = Math.round(((rect.width - relativeX) / rect.width) * 100)
previewWidthPercent.value = Math.min(70, Math.max(30, nextPreviewPercent))
}
const startPreviewResize = (event) => {
if (!showInlinePreview.value) return
resizePointerId = event.pointerId
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('pointermove', resizePreview)
window.addEventListener('pointerup', stopPreviewResize)
}
let workspaceResizeObserver = null
onMounted(async () => {
@ -245,6 +528,7 @@ onMounted(async () => {
onUnmounted(() => {
workspaceResizeObserver?.disconnect()
workspaceResizeObserver = null
stopPreviewResize()
revokePreviewObjectUrl()
})
@ -265,21 +549,69 @@ watch(useInlinePreview, (isInline) => {
min-height: 0;
}
.workspace-info {
color: var(--gray-500);
font-size: 13px;
.upload-input {
display: none;
}
.workspace-shell {
position: relative;
display: grid;
grid-template-columns: clamp(210px, 18vw, 280px) minmax(0, 1fr);
grid-template-columns: 195px minmax(0, 1fr);
flex: 1 1 auto;
min-height: 0;
// margin: 16px var(--page-padding) var(--page-padding);
// border: 1px solid var(--gray-100);
// border-radius: 12px;
background: var(--gray-0);
overflow: hidden;
&.is-sidebar-collapsed {
grid-template-columns: minmax(0, 1fr);
}
}
.workspace-sidebar-slot {
position: relative;
min-width: 0;
min-height: 0;
}
.workspace-sidebar-slot :deep(.workspace-sidebar) {
height: 100%;
}
.sidebar-collapse-action,
.sidebar-expand-action {
width: 26px;
height: 26px;
position: absolute;
top: 50%;
z-index: 4;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--gray-150);
background: var(--gray-0);
color: var(--gray-600);
cursor: pointer;
transform: translateY(-50%);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
&:hover {
background: var(--main-20);
color: var(--main-color);
}
}
.sidebar-collapse-action {
right: -13px;
width: 26px;
border-radius: 50%;
}
.sidebar-expand-action {
left: 0;
width: 22px;
border-left: 0;
border-radius: 0 12px 12px 0;
}
.workspace-main {
@ -289,8 +621,17 @@ watch(useInlinePreview, (isInline) => {
min-height: 0;
}
.workspace-main.is-inline-preview {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
.workspace-preview-resizer {
width: 6px;
min-width: 6px;
border-left: 1px solid var(--gray-100);
border-right: 1px solid var(--gray-100);
background: var(--gray-25);
cursor: col-resize;
&:hover {
background: var(--main-20);
}
}
.workspace-placeholder {
@ -319,7 +660,6 @@ watch(useInlinePreview, (isInline) => {
line-height: 1.6;
}
}
</style>
<style lang="less">