Private
Public Access
1
0

新增:多文件预览Tab系统+脏标记+关闭确认+路径黑名单优化

- useMultiPreview composable管理多Tab状态、会话持久化
- 面包屑状态dot移除
- 放开Program Files目录访问限制
This commit is contained in:
2026-05-05 00:08:15 +08:00
parent ee4b1f5ac1
commit eb5b85e007
7 changed files with 558 additions and 48 deletions

View File

@@ -6,7 +6,6 @@
<!-- 有远程配置完整标签 + 下拉菜单 -->
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
<span :class="['dot', state]" />
<span class="label">{{ label }}</span>
<div v-if="showMenu" class="menu" @click.stop>

View File

@@ -360,6 +360,34 @@
</div>
</div>
</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>
</template>
@@ -373,6 +401,8 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme'
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
import { getFileCategory } from '@/utils/fileTypeHelpers'
import { isDirty } from '../composables/useMultiPreview'
import { connectionManager } from '@/api/connection-manager'
// 异步加载 CodeEditor 组件,减少初始包大小
@@ -435,10 +465,26 @@ interface Emits {
(e: 'imageLoad', dimensions: string): void
(e: 'imageError'): void
(e: 'openLocalFile', link: string): void
(e: 'switchTab', tabId: string): void
(e: 'closeTab', tabId: string): void
}
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 读取,不缓存)
function resolveHtmlPreviewBase(): string {
if (!connectionManager.isRemote()) return 'http://localhost:2652'
@@ -868,6 +914,7 @@ onUnmounted(() => {
flex-direction: column;
height: 100%;
background: var(--color-bg-1);
position: relative;
}
.file-editor-panel:fullscreen {
@@ -1430,4 +1477,129 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
color: var(--hljs-link);
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>

View File

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

View File

@@ -8,7 +8,7 @@
:config="sidebarConfig"
@open-favorite="handleOpenFavorite"
@remove-favorite="handleRemoveFavorite"
@toggle-pin="handleTogglePin"
@toggle-pin="handleToggleFavPin"
@long-press-start="handleLongPressStart"
@long-press-cancel="handleLongPressCancel"
@drag-start="handleDragStart"
@@ -75,6 +75,8 @@
@image-load="handleImageLoad"
@image-error="handleImageError"
@open-local-file="handleOpenLocalFile"
@switch-tab="switchToTab"
@close-tab="closeTab"
/>
</div>
</div>
@@ -130,6 +132,7 @@ import { usePathNavigation } from './composables/usePathNavigation'
import { useFilePreview } from './composables/useFilePreview'
import { useFileEdit } from './composables/useFileEdit'
import { useCommonPaths } from './composables/useCommonPaths'
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
// 导入工具函数
import { getFileName, sortFileList } from '@/utils/fileUtils'
@@ -311,6 +314,9 @@ const { fileContent, originalContent, isEditMode, fileContentHeight, contentChan
currentDirectory: filePath
})
// 多文件预览 Tab
const multiPreview = useMultiPreview()
// ========== 计算属性 ==========
const favoritePaths = computed(() => favorites.value.map(f => f.path))
@@ -413,7 +419,10 @@ const fileEditorPanelConfig = computed(() => {
currentImageDimensions: currentImageDimensions.value,
currentFileExtension,
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)
}
const handleTogglePin = (path: string) => {
const handleToggleFavPin = (path: string) => {
togglePin(path)
}
@@ -610,24 +619,14 @@ const handleDragEnd = () => {
// 文件列表事件
const handleFileClick = async (file: FileItem) => {
// 正常文件系统浏览
if (file.isDir) {
// 目录:使用 navigate 函数,确保历史记录正确更新
await navigate(file.path)
} else {
// 文件:选中并读取内容
selectedFileItem.value = file
loadFileContent(file.path)
openFileAsTab(file)
}
}
const handleFileDoubleClick = async (file: FileItem) => {
if (file.isDir) {
await navigate(file.path)
} else {
selectFile(file.path)
}
}
const handleFileDoubleClick = handleFileClick
const handleToggleFavorite = (file: FileItem) => {
toggleFav(file)
@@ -1064,40 +1063,110 @@ const isMediaPreviewable = (filename: string): boolean => {
isPdfFile(filename)
}
const selectFile = async (path: string) => {
// 后端已统一返回 / 路径,直接比较
const normalizedPath = path.toLowerCase()
/** 激活 tab设置选中项 + 加载或恢复内容 */
const activateTab = async (tab: PreviewTab) => {
selectedFileItem.value = tab.fileItem
if (tab.loaded) {
restoreTabState(tab)
} else {
await loadFileContent(tab.fileItem.path)
tab.loaded = true
}
}
// 尝试在当前文件列表中查找
const file = fileList.value.find(f => {
const normalizedFilePath = f.path.toLowerCase()
return normalizedFilePath === normalizedPath
/** 文件 → 添加到 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 normalizedPath = path.toLowerCase()
const file = fileList.value.find(f => f.path.toLowerCase() === normalizedPath)
if (file) {
// 在当前列表中找到的文件
selectedFileItem.value = file
await openFileAsTab(file)
} else {
// 不在当前列表中的文件(如收藏夹中的其他目录文件)
// 构造一个基本的 FileItem 对象
const fileName = getFileName(path)
selectedFileItem.value = {
await openFileAsTab({
path,
name: fileName,
isDir: false,
size: 0,
modified_time: '',
is_favorite: isFavorite(path)
})
}
}
// 加载文件内容
await loadFileContent(path)
// 记住上次打开的文件
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
}
const loadFileContent = async (path: string) => {
try {
updatePreviewUrl(path)
@@ -1309,7 +1378,31 @@ onMounted(async () => {
await loadDirectory(startPath)
}
// 恢复上次打开的文件
// 恢复多文件预览会话
const session = multiPreview.restoreSession()
if (session.paths.length > 0 && !connectionManager.isRemote()) {
for (const path of session.paths) {
const name = path.split(/[/\\]/).pop() || path
const { tab } = multiPreview.addTab({ path, name, isDir: false, size: 0, modified_time: '' })
if (path === session.activePath || path.replace(/\\/g, '/').toLowerCase() === (session.activePath || '').replace(/\\/g, '/').toLowerCase()) {
multiPreview.activeTabId.value = tab.id
}
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(/\/+$/, '')
@@ -1322,6 +1415,7 @@ onMounted(async () => {
}
}
}
}
// 添加键盘快捷键
window.addEventListener('keydown', handleKeyDown)
@@ -1335,6 +1429,9 @@ onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('click', hideContextMenu)
window.removeEventListener('paste', handlePaste)
// 应用关闭时立即持久化当前会话
cacheCurrentTabState()
multiPreview.persistSession()
})
// 键盘快捷键

View File

@@ -203,6 +203,10 @@ export interface FileEditorPanelConfig {
isBinaryFile: boolean
/** 文件修改时间(用于检测外部变更) */
fileMtime: string
/** 多文件预览 Tab 列表 */
previewTabs?: any[]
/** 当前激活的 Tab ID */
activeTabId?: string | null
}
/**

View File

@@ -31,6 +31,7 @@ export const STORAGE_KEYS = {
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
MULTI_PREVIEW_TABS: 'app-filesystem-multi-preview-tabs', // 多文件预览 tab 会话
},
// 设备测试模块

View File

@@ -123,12 +123,9 @@ func (v *DefaultPathValidator) checkWindowsSystemPaths(path string) *ValidationE
if len(lowerPath) >= 3 && lowerPath[1] == ':' {
driveLetter := lowerPath[0:1]
// 检查系统关键目录(仅保留最关键的系统目录)
// 检查系统核心目录
forbiddenDirs := []string{
driveLetter + ":\\windows",
driveLetter + ":\\program files",
driveLetter + ":\\program files (x86)",
driveLetter + ":\\program files (arm)",
driveLetter + ":\\system volume information",
driveLetter + ":\\boot",
}