feat: 完善工作区文件管理能力
This commit is contained in:
parent
c5852617b1
commit
35f09f0bc8
@ -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():
|
||||
|
||||
@ -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="工作区文件路径"),
|
||||
|
||||
@ -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、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user