feat: 新增个人工作区预览

新增独立用户级 workspace 只读接口和工作区页面,支持文件列表浏览及宽屏内嵌/窄屏弹窗预览,便于后续扩展团队空间与知识库浏览。

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Wenjie Zhang 2026-04-30 21:28:00 +08:00
parent 943592c654
commit c5852617b1
12 changed files with 994 additions and 1 deletions

View File

@ -0,0 +1,124 @@
from __future__ import annotations
import asyncio
import io
import mimetypes
from pathlib import Path, PurePosixPath
from urllib.parse import quote
from fastapi import HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir
from yuxi.services.viewer_filesystem_service import _detect_preview_type
from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
from yuxi.utils.paths import WORKSPACE_DIR_NAME
def _workspace_root(user: User) -> Path:
try:
root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
root.mkdir(parents=True, exist_ok=True)
return root.resolve()
def _normalize_workspace_path(path: str | None) -> PurePosixPath:
raw_path = (path or "/").strip() or "/"
if not raw_path.startswith("/"):
raw_path = f"/{raw_path}"
normalized = PurePosixPath(raw_path)
if ".." in normalized.parts:
raise HTTPException(status_code=403, detail="Access denied")
return normalized
def _resolve_workspace_path(user: User, path: str | None) -> Path:
root = _workspace_root(user)
normalized = _normalize_workspace_path(path)
relative_parts = [part for part in normalized.parts if part not in {"/", ""}]
target = (root.joinpath(*relative_parts) if relative_parts else root).resolve()
try:
target.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
return target
def _entry_for_path(root: Path, path: Path) -> dict:
stat = path.stat()
is_dir = path.is_dir()
relative = path.relative_to(root).as_posix()
display_path = f"/{relative}" if relative else "/"
if is_dir and display_path != "/" and not display_path.endswith("/"):
display_path = f"{display_path}/"
return {
"path": display_path,
"name": path.name or "工作区",
"is_dir": is_dir,
"size": 0 if is_dir else stat.st_size,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "",
}
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 _list_directory(root: Path, target: Path) -> list[dict]:
entries = [_entry_for_path(root, child) for child in target.iterdir()]
return _sort_entries(entries)
async def list_workspace_tree(*, path: str, current_user: User) -> dict:
root = _workspace_root(current_user)
target = _resolve_workspace_path(current_user, path)
if not target.exists():
return {"entries": []}
if not target.is_dir():
raise HTTPException(status_code=400, detail="当前路径不是目录")
entries = await asyncio.to_thread(_list_directory, root, target)
return {"entries": entries}
async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
target = _resolve_workspace_path(current_user, path)
if not target.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if not target.is_file():
raise HTTPException(status_code=400, detail="当前路径是目录")
raw_content = await asyncio.to_thread(target.read_bytes)
preview_type, supported, message = _detect_preview_type(path, raw_content)
if preview_type in {"image", "pdf"} or not supported:
return {
"content": None,
"preview_type": preview_type,
"supported": supported,
"message": message,
}
return {
"content": raw_content.decode("utf-8"),
"preview_type": preview_type,
"supported": supported,
"message": message,
}
async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse:
target = _resolve_workspace_path(current_user, path)
if not target.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if not target.is_file():
raise HTTPException(status_code=400, detail="当前路径是目录")
file_name = target.name or "download"
media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream"
headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}"}
if target.stat().st_size > 1024 * 1024 * 16:
return FileResponse(path=target, media_type=media_type, headers=headers)
content = await asyncio.to_thread(target.read_bytes)
return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers)

View File

@ -15,6 +15,7 @@ from server.routers.task_router import tasks
from server.routers.tool_router import tools
from server.routers.apikey_router import apikey_router
from server.routers.filesystem_router import filesystem_router
from server.routers.workspace_router import workspace
_LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1")
@ -36,6 +37,7 @@ router.include_router(subagents_router) # /api/system/subagents/* 子智能体
router.include_router(tools) # /api/system/tools/* 工具列表与配置
router.include_router(apikey_router) # /api/apikey/* API Key 管理
router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图
router.include_router(workspace) # /api/workspace/* 用户个人工作区
if not _LITE_MODE:
from server.routers.graph_router import graph

View File

@ -0,0 +1,33 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
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.storage.postgres.models_business import User
workspace = APIRouter(prefix="/workspace", tags=["workspace"])
@workspace.get("/tree", response_model=dict)
async def get_workspace_tree(
path: str = Query("/", description="工作区目录路径"),
current_user: User = Depends(get_required_user),
):
return await list_workspace_tree(path=path, current_user=current_user)
@workspace.get("/file", response_model=dict)
async def get_workspace_file(
path: str = Query(..., description="工作区文件路径"),
current_user: User = Depends(get_required_user),
):
return await read_workspace_file_content(path=path, current_user=current_user)
@workspace.get("/download")
async def download_workspace(
path: str = Query(..., description="工作区文件路径"),
current_user: User = Depends(get_required_user),
):
return await download_workspace_file(path=path, current_user=current_user)

View File

@ -18,6 +18,8 @@
- 完善 Skills 的环境变量注入
- 拓宽检索的知识源统一多知识源channel目前已知知识库/知识图谱/网页,可拓展:个人知识库、数据库、历史对话等
- 前置任务,多知识库并行检索(扩展 query_kb
- 新增 query_keywords 工具,专门用于基于关键词命中的排序,也结合词频(和 BM25 的区别?)
- 评估
### Bugs
- 目前的知识库的图片存在公开访问风险
@ -35,6 +37,7 @@
### 0.6.2 开发记录
<!-- 0.6.2 的内容请放在这里 -->
- 新增个人工作区预览:提供独立于对话 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

@ -0,0 +1,26 @@
import { apiGet } from './base'
const buildQuery = (params) => {
const query = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value))
}
})
return query.toString()
}
export const getWorkspaceTree = (path = '/') => {
const query = buildQuery({ path })
return apiGet(`/api/workspace/tree?${query}`)
}
export const getWorkspaceFileContent = (path) => {
const query = buildQuery({ path })
return apiGet(`/api/workspace/file?${query}`)
}
export const downloadWorkspaceFile = (path) => {
const query = buildQuery({ path })
return apiGet(`/api/workspace/download?${query}`, {}, true, 'blob')
}

View File

@ -39,7 +39,6 @@ body {
.layout-container {
width: 100%;
padding: 0 var(--page-padding);
h2 {
margin: 20px 0 10px 0;

View File

@ -0,0 +1,213 @@
<template>
<section class="workspace-file-list">
<div class="file-list-header">
<div class="path-line">
<button
type="button"
class="path-action"
:disabled="currentPath === '/'"
aria-label="返回上级目录"
@click="$emit('go-parent')"
>
<ChevronLeft :size="16" />
</button>
<span class="path-text" :title="currentPath">{{ currentPath }}</span>
</div>
<span class="entry-count">{{ entries.length }} </span>
</div>
<div class="file-table" role="table" aria-label="工作区文件列表">
<div class="file-row table-head" role="row">
<span>名称</span>
<span>大小</span>
<span>修改时间</span>
</div>
<button
v-for="entry in entries"
:key="entry.path"
type="button"
class="file-row"
:class="{ selected: selectedPath === entry.path }"
role="row"
@click="$emit('select-entry', entry)"
>
<span class="name-cell">
<Folder v-if="entry.is_dir" :size="17" class="folder-icon" />
<component
v-else
:is="getFileIcon(entry.path)"
:style="{ color: getFileIconColor(entry.path), fontSize: '16px' }"
/>
<span class="entry-name" :title="entry.name">{{ entry.name }}</span>
</span>
<span>{{ entry.is_dir ? '-' : formatFileSize(entry.size) }}</span>
<span>{{ formatRelativeTime(entry.modified_at) }}</span>
</button>
</div>
<div v-if="loading" class="list-state">
<a-spin />
<span>正在加载文件...</span>
</div>
<a-empty v-else-if="!entries.length" class="list-empty" description="当前文件夹为空" />
</section>
</template>
<script setup>
import { ChevronLeft, Folder } from 'lucide-vue-next'
import { formatFileSize, formatRelativeTime, getFileIcon, getFileIconColor } from '@/utils/file_utils'
defineProps({
entries: { type: Array, default: () => [] },
currentPath: { type: String, default: '/' },
selectedPath: { type: String, default: '' },
loading: { type: Boolean, default: false }
})
defineEmits(['select-entry', 'go-parent'])
</script>
<style scoped lang="less">
.workspace-file-list {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--gray-0);
}
.file-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
padding: 0 14px;
border-bottom: 1px solid var(--gray-100);
}
.path-line {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
}
.path-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
color: var(--gray-600);
cursor: pointer;
&:disabled {
color: var(--gray-300);
cursor: not-allowed;
}
&:hover:not(:disabled) {
background: var(--main-20);
color: var(--main-color);
}
}
.path-text {
min-width: 0;
overflow: hidden;
color: var(--gray-900);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-count {
flex: 0 0 auto;
color: var(--gray-500);
font-size: 12px;
}
.file-table {
min-height: 0;
overflow-y: auto;
}
.file-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) 86px 130px;
align-items: center;
gap: 12px;
width: 100%;
min-height: 38px;
padding: 0 14px;
border: 0;
border-bottom: 1px solid var(--gray-50);
background: transparent;
color: var(--gray-700);
font-size: 13px;
text-align: left;
&:not(.table-head) {
cursor: pointer;
}
&:hover:not(.table-head),
&.selected {
background: var(--main-20);
color: var(--gray-1000);
}
&.selected {
box-shadow: inset 3px 0 0 var(--main-color);
}
}
.table-head {
position: sticky;
top: 0;
z-index: 1;
min-height: 34px;
background: var(--gray-25);
color: var(--gray-500);
font-size: 12px;
font-weight: 600;
}
.name-cell {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
}
.folder-icon {
color: var(--main-500);
fill: var(--main-500);
fill-opacity: 0.16;
}
.entry-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 180px;
color: var(--gray-500);
}
.list-empty {
margin-top: 48px;
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<aside class="workspace-preview-pane">
<AgentFilePreview
v-if="file"
:file="file"
:file-path="filePath"
:show-download="false"
:show-fullscreen="true"
:full-height="true"
container-class="workspace-preview-container"
content-class="workspace-preview-content"
/>
<div v-else-if="loading" class="preview-state">
<a-spin />
<span>正在加载预览...</span>
</div>
<div v-else class="preview-empty">
<FileSearch :size="28" />
<h3>选择文件以预览</h3>
<p>支持 Markdown文本代码图片与 PDF 的只读预览</p>
</div>
</aside>
</template>
<script setup>
import { FileSearch } from 'lucide-vue-next'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
defineProps({
file: { type: Object, default: null },
filePath: { type: String, default: '' },
loading: { type: Boolean, default: false }
})
</script>
<style scoped lang="less">
.workspace-preview-pane {
min-width: 0;
min-height: 0;
border-left: 1px solid var(--gray-100);
background: var(--gray-0);
overflow: hidden;
}
:deep(.workspace-preview-container) {
height: 100%;
border-radius: 0;
}
:deep(.workspace-preview-content) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
}
.preview-state,
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 260px;
padding: 24px;
color: var(--gray-500);
text-align: center;
}
.preview-state {
gap: 10px;
}
.preview-empty {
gap: 8px;
h3 {
margin: 6px 0 0;
color: var(--gray-800);
font-size: 15px;
font-weight: 600;
}
p {
max-width: 240px;
margin: 0;
color: var(--gray-500);
font-size: 13px;
line-height: 1.6;
}
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<aside class="workspace-sidebar">
<section class="sidebar-section">
<button
type="button"
class="workspace-nav-item"
:class="{ active: activeKey === 'personal' }"
@click="$emit('select-personal')"
>
<FolderKanban :size="16" />
<span>个人工作区</span>
</button>
</section>
<section class="sidebar-section">
<div class="section-title">知识库</div>
<button
v-for="database in databases"
:key="database.db_id || database.id || database.name"
type="button"
class="workspace-nav-item secondary"
:class="{ active: activeKey === `database:${database.db_id}` }"
@click="$emit('select-database', database)"
>
<LibraryBig :size="15" />
<span>{{ database.name }}</span>
</button>
<div v-if="loadingDatabases" class="sidebar-muted">正在加载知识库...</div>
<div v-else-if="!databases.length" class="sidebar-muted">暂无可访问知识库</div>
</section>
<section class="sidebar-section">
<div class="section-title">共享空间</div>
<button type="button" class="workspace-nav-item secondary disabled" disabled>
<UsersRound :size="15" />
<span>团队工作区</span>
<span class="soon-tag">即将支持</span>
</button>
</section>
</aside>
</template>
<script setup>
import { FolderKanban, LibraryBig, UsersRound } from 'lucide-vue-next'
defineProps({
activeKey: { type: String, default: 'personal' },
databases: { type: Array, default: () => [] },
loadingDatabases: { type: Boolean, default: false }
})
defineEmits(['select-personal', 'select-database'])
</script>
<style scoped lang="less">
.workspace-sidebar {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
padding: 14px calc(var(--page-padding) - 8px);
border-right: 1px solid var(--gray-100);
background: var(--gray-25);
overflow-y: auto;
}
.sidebar-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.section-title {
padding: 0 8px;
color: var(--gray-500);
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.workspace-nav-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 36px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--gray-700);
font-size: 14px;
text-align: left;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
span:first-of-type {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover:not(:disabled),
&.active {
border-color: transparent;
background: var(--main-20);
color: var(--main-color);
}
&.secondary {
min-height: 32px;
font-size: 13px;
}
&.disabled {
color: var(--gray-400);
cursor: not-allowed;
}
}
.soon-tag {
flex: 0 0 auto;
margin-left: auto;
color: var(--gray-400);
font-size: 11px;
}
.sidebar-muted {
padding: 6px 8px;
color: var(--gray-500);
font-size: 12px;
line-height: 1.5;
}
</style>

View File

@ -8,6 +8,7 @@ import {
ClipboardList,
Blocks,
Box,
FolderKanban,
PanelLeftClose,
PanelLeftOpen,
MessageCirclePlus
@ -131,6 +132,13 @@ const mainList = computed(() => {
}
]
items.push({
name: '工作区',
path: '/workspace',
icon: FolderKanban,
activeIcon: FolderKanban
})
if (userStore.isAdmin) {
if (!isLiteMode) {
items.push({

View File

@ -51,6 +51,19 @@ const router = createRouter({
}
]
},
{
path: '/workspace',
name: 'workspace',
component: AppLayout,
children: [
{
path: '',
name: 'WorkspaceComp',
component: () => import('../views/WorkspaceView.vue'),
meta: { keepAlive: true, requiresAuth: true }
}
]
},
{
path: '/graph',
name: 'graph',

View File

@ -0,0 +1,343 @@
<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>
</template>
</PageHeader>
<div class="workspace-shell">
<WorkspaceSidebar
:active-key="activeSourceKey"
:databases="databases"
:loading-databases="loadingDatabases"
@select-personal="selectPersonalWorkspace"
@select-database="selectDatabase"
/>
<main
ref="workspaceMainRef"
class="workspace-main"
:class="{ 'is-inline-preview': useInlinePreview }"
>
<template v-if="activeSourceKey === 'personal'">
<WorkspaceFileList
:entries="filteredEntries"
:current-path="currentPath"
:selected-path="selectedEntry?.path || ''"
:loading="loadingTree"
@select-entry="handleSelectEntry"
@go-parent="goParentDirectory"
/>
<WorkspacePreviewPane
v-if="useInlinePreview"
:file="previewFile"
:file-path="selectedEntry?.path || ''"
:loading="loadingPreview"
/>
</template>
<div v-else class="workspace-placeholder">
<LibraryBig :size="32" />
<h2>{{ selectedDatabase?.name || '知识库' }}</h2>
<p>当前版本仅展示可访问知识库到列表级别知识库文件浏览后续支持</p>
</div>
</main>
</div>
<a-modal
:open="previewModalVisible && !useInlinePreview"
width="880px"
:style="{ maxWidth: '92vw', top: '5vh' }"
:bodyStyle="{ maxHeight: '90vh', overflow: 'auto' }"
:footer="null"
:closable="false"
wrapClassName="workspace-file-preview-modal"
@cancel="closePreview"
>
<AgentFilePreview
:file="previewFile"
:filePath="selectedEntry?.path || ''"
:showClose="true"
:showDownload="false"
:showFullscreen="true"
@close="closePreview"
/>
</a-modal>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { 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'
const activeSourceKey = ref('personal')
const currentPath = ref('/')
const entries = ref([])
const selectedEntry = ref(null)
const previewFile = ref(null)
const previewObjectUrl = ref('')
const previewModalVisible = ref(false)
const loadingTree = ref(false)
const loadingPreview = ref(false)
const loadingDatabases = ref(false)
const databases = ref([])
const selectedDatabase = ref(null)
const searchQuery = ref('')
const workspaceMainRef = ref(null)
const workspaceMainWidth = ref(0)
const INLINE_PREVIEW_MIN_WIDTH = 960
const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
const filteredEntries = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
if (!keyword) return entries.value
return entries.value.filter((entry) => String(entry.name || '').toLowerCase().includes(keyword))
})
const revokePreviewObjectUrl = () => {
if (!previewObjectUrl.value) return
URL.revokeObjectURL(previewObjectUrl.value)
previewObjectUrl.value = ''
}
const normalizePreviewFile = async (entry, response) => {
const previewType = response.preview_type || response.previewType || 'text'
const file = {
...response,
previewType,
supported: response.supported !== false
}
if (previewType === 'image' || previewType === 'pdf') {
const blob = await downloadWorkspaceFile(entry.path)
revokePreviewObjectUrl()
previewObjectUrl.value = URL.createObjectURL(blob)
file.previewUrl = previewObjectUrl.value
}
return file
}
const loadWorkspaceEntries = async (path = '/') => {
loadingTree.value = true
try {
const response = await getWorkspaceTree(path)
entries.value = response.entries || []
currentPath.value = path
} catch (error) {
console.warn('加载工作区目录失败:', error)
message.error('加载工作区目录失败')
} finally {
loadingTree.value = false
}
}
const loadDatabases = async () => {
loadingDatabases.value = true
try {
const response = await databaseApi.getAccessibleDatabases()
databases.value = response?.databases || []
} catch (error) {
console.warn('加载可访问知识库失败:', error)
databases.value = []
} finally {
loadingDatabases.value = false
}
}
const selectPersonalWorkspace = async () => {
activeSourceKey.value = 'personal'
selectedDatabase.value = null
if (!entries.value.length) {
await loadWorkspaceEntries(currentPath.value)
}
}
const selectDatabase = (database) => {
selectedDatabase.value = database
activeSourceKey.value = `database:${database.db_id}`
}
const handleSelectEntry = async (entry) => {
if (entry.is_dir) {
closePreview()
await loadWorkspaceEntries(entry.path)
return
}
selectedEntry.value = entry
revokePreviewObjectUrl()
previewFile.value = {
...entry,
content: 'Loading...',
supported: true,
previewType: 'text',
message: '',
previewUrl: ''
}
previewModalVisible.value = !useInlinePreview.value
loadingPreview.value = true
try {
const response = await getWorkspaceFileContent(entry.path)
previewFile.value = await normalizePreviewFile(entry, response)
} catch (error) {
console.warn('加载文件预览失败:', error)
previewFile.value = {
...entry,
content: `Error loading file: ${error?.message || 'unknown error'}`,
supported: false,
previewType: 'unsupported',
message: error?.message || '文件预览失败',
previewUrl: ''
}
message.error('加载文件预览失败')
} finally {
loadingPreview.value = false
}
}
const closePreview = () => {
previewModalVisible.value = false
selectedEntry.value = null
previewFile.value = null
revokePreviewObjectUrl()
}
const goParentDirectory = async () => {
if (currentPath.value === '/') return
const trimmed = currentPath.value.replace(/\/$/, '')
const parent = trimmed.slice(0, trimmed.lastIndexOf('/')) || '/'
closePreview()
await loadWorkspaceEntries(parent)
}
let workspaceResizeObserver = null
onMounted(async () => {
await Promise.all([loadWorkspaceEntries('/'), loadDatabases()])
if (workspaceMainRef.value && typeof ResizeObserver !== 'undefined') {
workspaceMainWidth.value = workspaceMainRef.value.clientWidth || 0
workspaceResizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
workspaceMainWidth.value = entry.contentRect.width
})
workspaceResizeObserver.observe(workspaceMainRef.value)
}
})
onUnmounted(() => {
workspaceResizeObserver?.disconnect()
workspaceResizeObserver = null
revokePreviewObjectUrl()
})
watch(useInlinePreview, (isInline) => {
if (!previewFile.value) {
previewModalVisible.value = false
return
}
previewModalVisible.value = !isInline
})
</script>
<style scoped lang="less">
.workspace-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.workspace-info {
color: var(--gray-500);
font-size: 13px;
}
.workspace-shell {
display: grid;
grid-template-columns: clamp(210px, 18vw, 280px) 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;
}
.workspace-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.workspace-main.is-inline-preview {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.workspace-placeholder {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 360px;
padding: 32px;
color: var(--gray-500);
text-align: center;
h2 {
margin: 8px 0 0;
color: var(--gray-900);
font-size: 18px;
font-weight: 600;
}
p {
max-width: 360px;
margin: 0;
font-size: 14px;
line-height: 1.6;
}
}
</style>
<style lang="less">
.workspace-file-preview-modal {
.ant-modal {
z-index: 1050;
.ant-modal-content {
padding: 0;
overflow: hidden;
border: 1px solid var(--gray-200);
border-radius: 8px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.ant-modal-body {
padding: 0;
}
}
}
</style>