feat(workspace): 接入真实Vault数据链路
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import EditorPane from './EditorPane.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
@@ -19,12 +20,20 @@ async function waitForText(text: string) {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (filePath) => {
|
||||
if (filePath === '/欢迎使用 NotesAgent.md') {
|
||||
return '# 欢迎使用 NotesAgent\n\n祝你写作愉快'
|
||||
}
|
||||
if (filePath === '/数据结构/红黑树.md') return '# 红黑树\n\n新的文件内容'
|
||||
throw new Error(`Unexpected file path: ${filePath}`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('EditorPane file switching', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { ArrowRight, Document, Folder, FolderOpened, Moon, Plus, Sunny } from '@element-plus/icons-vue'
|
||||
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -13,17 +13,19 @@ const themeStore = useThemeStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const showCreateDialog = ref(false)
|
||||
const newVaultName = ref('')
|
||||
const newVaultPath = ref('')
|
||||
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
|
||||
await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
|
||||
const lastVaultPath = localStorage.getItem('last-vault-path')
|
||||
if (settingsStore.restoreLastVault && lastVaultPath) {
|
||||
await openVault(lastVaultPath)
|
||||
return
|
||||
try {
|
||||
await openVault(lastVaultPath)
|
||||
return
|
||||
} catch {
|
||||
// Mock 阶段保存的旧路径可能与当前后端 Vault 不同,清除后让用户重新选择。
|
||||
localStorage.removeItem('last-vault-path')
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
||||
@@ -41,24 +43,8 @@ async function openVault(path: string) {
|
||||
}
|
||||
|
||||
async function openFolderPicker() {
|
||||
// In Tauri this would use the native dialog
|
||||
// For web dev, simulate
|
||||
const path = prompt('请输入 Vault 路径(开发模式)', '/Users/demo/Documents/MyVault')
|
||||
if (path) {
|
||||
await openVault(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function createVault() {
|
||||
if (!newVaultName.value || !newVaultPath.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
await workspaceStore.createVault(newVaultPath.value, newVaultName.value)
|
||||
router.push('/workspace')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
showCreateDialog.value = false
|
||||
}
|
||||
const configured = workspaceStore.recentVaults[0]
|
||||
if (configured) await openVault(configured.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,7 +60,7 @@ async function createVault() {
|
||||
|
||||
<div class="vault-card">
|
||||
<h2 class="card-title">选择知识库</h2>
|
||||
<p class="card-desc">选择一个本地 Vault 开始你的知识之旅</p>
|
||||
<p class="card-desc">Web 联调模式连接 AI Core 当前配置的 Vault</p>
|
||||
|
||||
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
|
||||
<div class="section-label">最近打开</div>
|
||||
@@ -97,11 +83,8 @@ async function createVault() {
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading">
|
||||
<AppIcon :icon="FolderOpened" /> 打开本地 Vault
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
|
||||
<AppIcon :icon="Plus" /> 创建新 Vault
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
|
||||
<AppIcon :icon="FolderOpened" /> 打开后端 Vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -122,24 +105,6 @@ async function createVault() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Vault Dialog -->
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click.self="showCreateDialog = false">
|
||||
<div class="dialog">
|
||||
<h3>创建新 Vault</h3>
|
||||
<div class="form-group">
|
||||
<label>Vault 名称</label>
|
||||
<input v-model="newVaultName" type="text" placeholder="我的知识库" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>存储路径</label>
|
||||
<input v-model="newVaultPath" type="text" placeholder="/path/to/vault" />
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" @click="showCreateDialog = false">取消</button>
|
||||
<button class="btn btn-primary" @click="createVault" :disabled="!newVaultName || !newVaultPath">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -408,67 +373,5 @@ async function createVault() {
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-background-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
animation: dialog-backdrop-in var(--motion-fast) both;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
animation: dialog-in var(--motion-normal) both;
|
||||
}
|
||||
|
||||
@keyframes entry-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes dialog-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes dialog-in { from { opacity: 0; transform: translateY(8px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
|
||||
.dialog h3 {
|
||||
margin: 0 0 var(--space-lg) 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
transition: border-color var(--motion-fast);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import FileTreePanel from './FileTreePanel.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
@@ -21,12 +22,26 @@ async function waitForPath(path: string) {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.spyOn(workspaceService, 'openVault').mockResolvedValue({ path: 'C:/vault', name: 'vault' })
|
||||
vi.spyOn(workspaceService, 'getFileTree').mockResolvedValue([
|
||||
{
|
||||
id: 'folder-data', name: '数据结构', path: '/数据结构', type: 'folder', is_open: true,
|
||||
children: [
|
||||
{ id: 'note-rbt', note_id: 'note-rbt', name: '红黑树.md', path: '/数据结构/红黑树.md', type: 'file' },
|
||||
{ id: 'note-bst', note_id: 'note-bst', name: '二叉搜索树.md', path: '/数据结构/二叉搜索树.md', type: 'file' },
|
||||
],
|
||||
},
|
||||
])
|
||||
vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (path) =>
|
||||
path.includes('红黑树') ? '# 红黑树\n' : '# 二叉搜索树\n'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('FileTreePanel file switching', () => {
|
||||
@@ -40,7 +55,7 @@ describe('FileTreePanel file switching', () => {
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
await workspaceStore.openVault('/mock-vault')
|
||||
await workspaceStore.openVault('C:/vault')
|
||||
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
|
||||
|
||||
const findNode = (name: string) => wrapper!.findAll('.tree-node').find((node) => node.text().includes(name))!
|
||||
|
||||
@@ -17,16 +17,16 @@ afterEach(() => {
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
describe('WorkspaceView initial file', () => {
|
||||
it('does not overwrite a file selected while the welcome note is loading', async () => {
|
||||
describe('WorkspaceView empty state', () => {
|
||||
it('does not fabricate a Mock welcome note when no backend file is selected', async () => {
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
wrapper = mount(WorkspaceView, {
|
||||
global: { stubs: { EditorHeader: true, EditorPane: true } },
|
||||
})
|
||||
|
||||
workspaceStore.openFile('/数据结构/红黑树.md')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(workspaceStore.activeFilePath).toBeNull()
|
||||
expect(wrapper.find('.empty-workspace').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import EditorHeader from '@/features/editor/EditorHeader.vue'
|
||||
import EditorPane from '@/features/editor/EditorPane.vue'
|
||||
import { EditPen } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
onMounted(() => {
|
||||
if (!workspaceStore.fileTree.length && workspaceStore.hasVault) {
|
||||
// Already loaded
|
||||
}
|
||||
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
|
||||
void editorStore.loadFile('/欢迎使用 NotesAgent.md').then(() => {
|
||||
// 默认文件加载期间用户可能已经点击了其他文件,不能覆盖用户的选择。
|
||||
if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用 NotesAgent.md') {
|
||||
workspaceStore.openFile('/欢迎使用 NotesAgent.md')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user