first commit

This commit is contained in:
2026-06-01 17:28:00 +08:00
commit 23aa61401a
35 changed files with 5810 additions and 0 deletions

19
src/App.vue Normal file
View File

@@ -0,0 +1,19 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWorkspaceStore } from './stores/workspace'
const router = useRouter()
const workspaceStore = useWorkspaceStore()
onMounted(async () => {
await workspaceStore.initWorkspace()
if (!workspaceStore.path) {
// No workspace selected, stay on welcome page
}
})
</script>
<template>
<router-view />
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import { ref } from 'vue'
import { useUiStore } from '@/stores/ui'
const uiStore = useUiStore()
const isDragging = ref(false)
function onMouseDown(e) {
isDragging.value = true
const startX = e.clientX
const startWidth = uiStore.leftWidth
function onMouseMove(e) {
const delta = e.clientX - startX
uiStore.setLeftWidth(startWidth + delta)
}
function onMouseUp() {
isDragging.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
</script>
<template>
<div
class="w-1 h-full flex-shrink-0 cursor-col-resize group relative"
:style="{ backgroundColor: 'var(--p-surface-200)' }"
@mousedown="onMouseDown"
>
<div
class="absolute inset-y-0 -left-1 -right-1 group-hover:bg-blue-400/20 transition-colors"
:class="{ 'bg-blue-400/20': isDragging }"
/>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { Plus } from 'lucide-vue-next'
const emit = defineEmits(['click'])
</script>
<template>
<button
class="p-1.5 rounded-md transition-colors"
:style="{
backgroundColor: 'var(--p-primary-500)',
color: '#ffffff',
}"
@click="emit('click')"
title="新建笔记"
>
<Plus :size="16" />
</button>
</template>

View File

@@ -0,0 +1,73 @@
<script setup>
import { useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { useNotesStore } from '@/stores/notes'
import { useNotebooksStore } from '@/stores/notebooks'
import UserProfile from './UserProfile.vue'
import SearchInput from './SearchInput.vue'
import AddNoteButton from './AddNoteButton.vue'
import MenuItem from './MenuItem.vue'
import NotebookTree from './NotebookTree.vue'
import { FileText, Tag, Settings, Trash2, PanelLeftClose } from 'lucide-vue-next'
import { useUiStore } from '@/stores/ui'
const router = useRouter()
const workspaceStore = useWorkspaceStore()
const notesStore = useNotesStore()
const notebooksStore = useNotebooksStore()
const uiStore = useUiStore()
async function handleSearch(query) {
notesStore.setFilter('search', query)
router.push({ name: 'search', query: { q: query } })
}
async function handleAddNote() {
const note = await notesStore.createNote()
if (note) {
router.push({ name: 'note-detail', params: { id: note.id } })
}
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- 用户信息 + 折叠按钮 -->
<div class="flex items-center justify-between px-4 py-3 border-b" :style="{ borderColor: 'var(--p-surface-200)' }">
<UserProfile />
<button
class="p-1.5 rounded-md hover:bg-[var(--p-surface-100)] transition-colors"
@click="uiStore.toggleLeft()"
title="收起侧边栏"
>
<PanelLeftClose :size="18" :style="{ color: 'var(--p-surface-500)' }" />
</button>
</div>
<!-- 搜索框 + 新建按钮 -->
<div class="flex items-center gap-2 px-3 py-2">
<SearchInput class="flex-1" @search="handleSearch" />
<AddNoteButton @click="handleAddNote" />
</div>
<!-- 菜单项 -->
<nav class="flex-1 overflow-y-auto px-2">
<MenuItem label="笔记" :icon="FileText" @click="router.push({ name: 'notes' })" />
<MenuItem label="标签" :icon="Tag" @click="router.push({ name: 'tags' })" />
<!-- 笔记本区域 -->
<div class="mt-3">
<div class="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider" :style="{ color: 'var(--p-surface-400)' }">
笔记本
</div>
<NotebookTree />
</div>
</nav>
<!-- 底部固定 -->
<div class="border-t px-2 py-2" :style="{ borderColor: 'var(--p-surface-200)' }">
<MenuItem label="设置" :icon="Settings" @click="router.push({ name: 'settings' })" />
<MenuItem label="废纸篓" :icon="Trash2" @click="router.push({ name: 'trash' })" />
</div>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
defineProps({
label: { type: String, required: true },
icon: { type: Object, default: null },
active: { type: Boolean, default: false },
})
const emit = defineEmits(['click'])
</script>
<template>
<button
class="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors"
:class="active ? 'font-medium' : ''"
:style="{
backgroundColor: active ? 'var(--p-primary-50)' : 'transparent',
color: active ? 'var(--p-primary-700)' : 'var(--p-surface-600)',
}"
@click="emit('click')"
@mouseenter="$event.target.style.backgroundColor = active ? 'var(--p-primary-50)' : 'var(--p-surface-100)'"
@mouseleave="$event.target.style.backgroundColor = active ? 'var(--p-primary-50)' : 'transparent'"
>
<component v-if="icon" :is="icon" :size="18" />
<span>{{ label }}</span>
</button>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import { FileText } from 'lucide-vue-next'
defineProps({
note: { type: Object, required: true },
level: { type: Number, default: 0 },
})
const emit = defineEmits(['click'])
</script>
<template>
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors"
:style="{ paddingLeft: (level * 12 + 8) + 'px', color: 'var(--p-surface-600)' }"
@click="emit('click')"
@mouseenter="$event.target.style.backgroundColor = 'var(--p-surface-100)'"
@mouseleave="$event.target.style.backgroundColor = 'transparent'"
>
<FileText :size="14" :style="{ color: 'var(--p-surface-400)' }" />
<span class="truncate">{{ note.title }}</span>
</button>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNotebooksStore } from '@/stores/notebooks'
import { useNotesStore } from '@/stores/notes'
import { ChevronRight, Folder, FolderOpen } from 'lucide-vue-next'
import NoteItem from './NoteItem.vue'
const props = defineProps({
notebook: { type: Object, required: true },
level: { type: Number, default: 0 },
})
const router = useRouter()
const notebooksStore = useNotebooksStore()
const notesStore = useNotesStore()
const notes = ref([])
const isExpanded = () => notebooksStore.isExpanded(props.notebook.path)
function toggle() {
notebooksStore.toggleExpand(props.notebook.path)
}
async function loadNotes() {
const entries = await window.tunjiAPI.file.list(props.notebook.path)
notes.value = entries
.filter(e => e.isFile && e.name.endsWith('.md'))
.map(e => ({
id: e.name.replace('.md', ''),
title: e.name.replace('.md', ''),
filePath: e.path,
}))
}
function openNote(note) {
router.push({ name: 'note-detail', params: { id: note.id } })
}
onMounted(loadNotes)
</script>
<template>
<div>
<!-- 笔记本节点 -->
<button
class="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-sm transition-colors"
:style="{ paddingLeft: (level * 12 + 8) + 'px', color: 'var(--p-surface-600)' }"
@click="toggle"
@mouseenter="$event.target.style.backgroundColor = 'var(--p-surface-100)'"
@mouseleave="$event.target.style.backgroundColor = 'transparent'"
>
<ChevronRight
:size="14"
class="transition-transform flex-shrink-0"
:class="{ 'rotate-90': isExpanded() }"
:style="{ color: 'var(--p-surface-400)' }"
/>
<component
:is="isExpanded() ? FolderOpen : Folder"
:size="16"
:style="{ color: 'var(--p-primary-500)' }"
/>
<span class="truncate">{{ notebook.name }}</span>
</button>
<!-- 子内容 -->
<div v-if="isExpanded()">
<!-- 子笔记本 -->
<NotebookNode
v-for="child in notebook.children"
:key="child.path"
:notebook="child"
:level="level + 1"
/>
<!-- 笔记列表 -->
<NoteItem
v-for="note in notes"
:key="note.id"
:note="note"
:level="level + 1"
@click="openNote(note)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { onMounted } from 'vue'
import { useNotebooksStore } from '@/stores/notebooks'
import { useWorkspaceStore } from '@/stores/workspace'
import NotebookNode from './NotebookNode.vue'
const notebooksStore = useNotebooksStore()
const workspaceStore = useWorkspaceStore()
onMounted(async () => {
if (workspaceStore.path) {
await notebooksStore.fetchNotebooks()
}
})
</script>
<template>
<div class="space-y-0.5">
<NotebookNode
v-for="notebook in notebooksStore.notebooks"
:key="notebook.path"
:notebook="notebook"
:level="0"
/>
<div
v-if="notebooksStore.notebooks.length === 0"
class="px-3 py-4 text-xs text-center"
:style="{ color: 'var(--p-surface-400)' }"
>
暂无笔记本
</div>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { ref } from 'vue'
import { Search } from 'lucide-vue-next'
const emit = defineEmits(['search'])
const query = ref('')
let timer = null
function onInput() {
clearTimeout(timer)
timer = setTimeout(() => {
emit('search', query.value)
}, 300)
}
</script>
<template>
<div class="relative">
<Search
:size="14"
class="absolute left-2.5 top-1/2 -translate-y-1/2"
:style="{ color: 'var(--p-surface-400)' }"
/>
<input
v-model="query"
type="text"
placeholder="搜索笔记..."
class="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border outline-none transition-colors"
:style="{
borderColor: 'var(--p-surface-200)',
backgroundColor: 'var(--p-surface-0)',
color: 'var(--p-surface-700)',
}"
@input="onInput"
@keydown.enter="emit('search', query)"
/>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import { User } from 'lucide-vue-next'
</script>
<template>
<div class="flex items-center gap-2">
<div
class="w-8 h-8 rounded-full flex items-center justify-center"
:style="{ backgroundColor: 'var(--p-primary-100)', color: 'var(--p-primary-600)' }"
>
<User :size="16" />
</div>
<span class="text-sm font-medium" :style="{ color: 'var(--p-surface-700)' }">Tunji</span>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
import { useUiStore } from '@/stores/ui'
import LeftArea from '@/components/left-area/LeftArea.vue'
import ResizeHandle from '@/components/common/ResizeHandle.vue'
const uiStore = useUiStore()
</script>
<template>
<div class="flex h-full w-full overflow-hidden">
<!-- 左侧栏 -->
<aside
v-show="!uiStore.leftCollapsed"
class="flex-shrink-0 h-full overflow-hidden border-r"
:style="{ width: uiStore.leftWidth + 'px', borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
>
<LeftArea />
</aside>
<!-- 拖拽手柄 -->
<ResizeHandle v-show="!uiStore.leftCollapsed" />
<!-- 右侧区域 -->
<main class="flex-1 h-full overflow-hidden" :style="{ backgroundColor: 'var(--p-surface-0)' }">
<router-view />
</main>
</div>
</template>

16
src/main.js Normal file
View File

@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import App from './App.vue'
import router from './router'
import './styles/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(PrimeVue, {
unstyled: true,
})
app.mount('#app')

57
src/router/index.js Normal file
View File

@@ -0,0 +1,57 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
children: [
{
path: '',
name: 'welcome',
component: () => import('@/views/WelcomeView.vue'),
},
{
path: 'notes',
name: 'notes',
component: () => import('@/views/NotesListView.vue'),
},
{
path: 'tags',
name: 'tags',
component: () => import('@/views/TagsView.vue'),
},
{
path: 'tags/:tagId',
name: 'tag-detail',
component: () => import('@/views/TagsView.vue'),
},
{
path: 'note/:id',
name: 'note-detail',
component: () => import('@/views/NoteDetailView.vue'),
},
{
path: 'search',
name: 'search',
component: () => import('@/views/SearchView.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
{
path: 'trash',
name: 'trash',
component: () => import('@/views/TrashView.vue'),
},
],
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router

69
src/stores/notebooks.js Normal file
View File

@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useWorkspaceStore } from './workspace'
export const useNotebooksStore = defineStore('notebooks', () => {
const notebooks = ref([])
const expandedPaths = ref(new Set())
async function fetchNotebooks() {
const workspace = useWorkspaceStore()
if (!workspace.path) return
const entries = await window.tunjiAPI.file.list(workspace.path)
const dirs = entries.filter(e => e.isDirectory && !e.name.startsWith('.'))
notebooks.value = dirs.map(e => ({
name: e.name,
path: e.path,
parentPath: workspace.path,
children: [],
}))
// Recursively load children
for (const nb of notebooks.value) {
await loadChildren(nb)
}
}
async function loadChildren(notebook) {
const entries = await window.tunjiAPI.file.list(notebook.path)
const dirs = entries.filter(e => e.isDirectory && !e.name.startsWith('.'))
notebook.children = dirs.map(e => ({
name: e.name,
path: e.path,
parentPath: notebook.path,
children: [],
}))
for (const child of notebook.children) {
await loadChildren(child)
}
}
function toggleExpand(path) {
if (expandedPaths.value.has(path)) {
expandedPaths.value.delete(path)
} else {
expandedPaths.value.add(path)
}
}
function isExpanded(path) {
return expandedPaths.value.has(path)
}
async function createNotebook(name, parentPath) {
const workspace = useWorkspaceStore()
if (!workspace.path) return
const dir = parentPath || workspace.path
const newPath = `${dir}/${name}`
await window.tunjiAPI.file.mkdir(newPath)
await fetchNotebooks()
}
return {
notebooks,
expandedPaths,
fetchNotebooks,
toggleExpand,
isExpanded,
createNotebook,
}
})

96
src/stores/notes.js Normal file
View File

@@ -0,0 +1,96 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useWorkspaceStore } from './workspace'
export const useNotesStore = defineStore('notes', () => {
const notes = ref([])
const currentNote = ref(null)
const viewMode = ref('list') // 'list' | 'grid'
const filter = ref({
notebook: null,
tag: null,
search: '',
})
const filteredNotes = computed(() => {
let result = notes.value
if (filter.value.notebook) {
result = result.filter(n => n.notebookPath === filter.value.notebook)
}
if (filter.value.tag) {
result = result.filter(n => n.tags?.includes(filter.value.tag))
}
if (filter.value.search) {
const q = filter.value.search.toLowerCase()
result = result.filter(n =>
n.title.toLowerCase().includes(q) || n.content?.toLowerCase().includes(q)
)
}
return result
})
async function fetchNotes() {
const workspace = useWorkspaceStore()
if (!workspace.path) return
const entries = await window.tunjiAPI.file.list(workspace.path)
const mdFiles = entries.filter(e => e.isFile && e.name.endsWith('.md'))
notes.value = mdFiles.map(e => ({
id: e.name.replace('.md', ''),
title: e.name.replace('.md', ''),
filePath: e.path,
notebookPath: null,
createdAt: null,
updatedAt: null,
}))
}
async function openNote(id) {
const note = notes.value.find(n => n.id === id)
if (note) {
const content = await window.tunjiAPI.file.read(note.filePath)
currentNote.value = { ...note, content }
}
}
async function createNote(notebookPath) {
const workspace = useWorkspaceStore()
if (!workspace.path) return null
const dir = notebookPath || workspace.path
const title = `新笔记-${Date.now()}`
const filePath = `${dir}/${title}.md`
const content = `---\ntitle: "${title}"\ncreated: "${new Date().toISOString()}"\nupdated: "${new Date().toISOString()}"\ntags: []\n---\n\n# ${title}\n\n`
await window.tunjiAPI.file.write(filePath, content)
await fetchNotes()
return notes.value.find(n => n.filePath === filePath)
}
async function saveNote(id, content) {
const note = notes.value.find(n => n.id === id)
if (note) {
await window.tunjiAPI.file.write(note.filePath, content)
}
}
async function deleteNote(id) {
// MVP: placeholder
notes.value = notes.value.filter(n => n.id !== id)
}
function setFilter(key, value) {
filter.value[key] = value
}
return {
notes,
currentNote,
viewMode,
filter,
filteredNotes,
fetchNotes,
openNote,
createNote,
saveNote,
deleteNote,
setFilter,
}
})

23
src/stores/ui.js Normal file
View File

@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const leftCollapsed = ref(false)
const leftWidth = ref(280)
const theme = ref('light')
function toggleLeft() {
leftCollapsed.value = !leftCollapsed.value
}
function setLeftWidth(width) {
leftWidth.value = Math.max(240, Math.min(480, width))
}
function setTheme(newTheme) {
theme.value = newTheme
document.documentElement.classList.toggle('dark', newTheme === 'dark')
}
return { leftCollapsed, leftWidth, theme, toggleLeft, setLeftWidth, setTheme }
})

32
src/stores/workspace.js Normal file
View File

@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useWorkspaceStore = defineStore('workspace', () => {
const path = ref(null)
const isReady = ref(false)
async function initWorkspace() {
const savedPath = await window.tunjiAPI.workspace.get()
if (savedPath) {
path.value = savedPath
isReady.value = true
}
}
async function selectWorkspace() {
const selected = await window.tunjiAPI.workspace.select()
if (selected) {
path.value = selected
await window.tunjiAPI.workspace.ensureStructure(selected)
isReady.value = true
}
return selected
}
function $reset() {
path.value = null
isReady.value = false
}
return { path, isReady, initWorkspace, selectWorkspace, $reset }
})

52
src/styles/main.css Normal file
View File

@@ -0,0 +1,52 @@
@import "tailwindcss";
@import "./theme.css";
/* 全局基础样式 */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #app {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
background-color: var(--p-surface-0);
color: var(--p-surface-700);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--p-surface-300);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--p-surface-400);
}
/* 选中文本颜色 */
::selection {
background-color: var(--p-primary-200);
color: var(--p-primary-900);
}

52
src/styles/theme.css Normal file
View File

@@ -0,0 +1,52 @@
/* Tunji 主题色系统 - 参考 PrimeVue Lara 主题 */
:root {
/* Primary 蓝色 */
--p-primary-50: #eff6ff;
--p-primary-100: #dbeafe;
--p-primary-200: #bfdbfe;
--p-primary-300: #93c5fd;
--p-primary-400: #60a5fa;
--p-primary-500: #3b82f6;
--p-primary-600: #2563eb;
--p-primary-700: #1d4ed8;
--p-primary-800: #1e40af;
--p-primary-900: #1e3a8a;
/* Surface 中性灰 */
--p-surface-0: #ffffff;
--p-surface-50: #f8fafc;
--p-surface-100: #f1f5f9;
--p-surface-200: #e2e8f0;
--p-surface-300: #cbd5e1;
--p-surface-400: #94a3b8;
--p-surface-500: #64748b;
--p-surface-600: #475569;
--p-surface-700: #334155;
--p-surface-800: #1e293b;
--p-surface-900: #0f172a;
/* 语义色 */
--p-success-500: #22c55e;
--p-success-600: #16a34a;
--p-warn-500: #f59e0b;
--p-warn-600: #d97706;
--p-danger-500: #ef4444;
--p-danger-600: #dc2626;
--p-info-500: #06b6d4;
}
/* 暗色模式 */
.dark {
--p-surface-0: #0f172a;
--p-surface-50: #1e293b;
--p-surface-100: #334155;
--p-surface-200: #475569;
--p-surface-300: #64748b;
--p-surface-400: #94a3b8;
--p-surface-500: #cbd5e1;
--p-surface-600: #e2e8f0;
--p-surface-700: #f1f5f9;
--p-surface-800: #f8fafc;
--p-surface-900: #ffffff;
}

View File

@@ -0,0 +1,156 @@
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNotesStore } from '@/stores/notes'
import { ArrowLeft, Edit3, Eye, Columns } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const notesStore = useNotesStore()
const note = ref(null)
const content = ref('')
const editMode = ref('edit') // 'edit' | 'preview' | 'split'
const isSaving = ref(false)
onMounted(async () => {
await loadNote()
})
watch(() => route.params.id, loadNote)
async function loadNote() {
const id = route.params.id
if (!id) return
await notesStore.openNote(id)
if (notesStore.currentNote) {
note.value = notesStore.currentNote
content.value = notesStore.currentNote.content || ''
}
}
async function handleSave() {
if (!note.value) return
isSaving.value = true
await notesStore.saveNote(note.value.id, content.value)
isSaving.value = false
}
// Auto-save on content change
let saveTimer = null
watch(content, () => {
clearTimeout(saveTimer)
saveTimer = setTimeout(handleSave, 2000)
})
</script>
<template>
<div class="h-full flex flex-col">
<!-- 头部工具栏 -->
<div
class="flex items-center justify-between px-6 py-3 border-b"
:style="{ borderColor: 'var(--p-surface-200)' }"
>
<div class="flex items-center gap-3">
<button
class="p-1.5 rounded-md transition-colors"
:style="{ color: 'var(--p-surface-500)' }"
@click="router.back()"
>
<ArrowLeft :size="18" />
</button>
<h1
v-if="note"
class="text-lg font-semibold truncate"
:style="{ color: 'var(--p-surface-800)' }"
>
{{ note.title }}
</h1>
</div>
<!-- 视图模式切换 -->
<div class="flex items-center gap-1 rounded-lg p-0.5" :style="{ backgroundColor: 'var(--p-surface-100)' }">
<button
class="p-1.5 rounded-md transition-colors"
:style="{
backgroundColor: editMode === 'edit' ? 'var(--p-surface-0)' : 'transparent',
color: editMode === 'edit' ? 'var(--p-primary-600)' : 'var(--p-surface-500)',
}"
@click="editMode = 'edit'"
title="编辑模式"
>
<Edit3 :size="16" />
</button>
<button
class="p-1.5 rounded-md transition-colors"
:style="{
backgroundColor: editMode === 'preview' ? 'var(--p-surface-0)' : 'transparent',
color: editMode === 'preview' ? 'var(--p-primary-600)' : 'var(--p-surface-500)',
}"
@click="editMode = 'preview'"
title="预览模式"
>
<Eye :size="16" />
</button>
<button
class="p-1.5 rounded-md transition-colors"
:style="{
backgroundColor: editMode === 'split' ? 'var(--p-surface-0)' : 'transparent',
color: editMode === 'split' ? 'var(--p-primary-600)' : 'var(--p-surface-500)',
}"
@click="editMode = 'split'"
title="双栏模式"
>
<Columns :size="16" />
</button>
</div>
</div>
<!-- 内容区 -->
<div class="flex-1 overflow-hidden">
<div v-if="!note" class="flex items-center justify-center h-full">
<p :style="{ color: 'var(--p-surface-400)' }">加载中...</p>
</div>
<div v-else class="h-full flex">
<!-- 编辑区 -->
<div
v-if="editMode === 'edit' || editMode === 'split'"
class="h-full overflow-y-auto"
:class="editMode === 'split' ? 'w-1/2 border-r' : 'w-full'"
:style="{ borderColor: 'var(--p-surface-200)' }"
>
<textarea
v-model="content"
class="w-full h-full p-6 text-sm font-mono resize-none outline-none"
:style="{
backgroundColor: 'var(--p-surface-0)',
color: 'var(--p-surface-700)',
}"
placeholder="开始编写 Markdown..."
/>
</div>
<!-- 预览区 -->
<div
v-if="editMode === 'preview' || editMode === 'split'"
class="h-full overflow-y-auto p-6"
:class="editMode === 'split' ? 'w-1/2' : 'w-full'"
>
<div class="prose prose-sm max-w-none">
<pre class="whitespace-pre-wrap text-sm" :style="{ color: 'var(--p-surface-700)' }">{{ content }}</pre>
</div>
</div>
</div>
</div>
<!-- 状态栏 -->
<div
class="flex items-center justify-between px-6 py-1.5 border-t text-xs"
:style="{ borderColor: 'var(--p-surface-200)', color: 'var(--p-surface-400)' }"
>
<span>{{ isSaving ? '保存中...' : '已保存' }}</span>
<span>{{ content.length }} 字符</span>
</div>
</div>
</template>

108
src/views/NotesListView.vue Normal file
View File

@@ -0,0 +1,108 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNotesStore } from '@/stores/notes'
import { useWorkspaceStore } from '@/stores/workspace'
import { FileText, LayoutGrid, List } from 'lucide-vue-next'
const router = useRouter()
const notesStore = useNotesStore()
const workspaceStore = useWorkspaceStore()
onMounted(async () => {
if (workspaceStore.path) {
await notesStore.fetchNotes()
}
})
function openNote(note) {
router.push({ name: 'note-detail', params: { id: note.id } })
}
function toggleView() {
notesStore.viewMode = notesStore.viewMode === 'list' ? 'grid' : 'list'
}
</script>
<template>
<div class="h-full flex flex-col">
<!-- 头部 -->
<div
class="flex items-center justify-between px-6 py-4 border-b"
:style="{ borderColor: 'var(--p-surface-200)' }"
>
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">全部笔记</h1>
<button
class="p-1.5 rounded-md transition-colors"
:style="{ color: 'var(--p-surface-500)' }"
@click="toggleView"
:title="notesStore.viewMode === 'list' ? '卡片视图' : '列表视图'"
>
<component :is="notesStore.viewMode === 'list' ? LayoutGrid : List" :size="18" />
</button>
</div>
<!-- 内容区 -->
<div class="flex-1 overflow-y-auto p-6">
<!-- 无笔记提示 -->
<div
v-if="notesStore.filteredNotes.length === 0"
class="flex flex-col items-center justify-center h-full"
>
<div
class="w-16 h-16 rounded-xl flex items-center justify-center mb-4"
:style="{ backgroundColor: 'var(--p-surface-100)', color: 'var(--p-surface-400)' }"
>
<FileText :size="32" />
</div>
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">
{{ workspaceStore.path ? '暂无笔记,点击左侧 + 创建' : '请先选择工作目录' }}
</p>
</div>
<!-- 列表视图 -->
<div v-else-if="notesStore.viewMode === 'list'" class="space-y-1">
<div
v-for="note in notesStore.filteredNotes"
:key="note.id"
class="flex items-center gap-3 px-4 py-3 rounded-lg cursor-pointer transition-colors"
:style="{ color: 'var(--p-surface-700)' }"
@click="openNote(note)"
@mouseenter="$event.currentTarget.style.backgroundColor = 'var(--p-surface-50)'"
@mouseleave="$event.currentTarget.style.backgroundColor = 'transparent'"
>
<FileText :size="18" :style="{ color: 'var(--p-surface-400)' }" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ note.title }}</div>
<div class="text-xs mt-0.5" :style="{ color: 'var(--p-surface-400)' }">
{{ note.filePath }}
</div>
</div>
</div>
</div>
<!-- 卡片视图 -->
<div v-else class="grid grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="note in notesStore.filteredNotes"
:key="note.id"
class="p-4 rounded-xl border cursor-pointer transition-colors"
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
@click="openNote(note)"
@mouseenter="$event.currentTarget.style.borderColor = 'var(--p-primary-300)'"
@mouseleave="$event.currentTarget.style.borderColor = 'var(--p-surface-200)'"
>
<div class="flex items-center gap-2 mb-2">
<FileText :size="16" :style="{ color: 'var(--p-primary-500)' }" />
<span class="text-sm font-medium truncate" :style="{ color: 'var(--p-surface-700)' }">
{{ note.title }}
</span>
</div>
<p class="text-xs truncate" :style="{ color: 'var(--p-surface-400)' }">
{{ note.filePath }}
</p>
</div>
</div>
</div>
</div>
</template>

77
src/views/SearchView.vue Normal file
View File

@@ -0,0 +1,77 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNotesStore } from '@/stores/notes'
import { Search, FileText } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const notesStore = useNotesStore()
const query = ref(route.query.q || '')
onMounted(() => {
if (query.value) {
notesStore.setFilter('search', query.value)
}
})
watch(() => route.query.q, (newQ) => {
query.value = newQ || ''
notesStore.setFilter('search', query.value)
})
function openNote(note) {
router.push({ name: 'note-detail', params: { id: note.id } })
}
</script>
<template>
<div class="h-full flex flex-col">
<div
class="flex items-center justify-between px-6 py-4 border-b"
:style="{ borderColor: 'var(--p-surface-200)' }"
>
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">
搜索{{ query ? `: ${query}` : '' }}
</h1>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div v-if="!query" class="flex flex-col items-center justify-center h-full">
<div
class="w-16 h-16 rounded-xl flex items-center justify-center mb-4"
:style="{ backgroundColor: 'var(--p-surface-100)', color: 'var(--p-surface-400)' }"
>
<Search :size="32" />
</div>
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">输入关键词搜索笔记</p>
</div>
<div v-else-if="notesStore.filteredNotes.length === 0" class="flex flex-col items-center justify-center h-full">
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">未找到匹配的笔记</p>
</div>
<div v-else class="space-y-1">
<div
v-for="note in notesStore.filteredNotes"
:key="note.id"
class="flex items-center gap-3 px-4 py-3 rounded-lg cursor-pointer transition-colors"
@click="openNote(note)"
@mouseenter="$event.currentTarget.style.backgroundColor = 'var(--p-surface-50)'"
@mouseleave="$event.currentTarget.style.backgroundColor = 'transparent'"
>
<FileText :size="18" :style="{ color: 'var(--p-surface-400)' }" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate" :style="{ color: 'var(--p-surface-700)' }">
{{ note.title }}
</div>
<div class="text-xs mt-0.5" :style="{ color: 'var(--p-surface-400)' }">
{{ note.filePath }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,98 @@
<script setup>
import { useUiStore } from '@/stores/ui'
import { useWorkspaceStore } from '@/stores/workspace'
import { Settings, Sun, Moon, FolderOpen } from 'lucide-vue-next'
const uiStore = useUiStore()
const workspaceStore = useWorkspaceStore()
function toggleTheme() {
uiStore.setTheme(uiStore.theme === 'light' ? 'dark' : 'light')
}
async function changeWorkspace() {
await workspaceStore.selectWorkspace()
}
</script>
<template>
<div class="h-full flex flex-col">
<div
class="flex items-center justify-between px-6 py-4 border-b"
:style="{ borderColor: 'var(--p-surface-200)' }"
>
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">设置</h1>
</div>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<!-- 主题设置 -->
<div
class="rounded-xl p-5 border"
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
>
<div class="flex items-center gap-2 mb-4">
<component
:is="uiStore.theme === 'light' ? Sun : Moon"
:size="18"
:style="{ color: 'var(--p-primary-500)' }"
/>
<h2 class="text-sm font-semibold" :style="{ color: 'var(--p-surface-700)' }">外观</h2>
</div>
<div class="flex items-center justify-between">
<span class="text-sm" :style="{ color: 'var(--p-surface-600)' }">深色模式</span>
<button
class="relative w-11 h-6 rounded-full transition-colors"
:style="{ backgroundColor: uiStore.theme === 'dark' ? 'var(--p-primary-500)' : 'var(--p-surface-300)' }"
@click="toggleTheme"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="{ 'translate-x-5': uiStore.theme === 'dark' }"
/>
</button>
</div>
</div>
<!-- 工作目录设置 -->
<div
class="rounded-xl p-5 border"
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
>
<div class="flex items-center gap-2 mb-4">
<FolderOpen :size="18" :style="{ color: 'var(--p-primary-500)' }" />
<h2 class="text-sm font-semibold" :style="{ color: 'var(--p-surface-700)' }">工作目录</h2>
</div>
<div class="space-y-3">
<div>
<p class="text-xs mb-1" :style="{ color: 'var(--p-surface-400)' }">当前目录</p>
<p class="text-sm font-mono" :style="{ color: 'var(--p-surface-600)' }">
{{ workspaceStore.path || '未选择' }}
</p>
</div>
<button
class="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
:style="{ borderColor: 'var(--p-surface-200)', color: 'var(--p-surface-600)' }"
@click="changeWorkspace"
>
更换目录
</button>
</div>
</div>
<!-- 关于 -->
<div
class="rounded-xl p-5 border"
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
>
<div class="flex items-center gap-2 mb-4">
<Settings :size="18" :style="{ color: 'var(--p-primary-500)' }" />
<h2 class="text-sm font-semibold" :style="{ color: 'var(--p-surface-700)' }">关于</h2>
</div>
<p class="text-sm" :style="{ color: 'var(--p-surface-600)' }">Tunji v0.1.0</p>
<p class="text-xs mt-1" :style="{ color: 'var(--p-surface-400)' }">
类印象笔记 + Typora 的桌面笔记应用
</p>
</div>
</div>
</div>
</template>

25
src/views/TagsView.vue Normal file
View File

@@ -0,0 +1,25 @@
<script setup>
import { Tag } from 'lucide-vue-next'
</script>
<template>
<div class="h-full flex flex-col">
<div
class="flex items-center justify-between px-6 py-4 border-b"
:style="{ borderColor: 'var(--p-surface-200)' }"
>
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">标签</h1>
</div>
<div class="flex-1 flex items-center justify-center">
<div class="text-center">
<div
class="w-16 h-16 rounded-xl flex items-center justify-center mx-auto mb-4"
:style="{ backgroundColor: 'var(--p-surface-100)', color: 'var(--p-surface-400)' }"
>
<Tag :size="32" />
</div>
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">标签功能开发中...</p>
</div>
</div>
</div>
</template>

28
src/views/TrashView.vue Normal file
View File

@@ -0,0 +1,28 @@
<script setup>
import { Trash2 } from 'lucide-vue-next'
</script>
<template>
<div class="h-full flex flex-col">
<div
class="flex items-center justify-between px-6 py-4 border-b"
:style="{ borderColor: 'var(--p-surface-200)' }"
>
<h1 class="text-lg font-semibold" :style="{ color: 'var(--p-surface-800)' }">废纸篓</h1>
</div>
<div class="flex-1 flex items-center justify-center">
<div class="text-center">
<div
class="w-16 h-16 rounded-xl flex items-center justify-center mx-auto mb-4"
:style="{ backgroundColor: 'var(--p-surface-100)', color: 'var(--p-surface-400)' }"
>
<Trash2 :size="32" />
</div>
<p class="text-sm" :style="{ color: 'var(--p-surface-500)' }">废纸篓为空</p>
<p class="text-xs mt-1" :style="{ color: 'var(--p-surface-400)' }">
删除的笔记将在这里保留 30
</p>
</div>
</div>
</div>
</template>

87
src/views/WelcomeView.vue Normal file
View File

@@ -0,0 +1,87 @@
<script setup>
import { useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { FolderOpen, FileText } from 'lucide-vue-next'
const router = useRouter()
const workspaceStore = useWorkspaceStore()
async function selectWorkspace() {
const path = await workspaceStore.selectWorkspace()
if (path) {
router.push({ name: 'notes' })
}
}
</script>
<template>
<div class="flex items-center justify-center h-full" :style="{ backgroundColor: 'var(--p-surface-0)' }">
<div class="text-center max-w-md px-8">
<!-- Logo -->
<div class="mb-8">
<div
class="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center mb-4"
:style="{ backgroundColor: 'var(--p-primary-50)', color: 'var(--p-primary-500)' }"
>
<FileText :size="40" />
</div>
<h1 class="text-3xl font-bold" :style="{ color: 'var(--p-surface-800)' }">Tunji</h1>
<p class="mt-2 text-sm" :style="{ color: 'var(--p-surface-500)' }">
类印象笔记 + Typora 的桌面笔记应用
</p>
</div>
<!-- 工作目录选择 -->
<div
v-if="!workspaceStore.path"
class="rounded-xl p-6 border"
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
>
<p class="mb-4 text-sm" :style="{ color: 'var(--p-surface-600)' }">
请选择一个目录作为你的工作空间所有笔记将保存在该目录下
</p>
<button
class="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium text-white transition-colors"
:style="{ backgroundColor: 'var(--p-primary-500)' }"
@mouseenter="$event.target.style.backgroundColor = 'var(--p-primary-600)'"
@mouseleave="$event.target.style.backgroundColor = 'var(--p-primary-500)'"
@click="selectWorkspace"
>
<FolderOpen :size="18" />
选择工作目录
</button>
</div>
<!-- 已有工作目录 -->
<div v-else class="space-y-4">
<div
class="rounded-xl p-4 border"
:style="{ borderColor: 'var(--p-surface-200)', backgroundColor: 'var(--p-surface-50)' }"
>
<p class="text-xs mb-1" :style="{ color: 'var(--p-surface-400)' }">当前工作目录</p>
<p class="text-sm font-mono truncate" :style="{ color: 'var(--p-surface-700)' }">
{{ workspaceStore.path }}
</p>
</div>
<div class="flex gap-3 justify-center">
<button
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium text-white transition-colors"
:style="{ backgroundColor: 'var(--p-primary-500)' }"
@click="router.push({ name: 'notes' })"
>
<FileText :size="16" />
查看笔记
</button>
<button
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium border transition-colors"
:style="{ borderColor: 'var(--p-surface-200)', color: 'var(--p-surface-600)' }"
@click="selectWorkspace"
>
<FolderOpen :size="16" />
切换目录
</button>
</div>
</div>
</div>
</div>
</template>