refactor(ui): add context menu and file content viewer to Docs view

Introduce ContextMenu.vue and FileContent.vue components for interactive file operations
and file preview.

Update FileTree to support root drop, integrate the new components into DocsView,
and refresh i18n strings for file actions.

Refactor MilkdownEditor to embed TTS menu and player.
This commit is contained in:
2026-04-05 23:30:01 +08:00
parent 01b132266a
commit c70cb2a9f0
6 changed files with 1258 additions and 21 deletions
+463 -13
View File
@@ -1,40 +1,490 @@
<script setup>
import { useSettingsStore } from '../stores/settings'
import { ref, onMounted, computed } from 'vue'
import { useFileSystem } from '../composables/useFileSystem'
import FileTree from '../components/FileTree.vue'
import FileContent from '../components/FileContent.vue'
import ContextMenu from '../components/ContextMenu.vue'
const settings = useSettingsStore()
const t = (key) => settings.t[key]
const fs = useFileSystem()
const sidebarCollapsed = ref(false)
const confirmDialog = ref(null)
onMounted(() => {
fs.load()
})
const selectedNode = computed(() => fs.getSelectedNode())
const breadcrumb = computed(() => {
if (!fs.selectedId.value) return []
return fs.getBreadcrumbPath(fs.selectedId.value)
})
function handleCreateFile(parentId) {
const name = 'untitled.md'
fs.createFile(parentId, name)
}
function handleCreateFolder(parentId) {
const name = '新建文件夹'
fs.createFolder(parentId, name)
}
function handleRename(id, newName) {
fs.rename(id, newName)
}
function handleDelete(id) {
const node = fs.tree.value.find(n => n.id === id) || findNode(fs.tree.value, id)
if (node) {
confirmDialog.value = {
id,
name: node.name,
type: node.type
}
}
}
function confirmDelete() {
if (confirmDialog.value) {
fs.remove(confirmDialog.value.id)
confirmDialog.value = null
}
}
function cancelDelete() {
confirmDialog.value = null
}
function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return node
if (node.type === 'folder') {
const found = findNode(node.children || [], id)
if (found) return found
}
}
return null
}
function handleContextMenu(x, y, node) {
fs.showContextMenu(x, y, node)
}
function handleDrop(draggedId, targetParentId) {
if (draggedId === targetParentId) return
const draggedNode = findNode(fs.tree.value, draggedId)
if (!draggedNode) return
if (targetParentId && isDescendant(draggedNode, targetParentId)) return
const oldParent = findParent(fs.tree.value, draggedId)
if (oldParent) {
oldParent.children = (oldParent.children || []).filter(c => c.id !== draggedId)
} else {
fs.tree.value = fs.tree.value.filter(n => n.id !== draggedId)
}
draggedNode.parentId = targetParentId || null
if (targetParentId) {
const target = findNode(fs.tree.value, targetParentId)
if (target && target.type === 'folder') {
target.children = target.children || []
target.children.push(draggedNode)
}
} else {
fs.tree.value.push(draggedNode)
}
}
function isDescendant(node, targetId) {
if (node.type !== 'folder') return false
for (const child of (node.children || [])) {
if (child.id === targetId) return true
if (isDescendant(child, targetId)) return true
}
return false
}
function findParent(nodes, id, parent = null) {
for (const node of nodes) {
if (node.id === id) return parent
if (node.type === 'folder') {
const found = findParent(node.children || [], id, node)
if (found !== undefined) return found
}
}
return undefined
}
function handleDragStart(event, id) {
event.dataTransfer.setData('text/plain', id)
}
function handleDragOver(event) {
event.preventDefault()
}
</script>
<template>
<div class="docs-view">
<div class="docs-empty">
<h2>{{ t('docsManagement') || '文档管理' }}</h2>
<p>{{ t('docsEmptyDesc') || '文档管理界面开发中...' }}</p>
<div class="docs-layout">
<div v-show="!sidebarCollapsed" class="docs-sidebar">
<FileTree
:nodes="fs.tree.value"
:selected-id="fs.selectedId.value"
:expanded-ids="fs.expandedIds.value"
:clipboard="fs.clipboard.value"
:get-file-icon="fs.getFileIcon"
@select="fs.select"
@toggle="fs.toggleFolder"
@create-file="handleCreateFile"
@create-folder="handleCreateFolder"
@rename="handleRename"
@remove="handleDelete"
@copy="fs.copy"
@cut="fs.cut"
@paste="fs.paste"
@context-menu="handleContextMenu"
@drop="handleDrop"
@drag-start="handleDragStart"
@drag-over="handleDragOver"
/>
</div>
<div class="docs-main">
<div class="docs-toolbar">
<button class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed" :title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'">
<svg v-if="!sidebarCollapsed" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.5 3.5a.5.5 0 00-.707.707L6.586 7l-2.793 2.793a.5.5 0 10.707.707l3-3a.5.5 0 000-.707l-3-3z"/><path d="M9.5 3.5a.5.5 0 01.707.707L7.414 7l2.793 2.793a.5.5 0 01-.707.707l-3-3a.5.5 0 010-.707l3-3z"/></svg>
<svg v-else viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M11.5 3.5a.5.5 0 01.707.707L9.414 7l2.793 2.793a.5.5 0 01-.707.707l-3-3a.5.5 0 010-.707l3-3z"/><path d="M4.5 3.5a.5.5 0 00-.707.707L6.586 7l-2.793 2.793a.5.5 0 10.707.707l3-3a.5.5 0 000-.707l-3-3z"/></svg>
</button>
<div class="breadcrumb-bar">
<template v-for="(item, index) in breadcrumb" :key="item.id">
<span
v-if="item.type === 'folder' && index < breadcrumb.length - 1"
class="breadcrumb-link"
@click="fs.select(item.id)"
>{{ item.name }}</span>
<span v-else class="breadcrumb-current">{{ item.name }}</span>
<span v-if="index < breadcrumb.length - 1" class="breadcrumb-sep">/</span>
</template>
<span v-if="breadcrumb.length === 0" class="breadcrumb-root">根目录</span>
</div>
<div class="toolbar-actions">
<button v-if="fs.canPaste()" class="toolbar-btn" @click="fs.paste(selectedNode && selectedNode.type === 'folder' ? selectedNode.id : null)" title="粘贴">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v.59c0 .396.316.717.707.717h5.586c.39 0 .707-.32.707-.716v-.591a.25.25 0 00-.25-.25H4.75zm6.543-.75a1.75 1.75 0 011.75 1.75v.59c0 .396-.107.767-.293 1.086l1.293 1.293a.75.75 0 010 1.061l-1.293 1.293c.186.32.293.69.293 1.087v.59a1.75 1.75 0 01-1.75 1.75H4.75a1.75 1.75 0 01-1.75-1.75v-.59c0-.396.107-.767.293-1.087L2 5.53a.75.75 0 010-1.06l1.293-1.294A2.048 2.048 0 013 2.09v-.59A1.75 1.75 0 014.75 0h6.543zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
粘贴
</button>
</div>
</div>
<FileContent
:node="selectedNode"
:breadcrumb="breadcrumb"
@navigate="fs.select"
/>
</div>
</div>
<ContextMenu
:visible="!!fs.contextMenu.value"
:x="fs.contextMenu.value?.x || 0"
:y="fs.contextMenu.value?.y || 0"
:node="fs.contextMenu.value?.node || null"
:can-paste="fs.canPaste()"
@close="fs.hideContextMenu()"
@rename="(id) => { fs.hideContextMenu(); }"
@delete="(id) => { fs.hideContextMenu(); handleDelete(id) }"
@copy="(id) => { fs.hideContextMenu(); fs.copy(id) }"
@cut="(id) => { fs.hideContextMenu(); fs.cut(id) }"
@paste="(parentId) => { fs.hideContextMenu(); fs.paste(parentId) }"
@new-file="(parentId) => { fs.hideContextMenu(); handleCreateFile(parentId) }"
@new-folder="(parentId) => { fs.hideContextMenu(); handleCreateFolder(parentId) }"
/>
<Teleport to="body">
<div v-if="confirmDialog" class="confirm-overlay">
<div class="confirm-dialog">
<div class="confirm-icon">
<svg viewBox="0 0 24 24" width="32" height="32" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h3>确认删除</h3>
<p>确定要删除 <strong>{{ confirmDialog.name }}</strong> 吗?{{ confirmDialog.type === 'folder' ? '此操作将删除文件夹内的所有内容。' : '此操作不可撤销。' }}</p>
<div class="confirm-actions">
<button class="btn-cancel" @click="cancelDelete">取消</button>
<button class="btn-delete" @click="confirmDelete">删除</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="fs.error" class="error-toast">
<div class="error-content">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 100 16A8 8 0 008 0zm3.78 11.22a.75.75 0 01-1.06 0L8 8.56 5.28 11.28a.75.75 0 01-1.06-1.06L6.94 7.5 4.22 4.78a.75.75 0 011.06-1.06L8 6.44l2.72-2.72a.75.75 0 111.06 1.06L9.06 7.5l2.72 2.72a.75.75 0 010 1.06z"/></svg>
<span>{{ fs.error }}</span>
<button class="error-close" @click="fs.error = null">&times;</button>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.docs-view {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.docs-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.docs-sidebar {
width: 280px;
min-width: 200px;
max-width: 400px;
height: 100%;
border-right: 1px solid var(--panel-border);
flex-shrink: 0;
overflow: hidden;
}
.docs-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.docs-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--panel-border);
min-height: 44px;
flex-shrink: 0;
}
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--panel-border);
background: var(--app-bg);
color: var(--muted-text);
cursor: pointer;
border-radius: 6px;
flex-shrink: 0;
}
.docs-empty {
text-align: center;
.sidebar-toggle:hover {
color: var(--app-text);
border-color: var(--focus-ring);
}
.breadcrumb-bar {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
overflow: hidden;
white-space: nowrap;
}
.breadcrumb-link {
color: var(--focus-ring);
cursor: pointer;
}
.breadcrumb-link:hover {
text-decoration: underline;
}
.breadcrumb-current {
color: var(--app-text);
font-weight: 500;
}
.breadcrumb-sep {
color: var(--muted-text);
}
.docs-empty h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
.breadcrumb-root {
color: var(--muted-text);
}
.toolbar-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid var(--panel-border);
background: var(--app-bg);
color: var(--app-text);
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.toolbar-btn:hover {
border-color: var(--focus-ring);
color: var(--focus-ring);
}
.confirm-overlay {
position: fixed;
inset: 0;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 100001;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.confirm-dialog {
background: var(--panel-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--panel-border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
padding: 24px;
max-width: 400px;
width: 90%;
text-align: center;
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.confirm-icon {
color: var(--danger-text);
margin-bottom: 12px;
}
.confirm-dialog h3 {
margin: 0 0 8px;
font-size: 1.1rem;
}
.confirm-dialog p {
margin: 0 0 20px;
color: var(--muted-text);
font-size: 0.9rem;
line-height: 1.5;
}
.confirm-dialog p strong {
color: var(--app-text);
}
.docs-empty p {
font-size: 1rem;
.confirm-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.btn-cancel,
.btn-delete {
padding: 8px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
border: 1px solid var(--panel-border);
font-weight: 500;
}
.btn-cancel {
background: var(--app-bg);
color: var(--app-text);
}
.btn-cancel:hover {
background: var(--ghost-code-bg);
}
.btn-delete {
background: var(--danger-text);
color: #fff;
border-color: var(--danger-text);
}
.btn-delete:hover {
opacity: 0.9;
}
.error-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 100002;
animation: slideUp 0.2s ease;
}
.error-content {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--danger-text);
color: #fff;
border-radius: 8px;
font-size: 13px;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
.error-close {
background: none;
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.error-close:hover {
opacity: 0.8;
}
@media (max-width: 768px) {
.docs-sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 9000;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
}
</style>