fix(frontend): 修复合并审阅发现的构建与契约问题

恢复 Vue TypeScript 生产构建,补齐可运行页面壳子,并修复文件树与 SSE 状态问题。

按 FastAPI Wire Contract 统一 Service DTO 映射,同时补充前端开发说明和问题修复复盘。
This commit is contained in:
2026-08-29 12:10:33 +08:00
parent c6c28e4ebe
commit 610fc77b0f
32 changed files with 1204 additions and 611 deletions
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const title = computed(() => String(route.meta.title ?? '功能开发中'))
</script>
<template>
<section class="placeholder-view">
<div>
<h1>{{ title }}</h1>
<p>基础路由已经就绪具体页面将在后续功能开发中实现</p>
</div>
</section>
</template>
<style scoped>
.placeholder-view {
display: grid;
height: 100%;
place-items: center;
color: var(--color-text-secondary);
}
.placeholder-view div {
text-align: center;
}
.placeholder-view h1 {
margin: 0 0 var(--space-sm);
color: var(--color-text-primary);
}
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
</script>
<template>
<header class="editor-header">
<strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong>
<span>{{ editorStore.saveStatus }}</span>
</header>
</template>
<style scoped>
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
padding: 0 var(--space-lg);
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
const editorStore = useEditorStore()
</script>
<template>
<textarea
class="editor-pane"
:value="editorStore.content"
spellcheck="false"
@input="editorStore.updateContent(($event.target as HTMLTextAreaElement).value); editorStore.scheduleAutoSave()"
/>
</template>
<style scoped>
.editor-pane {
box-sizing: border-box;
flex: 1;
width: 100%;
resize: none;
border: 0;
outline: 0;
padding: var(--space-xl);
background: var(--color-background-primary);
color: var(--color-text-primary);
font: inherit;
line-height: 1.7;
}
</style>
@@ -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>
+81 -370
View File
@@ -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>