新增:多文件预览Tab系统+脏标记+关闭确认+路径黑名单优化
- useMultiPreview composable管理多Tab状态、会话持久化 - 面包屑状态dot移除 - 放开Program Files目录访问限制
This commit is contained in:
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
<!-- 有远程配置:完整标签 + 下拉菜单 -->
|
<!-- 有远程配置:完整标签 + 下拉菜单 -->
|
||||||
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
|
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
|
||||||
<span :class="['dot', state]" />
|
|
||||||
<span class="label">{{ label }}</span>
|
<span class="label">{{ label }}</span>
|
||||||
|
|
||||||
<div v-if="showMenu" class="menu" @click.stop>
|
<div v-if="showMenu" class="menu" @click.stop>
|
||||||
|
|||||||
@@ -360,6 +360,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 多文件预览浮动 Tab 栏 -->
|
||||||
|
<div
|
||||||
|
v-if="previewTabs.length > 1"
|
||||||
|
class="preview-tabs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="tab in previewTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="preview-tab"
|
||||||
|
:class="{ active: tab.id === activeTabId }"
|
||||||
|
@click="emit('switchTab', tab.id)"
|
||||||
|
@mousedown.middle.prevent="emit('closeTab', tab.id)"
|
||||||
|
>
|
||||||
|
<span class="tab-icon">
|
||||||
|
{{ getFileTypeIcon(tab.fileItem.name) }}
|
||||||
|
<i v-if="isDirty(tab)" class="tab-dirty"></i>
|
||||||
|
</span>
|
||||||
|
<span class="tab-name" :title="tab.fileItem.name">{{ tab.fileItem.name }}</span>
|
||||||
|
<div class="tab-actions">
|
||||||
|
<button
|
||||||
|
class="tab-close"
|
||||||
|
@click.stop="emit('closeTab', tab.id)"
|
||||||
|
title="关闭"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -373,6 +401,8 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
|
|||||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||||
|
import { getFileCategory } from '@/utils/fileTypeHelpers'
|
||||||
|
import { isDirty } from '../composables/useMultiPreview'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
|
|
||||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||||
@@ -435,10 +465,26 @@ interface Emits {
|
|||||||
(e: 'imageLoad', dimensions: string): void
|
(e: 'imageLoad', dimensions: string): void
|
||||||
(e: 'imageError'): void
|
(e: 'imageError'): void
|
||||||
(e: 'openLocalFile', link: string): void
|
(e: 'openLocalFile', link: string): void
|
||||||
|
(e: 'switchTab', tabId: string): void
|
||||||
|
(e: 'closeTab', tabId: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 多 tab 数据
|
||||||
|
const previewTabs = computed(() => props.config.previewTabs || [])
|
||||||
|
const activeTabId = computed(() => props.config.activeTabId || null)
|
||||||
|
|
||||||
|
// 文件类型图标(基于已有分类逻辑)
|
||||||
|
const CATEGORY_ICONS: Record<string, string> = {
|
||||||
|
image: '🖼️', video: '🎬', audio: '🎵', pdf: '📕',
|
||||||
|
html: '🌐', markdown: '📝', text: '⚡', binary: '📦',
|
||||||
|
unknown: '📄'
|
||||||
|
}
|
||||||
|
const getFileTypeIcon = (filename: string): string => {
|
||||||
|
return CATEGORY_ICONS[getFileCategory(filename)] || '📄'
|
||||||
|
}
|
||||||
|
|
||||||
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||||
function resolveHtmlPreviewBase(): string {
|
function resolveHtmlPreviewBase(): string {
|
||||||
if (!connectionManager.isRemote()) return 'http://localhost:2652'
|
if (!connectionManager.isRemote()) return 'http://localhost:2652'
|
||||||
@@ -868,6 +914,7 @@ onUnmounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-bg-1);
|
background: var(--color-bg-1);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-editor-panel:fullscreen {
|
.file-editor-panel:fullscreen {
|
||||||
@@ -1430,4 +1477,129 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
|
|||||||
color: var(--hljs-link);
|
color: var(--hljs-link);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== 多文件预览浮动 Tab 栏 ========== */
|
||||||
|
|
||||||
|
.preview-tabs {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 6px 4px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
width: 56px;
|
||||||
|
padding: 6px 4px 4px;
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-3) 50%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
|
||||||
|
border-right: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tabs:hover .preview-tab {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab:hover {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab.active {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
border-left-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: rgb(var(--primary-6));
|
||||||
|
border-radius: 3px 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dirty {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: -14px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: rgb(var(--warning-6));
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-name {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab.active .tab-name {
|
||||||
|
color: rgb(var(--primary-6));
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 2px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab:hover .tab-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover:not(:disabled) {
|
||||||
|
background: var(--color-fill-3);
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* 多文件预览 Tab 管理 Composable
|
||||||
|
* 管理预览 Tab 列表、激活状态、内容缓存、会话持久化
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { STORAGE_KEYS } from '@/utils/constants'
|
||||||
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
|
export interface PreviewTab {
|
||||||
|
/** 唯一标识(基于路径) */
|
||||||
|
id: string
|
||||||
|
/** 文件信息 */
|
||||||
|
fileItem: FileItem
|
||||||
|
/** 缓存的预览 URL */
|
||||||
|
previewUrl: string
|
||||||
|
/** 缓存的文件内容 */
|
||||||
|
fileContent: string
|
||||||
|
/** 缓存的原始内容 */
|
||||||
|
originalContent: string
|
||||||
|
/** 缓存的编辑模式状态 */
|
||||||
|
isEditMode: boolean
|
||||||
|
/** 缓存的渲染内容 (HTML/Markdown) */
|
||||||
|
rendered: string
|
||||||
|
/** 是否已加载过内容 */
|
||||||
|
loaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 持久化存储的精简结构 */
|
||||||
|
interface PersistedTab {
|
||||||
|
path: string
|
||||||
|
active: boolean
|
||||||
|
/** 未保存的内容(有修改时才存) */
|
||||||
|
unsavedContent?: string
|
||||||
|
originalContent?: string
|
||||||
|
isEditMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TABS = 10
|
||||||
|
const STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.MULTI_PREVIEW_TABS
|
||||||
|
|
||||||
|
interface UnsavedEntry {
|
||||||
|
content: string
|
||||||
|
original: string
|
||||||
|
editMode: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestoredSession {
|
||||||
|
paths: string[]
|
||||||
|
activePath: string | null
|
||||||
|
unsavedMap: Map<string, UnsavedEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToId(path: string): string {
|
||||||
|
return path.replace(/\\/g, '/').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirty(tab: PreviewTab): boolean {
|
||||||
|
return !!(tab.fileContent && tab.originalContent !== undefined && tab.fileContent !== tab.originalContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMultiPreview() {
|
||||||
|
const tabs = ref<PreviewTab[]>([])
|
||||||
|
const activeTabId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const activeTab = computed<PreviewTab | null>(() => {
|
||||||
|
if (!activeTabId.value) return null
|
||||||
|
return tabs.value.find(t => t.id === activeTabId.value) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 创建一个新 tab */
|
||||||
|
const createTab = (fileItem: FileItem): PreviewTab => ({
|
||||||
|
id: pathToId(fileItem.path),
|
||||||
|
fileItem,
|
||||||
|
previewUrl: '',
|
||||||
|
fileContent: '',
|
||||||
|
originalContent: '',
|
||||||
|
isEditMode: false,
|
||||||
|
rendered: '',
|
||||||
|
loaded: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== 持久化 ==========
|
||||||
|
|
||||||
|
/** 从 localStorage 恢复会话 */
|
||||||
|
const restoreSession = (): RestoredSession => {
|
||||||
|
const unsavedMap = new Map<string, UnsavedEntry>()
|
||||||
|
let activePath: string | null = null
|
||||||
|
const paths: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return { paths, activePath, unsavedMap }
|
||||||
|
|
||||||
|
const persisted: PersistedTab[] = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(persisted)) return { paths, activePath, unsavedMap }
|
||||||
|
|
||||||
|
for (const p of persisted) {
|
||||||
|
if (!p.path) continue
|
||||||
|
paths.push(p.path)
|
||||||
|
if (p.active) activePath = p.path
|
||||||
|
if (p.unsavedContent !== undefined) {
|
||||||
|
unsavedMap.set(pathToId(p.path), {
|
||||||
|
content: p.unsavedContent,
|
||||||
|
original: p.originalContent || '',
|
||||||
|
editMode: p.isEditMode || false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { paths, activePath, unsavedMap }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存会话到 localStorage */
|
||||||
|
const persistSession = () => {
|
||||||
|
const persisted: PersistedTab[] = tabs.value.map(tab => {
|
||||||
|
const hasUnsaved = tab.fileContent && tab.originalContent !== undefined && tab.fileContent !== tab.originalContent
|
||||||
|
// 限制存储大小,超过 100KB 的内容不存入 localStorage
|
||||||
|
const canSave = hasUnsaved && tab.fileContent.length <= 100_000
|
||||||
|
return {
|
||||||
|
path: tab.fileItem.path,
|
||||||
|
active: tab.id === activeTabId.value,
|
||||||
|
unsavedContent: canSave ? tab.fileContent : undefined,
|
||||||
|
originalContent: canSave ? tab.originalContent : undefined,
|
||||||
|
isEditMode: canSave ? tab.isEditMode : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted))
|
||||||
|
} catch {
|
||||||
|
// localStorage 满了,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复未保存内容到 tab */
|
||||||
|
const applyUnsavedContent = (tab: PreviewTab, unsavedMap: Map<string, UnsavedEntry>) => {
|
||||||
|
const unsaved = unsavedMap.get(tab.id)
|
||||||
|
if (unsaved) {
|
||||||
|
tab.fileContent = unsaved.content
|
||||||
|
tab.originalContent = unsaved.original
|
||||||
|
tab.isEditMode = unsaved.editMode
|
||||||
|
tab.loaded = true // 标记为已加载,避免重新从磁盘读取覆盖
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加或激活 tab,返回 { tab, isNew } */
|
||||||
|
const addTab = (fileItem: FileItem): { tab: PreviewTab; isNew: boolean } => {
|
||||||
|
const id = pathToId(fileItem.path)
|
||||||
|
const existing = tabs.value.find(t => t.id === id)
|
||||||
|
if (existing) {
|
||||||
|
activeTabId.value = id
|
||||||
|
return { tab: existing, isNew: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超出上限,移除非活跃的最早 tab
|
||||||
|
if (tabs.value.length >= MAX_TABS) {
|
||||||
|
const victimIdx = tabs.value.findIndex(t => t.id !== activeTabId.value && t.id !== id)
|
||||||
|
if (victimIdx !== -1) tabs.value.splice(victimIdx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = createTab(fileItem)
|
||||||
|
tabs.value.push(tab)
|
||||||
|
activeTabId.value = tab.id
|
||||||
|
return { tab, isNew: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换 tab */
|
||||||
|
const switchTab = (tabId: string) => {
|
||||||
|
if (tabs.value.find(t => t.id === tabId)) {
|
||||||
|
activeTabId.value = tabId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭 tab,返回关闭后应激活的 tabId */
|
||||||
|
const removeTab = (tabId: string): string | null => {
|
||||||
|
const idx = tabs.value.findIndex(t => t.id === tabId)
|
||||||
|
if (idx === -1) return activeTabId.value
|
||||||
|
|
||||||
|
tabs.value.splice(idx, 1)
|
||||||
|
|
||||||
|
if (activeTabId.value === tabId) {
|
||||||
|
const nextIdx = Math.min(idx, tabs.value.length - 1)
|
||||||
|
const nextTab = tabs.value[nextIdx] || null
|
||||||
|
activeTabId.value = nextTab?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeTabId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存当前 tab 的状态(切换前调用) */
|
||||||
|
const cacheCurrentTab = (state: {
|
||||||
|
previewUrl: string
|
||||||
|
fileContent: string
|
||||||
|
originalContent: string
|
||||||
|
isEditMode: boolean
|
||||||
|
rendered: string
|
||||||
|
}) => {
|
||||||
|
if (!activeTabId.value) return
|
||||||
|
const tab = tabs.value.find(t => t.id === activeTabId.value)
|
||||||
|
if (tab) {
|
||||||
|
tab.previewUrl = state.previewUrl
|
||||||
|
tab.fileContent = state.fileContent
|
||||||
|
tab.originalContent = state.originalContent
|
||||||
|
tab.isEditMode = state.isEditMode
|
||||||
|
tab.rendered = state.rendered
|
||||||
|
tab.loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空所有 tab */
|
||||||
|
const clearAll = () => {
|
||||||
|
tabs.value = []
|
||||||
|
activeTabId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 tabs/activeTabId 变化,防抖持久化
|
||||||
|
let _persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch([tabs, activeTabId], () => {
|
||||||
|
if (_persistTimer) clearTimeout(_persistTimer)
|
||||||
|
_persistTimer = setTimeout(persistSession, 1000)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
activeTab,
|
||||||
|
addTab,
|
||||||
|
switchTab,
|
||||||
|
removeTab,
|
||||||
|
cacheCurrentTab,
|
||||||
|
clearAll,
|
||||||
|
persistSession,
|
||||||
|
restoreSession,
|
||||||
|
applyUnsavedContent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
:config="sidebarConfig"
|
:config="sidebarConfig"
|
||||||
@open-favorite="handleOpenFavorite"
|
@open-favorite="handleOpenFavorite"
|
||||||
@remove-favorite="handleRemoveFavorite"
|
@remove-favorite="handleRemoveFavorite"
|
||||||
@toggle-pin="handleTogglePin"
|
@toggle-pin="handleToggleFavPin"
|
||||||
@long-press-start="handleLongPressStart"
|
@long-press-start="handleLongPressStart"
|
||||||
@long-press-cancel="handleLongPressCancel"
|
@long-press-cancel="handleLongPressCancel"
|
||||||
@drag-start="handleDragStart"
|
@drag-start="handleDragStart"
|
||||||
@@ -75,6 +75,8 @@
|
|||||||
@image-load="handleImageLoad"
|
@image-load="handleImageLoad"
|
||||||
@image-error="handleImageError"
|
@image-error="handleImageError"
|
||||||
@open-local-file="handleOpenLocalFile"
|
@open-local-file="handleOpenLocalFile"
|
||||||
|
@switch-tab="switchToTab"
|
||||||
|
@close-tab="closeTab"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,6 +132,7 @@ import { usePathNavigation } from './composables/usePathNavigation'
|
|||||||
import { useFilePreview } from './composables/useFilePreview'
|
import { useFilePreview } from './composables/useFilePreview'
|
||||||
import { useFileEdit } from './composables/useFileEdit'
|
import { useFileEdit } from './composables/useFileEdit'
|
||||||
import { useCommonPaths } from './composables/useCommonPaths'
|
import { useCommonPaths } from './composables/useCommonPaths'
|
||||||
|
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
|
||||||
|
|
||||||
// 导入工具函数
|
// 导入工具函数
|
||||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||||
@@ -311,6 +314,9 @@ const { fileContent, originalContent, isEditMode, fileContentHeight, contentChan
|
|||||||
currentDirectory: filePath
|
currentDirectory: filePath
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 多文件预览 Tab
|
||||||
|
const multiPreview = useMultiPreview()
|
||||||
|
|
||||||
// ========== 计算属性 ==========
|
// ========== 计算属性 ==========
|
||||||
|
|
||||||
const favoritePaths = computed(() => favorites.value.map(f => f.path))
|
const favoritePaths = computed(() => favorites.value.map(f => f.path))
|
||||||
@@ -413,7 +419,10 @@ const fileEditorPanelConfig = computed(() => {
|
|||||||
currentImageDimensions: currentImageDimensions.value,
|
currentImageDimensions: currentImageDimensions.value,
|
||||||
currentFileExtension,
|
currentFileExtension,
|
||||||
isBinaryFile: isBinaryFileRef.value,
|
isBinaryFile: isBinaryFileRef.value,
|
||||||
fileMtime: selectedFileItem.value?.modified_time || ''
|
fileMtime: selectedFileItem.value?.modified_time || '',
|
||||||
|
// 多 tab
|
||||||
|
previewTabs: multiPreview.tabs.value,
|
||||||
|
activeTabId: multiPreview.activeTabId.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -580,7 +589,7 @@ const handleRemoveFavorite = (path: string) => {
|
|||||||
removeFav(path)
|
removeFav(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTogglePin = (path: string) => {
|
const handleToggleFavPin = (path: string) => {
|
||||||
togglePin(path)
|
togglePin(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,24 +619,14 @@ const handleDragEnd = () => {
|
|||||||
|
|
||||||
// 文件列表事件
|
// 文件列表事件
|
||||||
const handleFileClick = async (file: FileItem) => {
|
const handleFileClick = async (file: FileItem) => {
|
||||||
// 正常文件系统浏览
|
|
||||||
if (file.isDir) {
|
if (file.isDir) {
|
||||||
// 目录:使用 navigate 函数,确保历史记录正确更新
|
|
||||||
await navigate(file.path)
|
await navigate(file.path)
|
||||||
} else {
|
} else {
|
||||||
// 文件:选中并读取内容
|
openFileAsTab(file)
|
||||||
selectedFileItem.value = file
|
|
||||||
loadFileContent(file.path)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileDoubleClick = async (file: FileItem) => {
|
const handleFileDoubleClick = handleFileClick
|
||||||
if (file.isDir) {
|
|
||||||
await navigate(file.path)
|
|
||||||
} else {
|
|
||||||
selectFile(file.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleFavorite = (file: FileItem) => {
|
const handleToggleFavorite = (file: FileItem) => {
|
||||||
toggleFav(file)
|
toggleFav(file)
|
||||||
@@ -1064,38 +1063,108 @@ const isMediaPreviewable = (filename: string): boolean => {
|
|||||||
isPdfFile(filename)
|
isPdfFile(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 激活 tab:设置选中项 + 加载或恢复内容 */
|
||||||
|
const activateTab = async (tab: PreviewTab) => {
|
||||||
|
selectedFileItem.value = tab.fileItem
|
||||||
|
if (tab.loaded) {
|
||||||
|
restoreTabState(tab)
|
||||||
|
} else {
|
||||||
|
await loadFileContent(tab.fileItem.path)
|
||||||
|
tab.loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件 → 添加到 tab 并激活 */
|
||||||
|
const openFileAsTab = async (file: FileItem) => {
|
||||||
|
cacheCurrentTabState()
|
||||||
|
const { tab } = multiPreview.addTab(file)
|
||||||
|
await activateTab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换 tab */
|
||||||
|
const switchToTab = async (tabId: string) => {
|
||||||
|
cacheCurrentTabState()
|
||||||
|
multiPreview.switchTab(tabId)
|
||||||
|
const tab = multiPreview.activeTab.value
|
||||||
|
if (tab) await activateTab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭 tab */
|
||||||
|
const closeTab = (tabId: string) => {
|
||||||
|
const tab = multiPreview.tabs.value.find(t => t.id === tabId)
|
||||||
|
|
||||||
|
if (tab && isDirty(tab)) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '未保存的修改',
|
||||||
|
content: `「${tab!.fileItem.name}」有未保存的修改,关闭后将丢失。`,
|
||||||
|
okText: '关闭',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => doCloseTab(tabId)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doCloseTab(tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doCloseTab = (tabId: string) => {
|
||||||
|
const newActiveId = multiPreview.removeTab(tabId)
|
||||||
|
if (newActiveId) {
|
||||||
|
const tab = multiPreview.activeTab.value
|
||||||
|
if (tab) activateTab(tab)
|
||||||
|
} else {
|
||||||
|
selectedFileItem.value = null
|
||||||
|
clearContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存当前 tab 的编辑状态 */
|
||||||
|
const cacheCurrentTabState = () => {
|
||||||
|
multiPreview.cacheCurrentTab({
|
||||||
|
previewUrl: previewUrl.value,
|
||||||
|
fileContent: fileContent.value,
|
||||||
|
originalContent: originalContent.value,
|
||||||
|
isEditMode: isEditMode.value,
|
||||||
|
rendered: computeRendered.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 活跃 tab 内容实时同步(脏标记用,完整缓存由 cacheCurrentTabState 负责) */
|
||||||
|
watch([fileContent, originalContent], () => {
|
||||||
|
const tab = multiPreview.activeTab.value
|
||||||
|
if (tab) {
|
||||||
|
tab.fileContent = fileContent.value
|
||||||
|
tab.originalContent = originalContent.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 从 tab 缓存恢复编辑状态 */
|
||||||
|
const restoreTabState = (tab: PreviewTab) => {
|
||||||
|
previewUrl.value = tab.previewUrl
|
||||||
|
fileContent.value = tab.fileContent
|
||||||
|
originalContent.value = tab.originalContent
|
||||||
|
isEditMode.value = tab.isEditMode
|
||||||
|
fileVersion.value++
|
||||||
|
}
|
||||||
|
|
||||||
const selectFile = async (path: string) => {
|
const selectFile = async (path: string) => {
|
||||||
// 后端已统一返回 / 路径,直接比较
|
|
||||||
const normalizedPath = path.toLowerCase()
|
const normalizedPath = path.toLowerCase()
|
||||||
|
|
||||||
// 尝试在当前文件列表中查找
|
const file = fileList.value.find(f => f.path.toLowerCase() === normalizedPath)
|
||||||
const file = fileList.value.find(f => {
|
|
||||||
const normalizedFilePath = f.path.toLowerCase()
|
|
||||||
return normalizedFilePath === normalizedPath
|
|
||||||
})
|
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// 在当前列表中找到的文件
|
await openFileAsTab(file)
|
||||||
selectedFileItem.value = file
|
|
||||||
} else {
|
} else {
|
||||||
// 不在当前列表中的文件(如收藏夹中的其他目录文件)
|
|
||||||
// 构造一个基本的 FileItem 对象
|
|
||||||
const fileName = getFileName(path)
|
const fileName = getFileName(path)
|
||||||
selectedFileItem.value = {
|
await openFileAsTab({
|
||||||
path,
|
path,
|
||||||
name: fileName,
|
name: fileName,
|
||||||
isDir: false,
|
isDir: false,
|
||||||
size: 0,
|
size: 0,
|
||||||
modified_time: '',
|
modified_time: '',
|
||||||
is_favorite: isFavorite(path)
|
is_favorite: isFavorite(path)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载文件内容
|
|
||||||
await loadFileContent(path)
|
|
||||||
|
|
||||||
// 记住上次打开的文件
|
|
||||||
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadFileContent = async (path: string) => {
|
const loadFileContent = async (path: string) => {
|
||||||
@@ -1309,16 +1378,41 @@ onMounted(async () => {
|
|||||||
await loadDirectory(startPath)
|
await loadDirectory(startPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 恢复上次打开的文件
|
// 恢复多文件预览会话
|
||||||
const lastFile = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
|
const session = multiPreview.restoreSession()
|
||||||
if (lastFile) {
|
if (session.paths.length > 0 && !connectionManager.isRemote()) {
|
||||||
const normalized = lastFile.replace(/\\/g, '/').replace(/\/+$/, '')
|
for (const path of session.paths) {
|
||||||
const currentDir = filePath.value.replace(/\\/g, '/').replace(/\/+$/, '')
|
const name = path.split(/[/\\]/).pop() || path
|
||||||
const lastFileDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/'
|
const { tab } = multiPreview.addTab({ path, name, isDir: false, size: 0, modified_time: '' })
|
||||||
if (lastFileDir.toLowerCase() === currentDir.toLowerCase()) {
|
if (path === session.activePath || path.replace(/\\/g, '/').toLowerCase() === (session.activePath || '').replace(/\\/g, '/').toLowerCase()) {
|
||||||
const found = fileList.value.find(f => f.path.replace(/\\/g, '/').toLowerCase() === normalized.toLowerCase())
|
multiPreview.activeTabId.value = tab.id
|
||||||
if (found && !found.isDir) {
|
}
|
||||||
await selectFile(found.path)
|
multiPreview.applyUnsavedContent(tab, session.unsavedMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载激活 tab 的内容
|
||||||
|
const active = multiPreview.activeTab.value
|
||||||
|
if (active) {
|
||||||
|
selectedFileItem.value = active.fileItem
|
||||||
|
if (active.loaded) {
|
||||||
|
restoreTabState(active)
|
||||||
|
} else {
|
||||||
|
await loadFileContent(active.fileItem.path)
|
||||||
|
active.loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无会话记录,回退到旧逻辑:恢复上次打开的单个文件
|
||||||
|
const lastFile = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
|
||||||
|
if (lastFile) {
|
||||||
|
const normalized = lastFile.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
const currentDir = filePath.value.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
const lastFileDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/'
|
||||||
|
if (lastFileDir.toLowerCase() === currentDir.toLowerCase()) {
|
||||||
|
const found = fileList.value.find(f => f.path.replace(/\\/g, '/').toLowerCase() === normalized.toLowerCase())
|
||||||
|
if (found && !found.isDir) {
|
||||||
|
await selectFile(found.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1335,6 +1429,9 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('keydown', handleKeyDown)
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
window.removeEventListener('click', hideContextMenu)
|
window.removeEventListener('click', hideContextMenu)
|
||||||
window.removeEventListener('paste', handlePaste)
|
window.removeEventListener('paste', handlePaste)
|
||||||
|
// 应用关闭时立即持久化当前会话
|
||||||
|
cacheCurrentTabState()
|
||||||
|
multiPreview.persistSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 键盘快捷键
|
// 键盘快捷键
|
||||||
|
|||||||
@@ -203,6 +203,10 @@ export interface FileEditorPanelConfig {
|
|||||||
isBinaryFile: boolean
|
isBinaryFile: boolean
|
||||||
/** 文件修改时间(用于检测外部变更) */
|
/** 文件修改时间(用于检测外部变更) */
|
||||||
fileMtime: string
|
fileMtime: string
|
||||||
|
/** 多文件预览 Tab 列表 */
|
||||||
|
previewTabs?: any[]
|
||||||
|
/** 当前激活的 Tab ID */
|
||||||
|
activeTabId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const STORAGE_KEYS = {
|
|||||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||||
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
||||||
|
MULTI_PREVIEW_TABS: 'app-filesystem-multi-preview-tabs', // 多文件预览 tab 会话
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设备测试模块
|
// 设备测试模块
|
||||||
|
|||||||
@@ -123,12 +123,9 @@ func (v *DefaultPathValidator) checkWindowsSystemPaths(path string) *ValidationE
|
|||||||
if len(lowerPath) >= 3 && lowerPath[1] == ':' {
|
if len(lowerPath) >= 3 && lowerPath[1] == ':' {
|
||||||
driveLetter := lowerPath[0:1]
|
driveLetter := lowerPath[0:1]
|
||||||
|
|
||||||
// 检查系统关键目录(仅保留最关键的系统目录)
|
// 检查系统核心目录
|
||||||
forbiddenDirs := []string{
|
forbiddenDirs := []string{
|
||||||
driveLetter + ":\\windows",
|
driveLetter + ":\\windows",
|
||||||
driveLetter + ":\\program files",
|
|
||||||
driveLetter + ":\\program files (x86)",
|
|
||||||
driveLetter + ":\\program files (arm)",
|
|
||||||
driveLetter + ":\\system volume information",
|
driveLetter + ":\\system volume information",
|
||||||
driveLetter + ":\\boot",
|
driveLetter + ":\\boot",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user