feat(workspace): 增强工作区功能,处理符号链接和非 UTF-8 文本文件的预览支持
This commit is contained in:
parent
5fb6af8cb8
commit
cbd265fea8
@ -11,24 +11,29 @@ from urllib.parse import quote
|
|||||||
import aiofiles
|
import aiofiles
|
||||||
from fastapi import HTTPException, UploadFile
|
from fastapi import HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
|
||||||
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files
|
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files
|
||||||
from yuxi.services.viewer_filesystem_service import _detect_preview_type
|
from yuxi.services.viewer_filesystem_service import _detect_preview_type
|
||||||
from yuxi.storage.postgres.models_business import User
|
from yuxi.storage.postgres.models_business import User
|
||||||
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
||||||
from yuxi.utils.paths import WORKSPACE_DIR_NAME
|
from yuxi.utils.paths import WORKSPACE_DIR_NAME
|
||||||
|
|
||||||
|
|
||||||
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
|
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
|
||||||
|
|
||||||
|
|
||||||
def _workspace_root(user: User) -> Path:
|
def _workspace_root(user: User) -> Path:
|
||||||
try:
|
try:
|
||||||
root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME
|
user_data_root = _global_user_data_dir(str(user.id)).resolve()
|
||||||
|
root = user_data_root / WORKSPACE_DIR_NAME
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=403, detail="Access denied") from exc
|
raise HTTPException(status_code=403, detail="Access denied") from exc
|
||||||
|
if root.is_symlink():
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
resolved_root = root.resolve()
|
resolved_root = root.resolve()
|
||||||
|
try:
|
||||||
|
resolved_root.relative_to(user_data_root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied") from exc
|
||||||
ensure_workspace_default_files(resolved_root)
|
ensure_workspace_default_files(resolved_root)
|
||||||
return resolved_root
|
return resolved_root
|
||||||
|
|
||||||
@ -138,8 +143,17 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
|
|||||||
"supported": supported,
|
"supported": supported,
|
||||||
"message": message,
|
"message": message,
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
|
content = raw_content.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return {
|
||||||
|
"content": None,
|
||||||
|
"preview_type": "unsupported",
|
||||||
|
"supported": False,
|
||||||
|
"message": "当前文件不是 UTF-8 文本,暂不支持预览",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"content": raw_content.decode("utf-8"),
|
"content": content,
|
||||||
"preview_type": preview_type,
|
"preview_type": preview_type,
|
||||||
"supported": supported,
|
"supported": supported,
|
||||||
"message": message,
|
"message": message,
|
||||||
|
|||||||
@ -43,6 +43,38 @@ def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkey
|
|||||||
assert agents_file.read_text(encoding="utf-8") == "保留已有内容"
|
assert agents_file.read_text(encoding="utf-8") == "保留已有内容"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_root_rejects_symlink_root(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||||
|
user_root = tmp_path / "threads" / "shared" / "user-1"
|
||||||
|
outside_root = tmp_path / "outside"
|
||||||
|
user_root.mkdir(parents=True)
|
||||||
|
outside_root.mkdir()
|
||||||
|
(user_root / "workspace").symlink_to(outside_root, target_is_directory=True)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
svc._workspace_root(SimpleNamespace(id="user-1"))
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_workspace_file_content_returns_unsupported_for_non_utf8_text(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||||
|
user = SimpleNamespace(id="user-1")
|
||||||
|
root = svc._workspace_root(user)
|
||||||
|
target = root / "bad.txt"
|
||||||
|
target.write_bytes(b"\xff\xfe\x00")
|
||||||
|
|
||||||
|
result = await svc.read_workspace_file_content(path="/bad.txt", current_user=user)
|
||||||
|
|
||||||
|
assert result["content"] is None
|
||||||
|
assert result["preview_type"] == "unsupported"
|
||||||
|
assert result["supported"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None:
|
async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||||
|
|||||||
@ -240,7 +240,17 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Code2, Download, Eye, Globe, Maximize2, PanelRightClose, Pencil, Save, X } from 'lucide-vue-next'
|
import {
|
||||||
|
Code2,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Globe,
|
||||||
|
Maximize2,
|
||||||
|
PanelRightClose,
|
||||||
|
Pencil,
|
||||||
|
Save,
|
||||||
|
X
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { MdPreview } from 'md-editor-v3'
|
import { MdPreview } from 'md-editor-v3'
|
||||||
import hljs from 'highlight.js/lib/common'
|
import hljs from 'highlight.js/lib/common'
|
||||||
import 'md-editor-v3/lib/preview.css'
|
import 'md-editor-v3/lib/preview.css'
|
||||||
@ -311,8 +321,12 @@ const emit = defineEmits(['close', 'download', 'save'])
|
|||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||||
const closeTitle = computed(() => (props.closeVariant === 'collapse-right' ? '收起预览面板' : '关闭预览'))
|
const closeTitle = computed(() =>
|
||||||
const closeIconComponent = computed(() => (props.closeVariant === 'collapse-right' ? PanelRightClose : X))
|
props.closeVariant === 'collapse-right' ? '收起预览面板' : '关闭预览'
|
||||||
|
)
|
||||||
|
const closeIconComponent = computed(() =>
|
||||||
|
props.closeVariant === 'collapse-right' ? PanelRightClose : X
|
||||||
|
)
|
||||||
const htmlPreviewMode = ref('render')
|
const htmlPreviewMode = ref('render')
|
||||||
const editMode = ref('preview')
|
const editMode = ref('preview')
|
||||||
const draftContent = ref('')
|
const draftContent = ref('')
|
||||||
@ -406,7 +420,9 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, { immediate: true })
|
watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, {
|
||||||
|
immediate: true
|
||||||
|
})
|
||||||
|
|
||||||
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
|
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
|
||||||
if (isHtmlFile.value) {
|
if (isHtmlFile.value) {
|
||||||
|
|||||||
@ -124,14 +124,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import {
|
import { ChevronsDownUp, ChevronsUpDown, Download, RefreshCw, Trash2, X } from 'lucide-vue-next'
|
||||||
ChevronsDownUp,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Download,
|
|
||||||
RefreshCw,
|
|
||||||
Trash2,
|
|
||||||
X
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { Modal, message } from 'ant-design-vue'
|
import { Modal, message } from 'ant-design-vue'
|
||||||
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
||||||
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
||||||
|
|||||||
@ -266,7 +266,11 @@ const renameChat = async (chatId) => {
|
|||||||
|
|
||||||
.actions-mask {
|
.actions-mask {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: linear-gradient(to right, transparent, color-mix(in srgb, var(--main-color) 6%, var(--gray-0)) 20px);
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
color-mix(in srgb, var(--main-color) 6%, var(--gray-0)) 20px
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -259,7 +259,10 @@ defineExpose({
|
|||||||
background: var(--gray-0);
|
background: var(--gray-0);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
|
|||||||
@ -469,7 +469,6 @@ onMounted(() => {
|
|||||||
@import '@/assets/css/extensions.less';
|
@import '@/assets/css/extensions.less';
|
||||||
@import '@/assets/css/extension-detail.less';
|
@import '@/assets/css/extension-detail.less';
|
||||||
|
|
||||||
|
|
||||||
/* 工具列表样式 */
|
/* 工具列表样式 */
|
||||||
.tools-tab {
|
.tools-tab {
|
||||||
.tools-toolbar {
|
.tools-toolbar {
|
||||||
@ -607,7 +606,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.mcp-detail {
|
.mcp-detail {
|
||||||
.detail-content-wrapper {
|
.detail-content-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@ -323,10 +323,6 @@ const currentSkillStatusLabel = computed(() => {
|
|||||||
return '已上传'
|
return '已上传'
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentSkillStatusTone = computed(() => {
|
|
||||||
return currentSkill.value?.status === 'update_available' ? 'warning' : 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSave = computed(() => {
|
const canSave = computed(() => {
|
||||||
if (!selectedPath.value || selectedIsDir.value) return false
|
if (!selectedPath.value || selectedIsDir.value) return false
|
||||||
return fileContent.value !== originalFileContent.value
|
return fileContent.value !== originalFileContent.value
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="info-card" :class="{ 'info-card-disabled': disabled }" @click="$emit('click')">
|
||||||
class="info-card"
|
|
||||||
:class="{ 'info-card-disabled': disabled }"
|
|
||||||
@click="$emit('click')"
|
|
||||||
>
|
|
||||||
<div class="info-card-header">
|
<div class="info-card-header">
|
||||||
<div class="info-card-icon">
|
<div class="info-card-icon">
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
@ -12,9 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-card-info">
|
<div class="info-card-info">
|
||||||
<span class="info-card-name" :title="title">{{ title }}</span>
|
<span class="info-card-name" :title="title">{{ title }}</span>
|
||||||
<span v-if="subtitle" class="info-card-subtitle" :title="subtitle">{{
|
<span v-if="subtitle" class="info-card-subtitle" :title="subtitle">{{ subtitle }}</span>
|
||||||
subtitle
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card-status">
|
<div class="info-card-status">
|
||||||
<slot name="status" />
|
<slot name="status" />
|
||||||
@ -54,10 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="$slots.tags || (normalizedTags && normalizedTags.length > 0)" class="info-card-tags">
|
||||||
v-if="$slots.tags || (normalizedTags && normalizedTags.length > 0)"
|
|
||||||
class="info-card-tags"
|
|
||||||
>
|
|
||||||
<slot name="tags">
|
<slot name="tags">
|
||||||
<span
|
<span
|
||||||
v-for="(tag, idx) in normalizedTags"
|
v-for="(tag, idx) in normalizedTags"
|
||||||
@ -254,7 +245,6 @@ const normalizedTags = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.card-action-btn {
|
.card-action-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -105,7 +105,11 @@
|
|||||||
</button>
|
</button>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
<a-menu>
|
<a-menu>
|
||||||
<a-menu-item v-if="!entry.is_dir" key="download" @click="$emit('download-entry', entry)">
|
<a-menu-item
|
||||||
|
v-if="!entry.is_dir"
|
||||||
|
key="download"
|
||||||
|
@click="$emit('download-entry', entry)"
|
||||||
|
>
|
||||||
<span class="menu-item-content">
|
<span class="menu-item-content">
|
||||||
<Download :size="14" />
|
<Download :size="14" />
|
||||||
<span>下载</span>
|
<span>下载</span>
|
||||||
@ -135,7 +139,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Download, Folder, ListChecks, MoreHorizontal, Trash2 } from 'lucide-vue-next'
|
import { Download, Folder, ListChecks, MoreHorizontal, Trash2 } from 'lucide-vue-next'
|
||||||
import { formatFileSize, formatRelativeTime, getFileIcon, getFileIconColor } from '@/utils/file_utils'
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
formatRelativeTime,
|
||||||
|
getFileIcon,
|
||||||
|
getFileIconColor
|
||||||
|
} from '@/utils/file_utils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
entries: { type: Array, default: () => [] },
|
entries: { type: Array, default: () => [] },
|
||||||
@ -179,7 +188,9 @@ const breadcrumbItems = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const allSelected = computed(() => {
|
const allSelected = computed(() => {
|
||||||
return entryPaths.value.length > 0 && entryPaths.value.every((path) => selectedPathSet.value.has(path))
|
return (
|
||||||
|
entryPaths.value.length > 0 && entryPaths.value.every((path) => selectedPathSet.value.has(path))
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const partiallySelected = computed(() => {
|
const partiallySelected = computed(() => {
|
||||||
@ -207,7 +218,10 @@ const toggleEntrySelection = (path, checked) => {
|
|||||||
} else {
|
} else {
|
||||||
nextSelectedPaths.delete(path)
|
nextSelectedPaths.delete(path)
|
||||||
}
|
}
|
||||||
emit('update:selectedPaths', [...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath)))
|
emit(
|
||||||
|
'update:selectedPaths',
|
||||||
|
[...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="workspace-nav-item secondary"
|
class="workspace-nav-item secondary"
|
||||||
:class="{ active: activeKey === 'personal' && isSameOrChildPath(currentPath, savedArtifactsPath) }"
|
:class="{
|
||||||
|
active: activeKey === 'personal' && isSameOrChildPath(currentPath, savedArtifactsPath)
|
||||||
|
}"
|
||||||
@click="$emit('select-path', savedArtifactsPath)"
|
@click="$emit('select-path', savedArtifactsPath)"
|
||||||
>
|
>
|
||||||
<Archive :size="15" />
|
<Archive :size="15" />
|
||||||
@ -75,7 +77,8 @@ const isSameOrChildPath = (path, targetPath) => {
|
|||||||
const target = normalizePath(targetPath)
|
const target = normalizePath(targetPath)
|
||||||
return current === target || current.startsWith(`${target}/`)
|
return current === target || current.startsWith(`${target}/`)
|
||||||
}
|
}
|
||||||
const isQuickAccessPath = (path) => quickAccessPaths.some((targetPath) => isSameOrChildPath(path, targetPath))
|
const isQuickAccessPath = (path) =>
|
||||||
|
quickAccessPaths.some((targetPath) => isSameOrChildPath(path, targetPath))
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
activeKey: { type: String, default: 'personal' },
|
activeKey: { type: String, default: 'personal' },
|
||||||
|
|||||||
@ -43,9 +43,7 @@
|
|||||||
<div class="new-database-form">
|
<div class="new-database-form">
|
||||||
<!-- 知识库类型选择 -->
|
<!-- 知识库类型选择 -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3 class="section-title">
|
<h3 class="section-title">知识库类型<span class="required-mark">*</span></h3>
|
||||||
知识库类型<span class="required-mark">*</span>
|
|
||||||
</h3>
|
|
||||||
<div class="kb-type-cards">
|
<div class="kb-type-cards">
|
||||||
<div
|
<div
|
||||||
v-for="(typeInfo, typeKey) in orderedKbTypes"
|
v-for="(typeInfo, typeKey) in orderedKbTypes"
|
||||||
@ -156,7 +154,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 隐私设置(暂时隐藏)
|
<!-- 隐私设置(暂时隐藏)
|
||||||
<h3 style="margin-top: 20px">隐私设置</h3>
|
<h3 style="margin-top: 20px">隐私设置</h3>
|
||||||
<div class="privacy-config">
|
<div class="privacy-config">
|
||||||
<a-switch
|
<a-switch
|
||||||
|
|||||||
@ -5,13 +5,22 @@
|
|||||||
<a-button :disabled="activeSourceKey !== 'personal'" @click="openCreateDirectoryModal">
|
<a-button :disabled="activeSourceKey !== 'personal'" @click="openCreateDirectoryModal">
|
||||||
新建文件夹
|
新建文件夹
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button :loading="uploadingFile" :disabled="activeSourceKey !== 'personal'" @click="openUploadFilePicker">
|
<a-button
|
||||||
|
:loading="uploadingFile"
|
||||||
|
:disabled="activeSourceKey !== 'personal'"
|
||||||
|
@click="openUploadFilePicker"
|
||||||
|
>
|
||||||
上传文件
|
上传文件
|
||||||
</a-button>
|
</a-button>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<input ref="uploadInputRef" class="upload-input" type="file" @change="handleUploadInputChange" />
|
<input
|
||||||
|
ref="uploadInputRef"
|
||||||
|
class="upload-input"
|
||||||
|
type="file"
|
||||||
|
@change="handleUploadInputChange"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="workspace-shell" :class="{ 'is-sidebar-collapsed': sidebarCollapsed }">
|
<div class="workspace-shell" :class="{ 'is-sidebar-collapsed': sidebarCollapsed }">
|
||||||
<div v-if="!sidebarCollapsed" class="workspace-sidebar-slot">
|
<div v-if="!sidebarCollapsed" class="workspace-sidebar-slot">
|
||||||
@ -181,6 +190,7 @@ const uploadInputRef = ref(null)
|
|||||||
const deletingPaths = ref([])
|
const deletingPaths = ref([])
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
const previewWidthPercent = ref(50)
|
const previewWidthPercent = ref(50)
|
||||||
|
const previewRequestId = ref(0)
|
||||||
const INLINE_PREVIEW_MIN_WIDTH = 960
|
const INLINE_PREVIEW_MIN_WIDTH = 960
|
||||||
|
|
||||||
const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
|
const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
|
||||||
@ -196,7 +206,11 @@ const workspaceMainStyle = computed(() => {
|
|||||||
const filteredEntries = computed(() => {
|
const filteredEntries = computed(() => {
|
||||||
const keyword = searchQuery.value.trim().toLowerCase()
|
const keyword = searchQuery.value.trim().toLowerCase()
|
||||||
if (!keyword) return entries.value
|
if (!keyword) return entries.value
|
||||||
return entries.value.filter((entry) => String(entry.name || '').toLowerCase().includes(keyword))
|
return entries.value.filter((entry) =>
|
||||||
|
String(entry.name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(keyword)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedEntries = computed(() => {
|
const selectedEntries = computed(() => {
|
||||||
@ -309,6 +323,8 @@ const handleSelectEntry = async (entry) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestId = previewRequestId.value + 1
|
||||||
|
previewRequestId.value = requestId
|
||||||
selectedEntry.value = entry
|
selectedEntry.value = entry
|
||||||
revokePreviewObjectUrl()
|
revokePreviewObjectUrl()
|
||||||
previewFile.value = {
|
previewFile.value = {
|
||||||
@ -323,8 +339,10 @@ const handleSelectEntry = async (entry) => {
|
|||||||
loadingPreview.value = true
|
loadingPreview.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getWorkspaceFileContent(entry.path)
|
const response = await getWorkspaceFileContent(entry.path)
|
||||||
|
if (previewRequestId.value !== requestId || selectedEntry.value?.path !== entry.path) return
|
||||||
previewFile.value = await normalizePreviewFile(entry, response)
|
previewFile.value = await normalizePreviewFile(entry, response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (previewRequestId.value !== requestId || selectedEntry.value?.path !== entry.path) return
|
||||||
console.warn('加载文件预览失败:', error)
|
console.warn('加载文件预览失败:', error)
|
||||||
previewFile.value = {
|
previewFile.value = {
|
||||||
...entry,
|
...entry,
|
||||||
@ -336,14 +354,18 @@ const handleSelectEntry = async (entry) => {
|
|||||||
}
|
}
|
||||||
message.error('加载文件预览失败')
|
message.error('加载文件预览失败')
|
||||||
} finally {
|
} finally {
|
||||||
loadingPreview.value = false
|
if (previewRequestId.value === requestId) {
|
||||||
|
loadingPreview.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const closePreview = () => {
|
const closePreview = () => {
|
||||||
|
previewRequestId.value += 1
|
||||||
previewModalVisible.value = false
|
previewModalVisible.value = false
|
||||||
selectedEntry.value = null
|
selectedEntry.value = null
|
||||||
previewFile.value = null
|
previewFile.value = null
|
||||||
|
loadingPreview.value = false
|
||||||
revokePreviewObjectUrl()
|
revokePreviewObjectUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -430,7 +452,9 @@ const comparablePath = (path) => String(path || '/').replace(/\/$/, '') || '/'
|
|||||||
const isSameOrChildPath = (path, targetPath) => {
|
const isSameOrChildPath = (path, targetPath) => {
|
||||||
const normalizedPath = comparablePath(path)
|
const normalizedPath = comparablePath(path)
|
||||||
const normalizedTargetPath = comparablePath(targetPath)
|
const normalizedTargetPath = comparablePath(targetPath)
|
||||||
return normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`)
|
return (
|
||||||
|
normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDeleteEntries = (targetEntries) => {
|
const confirmDeleteEntries = (targetEntries) => {
|
||||||
@ -445,7 +469,10 @@ const confirmDeleteEntries = (targetEntries) => {
|
|||||||
: firstEntry.is_dir
|
: firstEntry.is_dir
|
||||||
? `确认删除文件夹「${firstEntry.name}」?`
|
? `确认删除文件夹「${firstEntry.name}」?`
|
||||||
: `确认删除文件「${firstEntry.name}」?`,
|
: `确认删除文件「${firstEntry.name}」?`,
|
||||||
content: isBatch || firstEntry.is_dir ? '将删除文件夹及其所有内容,删除后不可恢复。' : '删除后不可恢复。',
|
content:
|
||||||
|
isBatch || firstEntry.is_dir
|
||||||
|
? '将删除文件夹及其所有内容,删除后不可恢复。'
|
||||||
|
: '删除后不可恢复。',
|
||||||
okText: '删除',
|
okText: '删除',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
@ -458,7 +485,10 @@ const deleteEntries = async (targetEntries) => {
|
|||||||
deletingPaths.value = paths
|
deletingPaths.value = paths
|
||||||
try {
|
try {
|
||||||
await Promise.all(paths.map((path) => deleteWorkspacePath(path)))
|
await Promise.all(paths.map((path) => deleteWorkspacePath(path)))
|
||||||
if (selectedEntry.value && paths.some((path) => isSameOrChildPath(selectedEntry.value.path, path))) {
|
if (
|
||||||
|
selectedEntry.value &&
|
||||||
|
paths.some((path) => isSameOrChildPath(selectedEntry.value.path, path))
|
||||||
|
) {
|
||||||
closePreview()
|
closePreview()
|
||||||
}
|
}
|
||||||
clearWorkspaceSelection()
|
clearWorkspaceSelection()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user