feat(frontend): 搭建桌面端基础界面与 Workspace

- App Shell 壳层:主导航、次导航、Title Bar、状态栏
- Vault 入口页:最近 Vault、打开/创建 Vault、AI Core 状态
- Workspace 与文件树:浏览、打开、创建笔记/文件夹
- Design Token:浅色/深色主题 CSS Variables
- Contracts / Service / Store / Router 基础架构
- 统一 ApiClient 与 SseClient,对接后端 API 契约
This commit is contained in:
2026-08-29 10:36:16 +08:00
parent 694c1b27c1
commit f9efc4f4a2
42 changed files with 8044 additions and 167 deletions
+441
View File
@@ -0,0 +1,441 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme'
import { useSettingsStore } from '@/stores/settings'
const router = useRouter()
const workspaceStore = useWorkspaceStore()
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 workspaceStore.loadRecentVaults()
setTimeout(() => {
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}, 800)
})
async function openVault(path: string) {
isLoading.value = true
try {
await workspaceStore.openVault(path)
router.push('/workspace')
} finally {
isLoading.value = false
}
}
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
}
}
</script>
<template>
<div class="vault-entry">
<div class="bg-decoration" />
<div class="entry-container">
<div class="brand-section">
<div class="logo">📝</div>
<h1 class="app-title">知笔知己</h1>
<p class="app-subtitle">本地优先的 AI 笔记软件</p>
</div>
<div class="vault-card">
<h2 class="card-title">选择知识库</h2>
<p class="card-desc">选择一个本地 Vault 开始你的知识之旅</p>
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
<div class="section-label">最近打开</div>
<div class="vault-list">
<button
v-for="vault in workspaceStore.recentVaults"
:key="vault.path"
class="vault-item"
@click="openVault(vault.path)"
:disabled="isLoading"
>
<span class="vault-icon">📁</span>
<div class="vault-info">
<div class="vault-name">{{ vault.name }}</div>
<div class="vault-path">{{ vault.path }}</div>
</div>
<span class="vault-arrow"></span>
</button>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading">
<span>📂</span> 打开本地 Vault
</button>
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
<span></span> 创建新 Vault
</button>
</div>
<div class="ai-core-status">
<span class="status-dot" :class="aiCoreStatus" />
<span v-if="aiCoreStatus === 'checking'">正在检查 AI Core 状态...</span>
<span v-else-if="aiCoreStatus === 'running'" class="status-running">AI Core 运行正常</span>
<span v-else class="status-stopped">AI Core 未启动编辑功能仍可用</span>
</div>
</div>
<div class="footer-info">
<span>v0.1.0</span>
<button class="theme-toggle" @click="themeStore.toggleTheme()">
{{ themeStore.isDark ? ' 浅色' : '🌙 深色' }}
</button>
</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>
<style scoped>
.vault-entry {
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-background-primary);
position: relative;
overflow: hidden;
}
.bg-decoration {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 30%, var(--color-accent-soft) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, var(--color-info-soft) 0%, transparent 50%);
opacity: 0.5;
}
.entry-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
max-width: 480px;
width: 90%;
}
.brand-section {
text-align: center;
}
.logo {
font-size: 64px;
margin-bottom: 12px;
}
.app-title {
font-size: 32px;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 8px;
background: linear-gradient(135deg, var(--color-accent-primary), var(--color-info));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-subtitle {
font-size: 15px;
color: var(--color-text-secondary);
}
.vault-card {
width: 100%;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
box-shadow: var(--shadow-lg);
}
.card-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
color: var(--color-text-primary);
}
.card-desc {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: var(--space-xl);
}
.section-label {
font-size: 12px;
color: var(--color-text-tertiary);
margin-bottom: var(--space-sm);
font-weight: 500;
}
.vault-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-bottom: var(--space-xl);
}
.vault-item {
display: flex;
align-items: center;
gap: var(--space-md);
width: 100%;
padding: var(--space-md);
background: var(--color-background-secondary);
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
transition: all var(--motion-fast);
&:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent-secondary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.vault-icon {
font-size: 20px;
flex-shrink: 0;
}
.vault-info {
flex: 1;
min-width: 0;
}
.vault-name {
font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
}
.vault-path {
font-size: 12px;
color: var(--color-text-tertiary);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-arrow {
color: var(--color-text-tertiary);
font-size: 20px;
}
.actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 10px 16px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--motion-fast);
border: 1px solid transparent;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.btn-primary {
background: var(--color-accent-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background: var(--color-accent-primary-hover);
}
}
&.btn-secondary {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
border-color: var(--color-border-default);
&:hover:not(:disabled) {
background: var(--color-background-hover);
}
}
}
.ai-core-status {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 13px;
color: var(--color-text-secondary);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border-subtle);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-text-tertiary);
&.checking {
animation: pulse 1.5s infinite;
background: var(--color-warning);
}
&.running { background: var(--color-success); }
&.stopped { background: var(--color-text-tertiary); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-running { color: var(--color-success); }
.status-stopped { color: var(--color-text-tertiary); }
.footer-info {
display: flex;
align-items: center;
gap: var(--space-lg);
font-size: 12px;
color: var(--color-text-tertiary);
}
.theme-toggle {
color: var(--color-text-secondary);
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
&:hover {
background: var(--color-background-hover);
}
}
.dialog-overlay {
position: fixed;
inset: 0;
background: var(--color-background-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
}
.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);
}
.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>