fix(frontend): 修复合并审阅发现的构建与契约问题
恢复 Vue TypeScript 生产构建,补齐可运行页面壳子,并修复文件树与 SSE 状态问题。 按 FastAPI Wire Contract 统一 Service DTO 映射,同时补充前端开发说明和问题修复复盘。
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileNode } from '@/contracts'
|
||||
|
||||
defineProps<{ node: FileNode; activePath: string | null }>()
|
||||
const emit = defineEmits<{
|
||||
open: [node: FileNode]
|
||||
contextMenu: [event: MouseEvent, node: FileNode]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="tree-node" :class="{ active: node.path === activePath }"
|
||||
@click="emit('open', node)" @contextmenu="emit('contextMenu', $event, node)">
|
||||
<span>{{ node.type === 'folder' ? (node.is_open ? '📂' : '📁') : '📄' }}</span>
|
||||
<span class="name">{{ node.name }}</span>
|
||||
<span v-if="node.is_dirty">●</span>
|
||||
</div>
|
||||
<div v-if="node.type === 'folder' && node.is_open" class="children">
|
||||
<FileTreeNode v-for="child in node.children ?? []" :key="child.id" :node="child" :active-path="activePath"
|
||||
@open="emit('open', $event)" @context-menu="(event, target) => emit('contextMenu', event, target)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree-node { display: flex; align-items: center; gap: var(--space-xs); min-height: 28px; padding: 0 var(--space-sm); border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.tree-node:hover, .tree-node.active { background: var(--color-background-secondary); }
|
||||
.name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.children { padding-left: var(--space-md); }
|
||||
</style>
|
||||
@@ -1,406 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { FileNode } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import FileTreeNode from './FileTreeNode.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
const router = useRouter()
|
||||
const newItemType = ref<'file' | 'folder' | null>(null)
|
||||
const newItemName = ref('')
|
||||
const parentPath = ref('/')
|
||||
const contextTarget = ref<FileNode | null>(null)
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const showNewMenu = ref(false)
|
||||
const newFileName = ref('')
|
||||
const newFolderName = ref('')
|
||||
const newFileParentPath = ref('')
|
||||
const showNewFileInput = ref(false)
|
||||
const showNewFolderInput = ref(false)
|
||||
const contextMenuPath = ref<string | null>(null)
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuPos = ref({ x: 0, y: 0 })
|
||||
const renamingPath = ref<string | null>(null)
|
||||
const renameValue = ref('')
|
||||
|
||||
function toggleFolder(node: FileNode) {
|
||||
workspaceStore.toggleFolder(node.path)
|
||||
function beginCreate(type: 'file' | 'folder', parent = '/') {
|
||||
newItemType.value = type
|
||||
newItemName.value = ''
|
||||
parentPath.value = parent
|
||||
}
|
||||
|
||||
async function openFile(node: FileNode) {
|
||||
if (node.type === 'folder') {
|
||||
toggleFolder(node)
|
||||
return
|
||||
async function createItem() {
|
||||
const rawName = newItemName.value.trim()
|
||||
if (!rawName || !newItemType.value) return
|
||||
if (newItemType.value === 'file') {
|
||||
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
||||
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
|
||||
workspaceStore.addFileToTree(parentPath.value, file)
|
||||
workspaceStore.openFile(file.path)
|
||||
await editorStore.loadFile(file.path)
|
||||
await router.push('/workspace')
|
||||
} else {
|
||||
const folder = await workspaceService.createFolder(parentPath.value, rawName)
|
||||
workspaceStore.addFileToTree(parentPath.value, folder)
|
||||
}
|
||||
newItemType.value = null
|
||||
newItemName.value = ''
|
||||
}
|
||||
|
||||
async function openNode(node: FileNode) {
|
||||
if (node.type === 'folder') return workspaceStore.toggleFolder(node.path)
|
||||
workspaceStore.openFile(node.path)
|
||||
await editorStore.loadFile(node.path)
|
||||
router.push('/workspace')
|
||||
await router.push('/workspace')
|
||||
}
|
||||
|
||||
function startNewFile(parentPath = '') {
|
||||
newFileParentPath.value = parentPath
|
||||
showNewFileInput.value = true
|
||||
showNewMenu.value = false
|
||||
newFileName.value = ''
|
||||
function openContextMenu(event: MouseEvent, node: FileNode) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
contextTarget.value = node
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function startNewFolder(parentPath = '') {
|
||||
newFileParentPath.value = parentPath
|
||||
showNewFolderInput.value = true
|
||||
showNewMenu.value = false
|
||||
newFolderName.value = ''
|
||||
}
|
||||
function closeContextMenu() { contextTarget.value = null }
|
||||
|
||||
async function createFile() {
|
||||
if (!newFileName.value.trim()) return
|
||||
const name = newFileName.value.endsWith('.md') ? newFileName.value : `${newFileName.value}.md`
|
||||
const file = await workspaceService.createFile(newFileParentPath.value || '/', name, '# ' + newFileName.value + '\n\n')
|
||||
workspaceStore.addFileToTree(newFileParentPath.value || '/', file)
|
||||
workspaceStore.openFile(file.path)
|
||||
await editorStore.loadFile(file.path)
|
||||
showNewFileInput.value = false
|
||||
newFileName.value = ''
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
const folder = await workspaceService.createFolder(newFileParentPath.value || '/', newFolderName.value)
|
||||
workspaceStore.addFileToTree(newFileParentPath.value || '/', folder)
|
||||
showNewFolderInput.value = false
|
||||
newFolderName.value = ''
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent, node: FileNode) {
|
||||
e.preventDefault()
|
||||
contextMenuPath.value = node.path
|
||||
contextMenuPos.value = { x: e.clientX, y: e.clientY }
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
showContextMenu.value = false
|
||||
contextMenuPath.value = null
|
||||
}
|
||||
|
||||
function startRename(node: FileNode) {
|
||||
renamingPath.value = node.path
|
||||
renameValue.value = node.name
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
async function finishRename(node: FileNode) {
|
||||
if (renameValue.value && renameValue.value !== node.name) {
|
||||
await workspaceService.renameFile(node.path, renameValue.value)
|
||||
node.name = renameValue.value
|
||||
async function renameTarget() {
|
||||
const node = contextTarget.value
|
||||
if (!node) return
|
||||
const newName = window.prompt('新名称', node.name)?.trim()
|
||||
if (newName && newName !== node.name) {
|
||||
await workspaceService.renameFile(node.path, newName)
|
||||
node.name = newName
|
||||
}
|
||||
renamingPath.value = null
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
async function deleteNode(node: FileNode) {
|
||||
const confirmMsg = node.type === 'folder' ? `确定要删除文件夹 "${node.name}" 吗?` : `确定要删除笔记 "${node.name}" 吗?`
|
||||
if (confirm(confirmMsg)) {
|
||||
await workspaceService.deleteFile(node.path)
|
||||
workspaceStore.removeFromTree(node.path)
|
||||
if (node.type === 'file') {
|
||||
workspaceStore.closeFile(node.path)
|
||||
}
|
||||
}
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
function getFileIcon(name: string) {
|
||||
if (name.endsWith('.md')) return '📄'
|
||||
return '📄'
|
||||
async function deleteTarget() {
|
||||
const node = contextTarget.value
|
||||
if (!node) return
|
||||
if (!window.confirm(`确定要删除“${node.name}”吗?`)) return closeContextMenu()
|
||||
await workspaceService.deleteFile(node.path)
|
||||
workspaceStore.removeFromTree(node.path)
|
||||
if (node.type === 'file') workspaceStore.closeFile(node.path)
|
||||
closeContextMenu()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-tree-panel" @click="closeContextMenu">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="tool-btn" @click="startNewFile" title="新建笔记">
|
||||
<span>➕</span>
|
||||
</button>
|
||||
<button class="tool-btn" @click="startNewFolder" title="新建文件夹">
|
||||
<span>📁</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="tool-btn" title="刷新">
|
||||
<span>🔄</span>
|
||||
</button>
|
||||
<section class="file-tree-panel" @click="closeContextMenu">
|
||||
<div class="toolbar">
|
||||
<button type="button" title="新建笔记" @click.stop="beginCreate('file')">+📄</button>
|
||||
<button type="button" title="新建文件夹" @click.stop="beginCreate('folder')">+📁</button>
|
||||
</div>
|
||||
|
||||
<div class="new-input" v-if="showNewFileInput">
|
||||
<input
|
||||
v-model="newFileName"
|
||||
type="text"
|
||||
placeholder="笔记名称"
|
||||
@keyup.enter="createFile"
|
||||
@keyup.esc="showNewFileInput = false"
|
||||
autofocus
|
||||
/>
|
||||
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
|
||||
<input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus />
|
||||
<button type="submit">创建</button>
|
||||
<button type="button" @click="newItemType = null">取消</button>
|
||||
</form>
|
||||
<div class="tree">
|
||||
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
|
||||
:active-path="workspaceStore.activeFilePath" @open="openNode" @context-menu="openContextMenu" />
|
||||
</div>
|
||||
<div class="new-input" v-if="showNewFolderInput">
|
||||
<input
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
placeholder="文件夹名称"
|
||||
@keyup.enter="createFolder"
|
||||
@keyup.esc="showNewFolderInput = false"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tree-container">
|
||||
<template v-for="node in workspaceStore.fileTree" :key="node.id">
|
||||
<div class="tree-node-wrapper">
|
||||
<TreeNode :node="node" :depth="0" @open="openFile" @toggle="toggleFolder" @context-menu="onContextMenu"
|
||||
:renaming-path="renamingPath" :rename-value="renameValue"
|
||||
@rename-start="startRename" @rename-finish="finishRename"
|
||||
@delete-node="deleteNode"
|
||||
@new-file="startNewFile" @new-folder="startNewFolder" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showContextMenu" class="context-menu"
|
||||
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
|
||||
@click.stop>
|
||||
<button @click="() => { const n = workspaceStore.activeFile; if (n) startRename(n) }">✏️ 重命名</button>
|
||||
<button @click="() => { const n = workspaceStore.activeFile; if (n) deleteNode(n) }" class="danger">🗑️ 删除</button>
|
||||
<div v-if="contextTarget" class="context-menu"
|
||||
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
|
||||
<button @click="renameTarget">重命名</button>
|
||||
<button class="danger" @click="deleteTarget">删除</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h } from 'vue'
|
||||
import type { FileNode } from '@/contracts'
|
||||
|
||||
const TreeNode = defineComponent({
|
||||
name: 'TreeNode',
|
||||
props: {
|
||||
node: { type: Object as () => FileNode, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
renamingPath: { type: String, default: null },
|
||||
renameValue: { type: String, default: '' },
|
||||
},
|
||||
emits: ['open', 'toggle', 'context-menu', 'rename-start', 'rename-finish', 'delete-node', 'new-file', 'new-folder'],
|
||||
setup(props, { emit }) {
|
||||
const isActive = (path: string) => {
|
||||
const { useWorkspaceStore } = require('@/stores/workspace')
|
||||
return useWorkspaceStore().activeFilePath === path
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
emit('open', props.node)
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
emit('context-menu', e, props.node)
|
||||
}
|
||||
|
||||
const finishRename = () => {
|
||||
emit('rename-finish', props.node)
|
||||
}
|
||||
|
||||
return () => {
|
||||
const isFolder = props.node.type === 'folder'
|
||||
const isOpen = props.node.is_open
|
||||
const isRenaming = props.renamingPath === props.node.path
|
||||
const active = isActive(props.node.path)
|
||||
|
||||
return h('div', { class: 'tree-node' }, [
|
||||
h('div', {
|
||||
class: ['node-row', { active, folder: isFolder, open: isOpen }],
|
||||
style: { paddingLeft: `${props.depth * 16 + 8}px` },
|
||||
onClick: handleClick,
|
||||
onContextmenu: handleContextMenu,
|
||||
}, [
|
||||
h('span', { class: 'chevron' }, isFolder ? (isOpen ? '▼' : '▶') : ''),
|
||||
h('span', { class: 'node-icon' }, isFolder ? (isOpen ? '📂' : '📁') : '📄'),
|
||||
isRenaming
|
||||
? h('input', {
|
||||
class: 'rename-input',
|
||||
value: props.renameValue,
|
||||
autofocus: true,
|
||||
onBlur: finishRename,
|
||||
onKeyup: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') finishRename()
|
||||
if (e.key === 'Escape') emit('rename-finish', props.node)
|
||||
},
|
||||
})
|
||||
: h('span', { class: 'node-name' }, props.node.name),
|
||||
]),
|
||||
isFolder && isOpen && props.node.children && props.node.children.length
|
||||
? h('div', { class: 'node-children' },
|
||||
props.node.children.map((child) =>
|
||||
h(TreeNode, {
|
||||
key: child.id,
|
||||
node: child,
|
||||
depth: props.depth + 1,
|
||||
renamingPath: props.renamingPath,
|
||||
renameValue: props.renameValue,
|
||||
onOpen: (n: FileNode) => emit('open', n),
|
||||
onToggle: (n: FileNode) => emit('toggle', n.path),
|
||||
onContextmenu: (e: MouseEvent, n: FileNode) => emit('context-menu', e, n),
|
||||
onRenameStart: (n: FileNode) => emit('rename-start', n),
|
||||
onRenameFinish: (n: FileNode) => emit('rename-finish', n),
|
||||
onDeleteNode: (n: FileNode) => emit('delete-node', n),
|
||||
onNewFile: (p: string) => emit('new-file', p),
|
||||
onNewFolder: (p: string) => emit('new-folder', p),
|
||||
})
|
||||
)
|
||||
)
|
||||
: null,
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-tree-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
transition: all var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.new-input {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border-focus);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding-right: var(--space-md);
|
||||
cursor: pointer;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin-right: 4px;
|
||||
transition: background var(--motion-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 14px;
|
||||
font-size: 9px;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
flex: 1;
|
||||
padding: 2px 4px;
|
||||
font-size: 13px;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-focus);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: var(--z-dropdown);
|
||||
background: var(--color-surface-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
.file-tree-panel { height: 100%; }
|
||||
.toolbar { display: flex; gap: var(--space-xs); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
button { border: 0; border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); background: transparent; color: inherit; cursor: pointer; }
|
||||
button:hover { background: var(--color-background-secondary); }
|
||||
.new-item { display: flex; gap: var(--space-xs); padding: var(--space-sm); }
|
||||
.new-item input { min-width: 0; flex: 1; }
|
||||
.tree { padding: var(--space-xs); }
|
||||
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
|
||||
.context-menu button { text-align: left; }
|
||||
.context-menu .danger { color: var(--color-danger, #d33); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user