ForcePilot/web/src/views/WorkspaceView.vue
Wenjie Zhang c5852617b1 feat: 新增个人工作区预览
新增独立用户级 workspace 只读接口和工作区页面,支持文件列表浏览及宽屏内嵌/窄屏弹窗预览,便于后续扩展团队空间与知识库浏览。

Co-authored-by: Copilot <copilot@github.com>
2026-05-04 08:18:37 +08:00

344 lines
9.1 KiB
Vue

<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>