Files
llm-in-text/src/views/DocsView.vue
T

445 lines
11 KiB
Vue
Raw Normal View History

<script setup>
import { computed, onMounted, ref } from 'vue'
import FileTree from '../components/FileTree.vue'
import FileContent from '../components/FileContent.vue'
import ContextMenu from '../components/ContextMenu.vue'
import { useFileSystem } from '../composables/useFileSystem'
const fs = useFileSystem()
const sidebarCollapsed = ref(false)
const confirmDialog = ref(null)
onMounted(() => {
fs.load()
})
const selectedNode = computed(() => fs.getSelectedNode())
const breadcrumb = computed(() => (fs.selectedId.value ? fs.getBreadcrumbPath(fs.selectedId.value) : []))
const storageSummary = computed(() => {
const bytes = fs.stats.value.usedBytes
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
return `${value >= 100 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`
})
async function handleCreateFile(parentId, name = 'untitled.md') {
await fs.createFile(parentId, name)
}
async function handleCreateFolder(parentId, name = '新建文件夹') {
await fs.createFolder(parentId, name)
}
async function handleRename(id, newName) {
await fs.rename(id, newName)
}
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 handleDelete(id) {
const node = findNode(fs.tree.value, id)
if (!node) return
confirmDialog.value = {
id,
name: node.name,
type: node.type
}
}
async function confirmDelete() {
if (!confirmDialog.value) return
await fs.remove(confirmDialog.value.id)
confirmDialog.value = null
}
function handleContextMenu(x, y, node) {
fs.showContextMenu(x, y, node)
}
async function handleDrop(draggedId, targetParentId) {
if (draggedId === targetParentId) return
fs.cut(draggedId)
await fs.paste(targetParentId)
}
async function handleUploadFiles(files) {
const parentId = selectedNode.value?.type === 'folder' ? selectedNode.value.id : selectedNode.value?.parentId || null
const result = await fs.uploadFiles(files, parentId)
if (result.failed.length > 0) {
fs.error.value = result.failed.map((item) => `${item.name}${item.reason}`).join('')
}
}
function closeConfirm() {
confirmDialog.value = null
}
</script>
<template>
<div class="docs-view">
<div class="docs-layout">
<aside 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"
:loading="fs.loading.value"
:stats="fs.stats.value"
@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="() => {}"
@drag-over="() => {}"
@upload-files="handleUploadFiles"
/>
</aside>
<section class="docs-main">
<header v-if="selectedNode?.type !== 'file'" class="docs-toolbar">
<div class="toolbar-left">
<button
class="sidebar-toggle"
type="button"
:title="sidebarCollapsed ? '展开左侧栏' : '收起左侧栏'"
@click="sidebarCollapsed = !sidebarCollapsed"
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"><path d="M2.75 2A1.75 1.75 0 001 3.75v8.5C1 13.216 1.784 14 2.75 14h10.5A1.75 1.75 0 0015 12.25v-8.5A1.75 1.75 0 0013.25 2H2.75zm0 1.5h2.5v9h-2.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25zm4 9v-9h6.5a.25.25 0 01.25.25v8.5a.25.25 0 01-.25.25h-6.5z"/></svg>
</button>
<div class="toolbar-breadcrumb">
<button class="crumb-link" type="button" @click="fs.select(null)">workspace</button>
<template v-for="item in breadcrumb" :key="item.id">
<span class="crumb-sep">/</span>
<button
v-if="item.id !== selectedNode?.id"
class="crumb-link"
type="button"
@click="fs.select(item.id)"
>
{{ item.name }}
</button>
<span v-else class="crumb-current">{{ item.name }}</span>
</template>
</div>
</div>
<div class="toolbar-right">
<span class="storage-pill">文档空间 {{ storageSummary }}</span>
<button
v-if="fs.canPaste()"
class="toolbar-btn"
type="button"
@click="fs.paste(selectedNode?.type === 'folder' ? selectedNode.id : selectedNode?.parentId || null)"
>
粘贴
</button>
</div>
</header>
<FileContent
:node="selectedNode"
:breadcrumb="breadcrumb"
:root-nodes="fs.tree.value"
:get-file-icon="fs.getFileIcon"
:get-file-blob="fs.getFileBlob"
:update-file="fs.updateFile"
:show-sidebar-toggle="selectedNode?.type === 'file'"
@navigate="fs.select"
@toggle-sidebar="sidebarCollapsed = !sidebarCollapsed"
/>
</section>
</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="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">
<h3>确认删除</h3>
<p>
确定要删除 <strong>{{ confirmDialog.name }}</strong>
{{ confirmDialog.type === 'folder' ? ' 以及其中的全部内容' : '' }} 吗?
</p>
<div class="confirm-actions">
<button class="btn btn-secondary" type="button" @click="closeConfirm">取消</button>
<button class="btn btn-danger" type="button" @click="confirmDelete">删除</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="fs.error.value" class="error-toast">
<div class="error-content">
<span>{{ fs.error.value }}</span>
<button class="error-close" type="button" @click="fs.error.value = null">×</button>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.docs-view {
width: 100%;
2026-06-27 22:22:42 +08:00
height: 100dvh;
min-height: 0;
overflow: hidden;
background: var(--github-bg);
}
.docs-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
height: 100%;
2026-06-27 22:22:42 +08:00
min-height: 0;
}
.docs-sidebar {
min-width: 0;
2026-06-27 22:22:42 +08:00
overflow: auto;
border-right: 1px solid var(--github-border);
background: var(--github-bg);
}
.docs-main {
min-width: 0;
display: flex;
flex-direction: column;
}
.docs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
padding: 0 18px;
border-bottom: 1px solid var(--github-border);
background: var(--github-bg);
}
.toolbar-left,
.toolbar-right,
.toolbar-breadcrumb {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-breadcrumb {
min-width: 0;
flex-wrap: wrap;
}
.sidebar-toggle,
.toolbar-btn,
.crumb-link {
border: none;
background: transparent;
cursor: pointer;
}
.sidebar-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 1px solid var(--github-border);
border-radius: 8px;
color: var(--github-text-secondary);
background: var(--github-bg);
}
.sidebar-toggle:hover,
.toolbar-btn:hover {
background: var(--github-hover);
}
.crumb-link {
color: #0969da;
font-size: 14px;
}
.crumb-current {
color: var(--github-text);
font-size: 14px;
font-weight: 700;
}
.crumb-sep {
color: var(--github-text-secondary);
}
.storage-pill {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 999px;
background: var(--github-hover);
color: var(--github-text-secondary);
font-size: 12px;
font-weight: 700;
}
.toolbar-btn {
height: 32px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-bg);
color: var(--github-text);
font-size: 13px;
font-weight: 600;
}
.confirm-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.35);
z-index: 10000;
}
.confirm-dialog {
width: min(420px, calc(100vw - 32px));
padding: 24px;
border: 1px solid var(--github-border);
border-radius: 16px;
background: var(--github-bg);
box-shadow: 0 28px 60px rgba(15, 23, 42, 0.22);
}
.confirm-dialog h3 {
margin: 0 0 10px;
font-size: 1.1rem;
}
.confirm-dialog p {
margin: 0;
color: var(--github-text-secondary);
line-height: 1.7;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
.btn {
height: 36px;
padding: 0 14px;
border: 1px solid var(--github-border);
border-radius: 10px;
font-weight: 700;
cursor: pointer;
}
.btn-secondary {
background: var(--github-bg);
color: var(--github-text);
}
.btn-danger {
border-color: #cf222e;
background: #cf222e;
color: #fff;
}
.error-toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 10001;
}
.error-content {
display: flex;
align-items: center;
gap: 12px;
max-width: min(720px, calc(100vw - 32px));
padding: 12px 16px;
border-radius: 12px;
background: #cf222e;
color: #fff;
box-shadow: 0 18px 40px rgba(207, 34, 46, 0.28);
}
.error-close {
border: none;
background: transparent;
color: inherit;
font-size: 18px;
cursor: pointer;
}
@media (max-width: 900px) {
.docs-layout {
grid-template-columns: minmax(0, 1fr);
}
.docs-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 999;
width: min(88vw, 320px);
2026-06-27 22:22:42 +08:00
overflow: auto;
box-shadow: 20px 0 40px rgba(15, 23, 42, 0.16);
}
.docs-toolbar {
padding: 10px 14px;
align-items: flex-start;
flex-direction: column;
}
}
</style>