feat: 新增个人工作区预览
新增独立用户级 workspace 只读接口和工作区页面,支持文件列表浏览及宽屏内嵌/窄屏弹窗预览,便于后续扩展团队空间与知识库浏览。 Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
943592c654
commit
c5852617b1
124
backend/package/yuxi/services/workspace_service.py
Normal file
124
backend/package/yuxi/services/workspace_service.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
33
backend/server/routers/workspace_router.py
Normal file
33
backend/server/routers/workspace_router.py
Normal 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)
|
||||
@ -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、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
||||
|
||||
26
web/src/apis/workspace_api.js
Normal file
26
web/src/apis/workspace_api.js
Normal 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')
|
||||
}
|
||||
@ -39,7 +39,6 @@ body {
|
||||
|
||||
.layout-container {
|
||||
width: 100%;
|
||||
padding: 0 var(--page-padding);
|
||||
|
||||
h2 {
|
||||
margin: 20px 0 10px 0;
|
||||
|
||||
213
web/src/components/workspace/WorkspaceFileList.vue
Normal file
213
web/src/components/workspace/WorkspaceFileList.vue
Normal 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>
|
||||
91
web/src/components/workspace/WorkspacePreviewPane.vue
Normal file
91
web/src/components/workspace/WorkspacePreviewPane.vue
Normal 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>
|
||||
138
web/src/components/workspace/WorkspaceSidebar.vue
Normal file
138
web/src/components/workspace/WorkspaceSidebar.vue
Normal 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>
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
|
||||
343
web/src/views/WorkspaceView.vue
Normal file
343
web/src/views/WorkspaceView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user