first commit
This commit is contained in:
19
src/App.vue
Normal file
19
src/App.vue
Normal 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>
|
||||
44
src/components/common/ResizeHandle.vue
Normal file
44
src/components/common/ResizeHandle.vue
Normal 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>
|
||||
19
src/components/left-area/AddNoteButton.vue
Normal file
19
src/components/left-area/AddNoteButton.vue
Normal 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>
|
||||
73
src/components/left-area/LeftArea.vue
Normal file
73
src/components/left-area/LeftArea.vue
Normal 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>
|
||||
26
src/components/left-area/MenuItem.vue
Normal file
26
src/components/left-area/MenuItem.vue
Normal 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>
|
||||
23
src/components/left-area/NoteItem.vue
Normal file
23
src/components/left-area/NoteItem.vue
Normal 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>
|
||||
86
src/components/left-area/NotebookNode.vue
Normal file
86
src/components/left-area/NotebookNode.vue
Normal 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>
|
||||
33
src/components/left-area/NotebookTree.vue
Normal file
33
src/components/left-area/NotebookTree.vue
Normal 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>
|
||||
38
src/components/left-area/SearchInput.vue
Normal file
38
src/components/left-area/SearchInput.vue
Normal 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>
|
||||
15
src/components/left-area/UserProfile.vue
Normal file
15
src/components/left-area/UserProfile.vue
Normal 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>
|
||||
28
src/layouts/MainLayout.vue
Normal file
28
src/layouts/MainLayout.vue
Normal 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
16
src/main.js
Normal 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
57
src/router/index.js
Normal 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
69
src/stores/notebooks.js
Normal 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
96
src/stores/notes.js
Normal 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
23
src/stores/ui.js
Normal 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
32
src/stores/workspace.js
Normal 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
52
src/styles/main.css
Normal 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
52
src/styles/theme.css
Normal 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;
|
||||
}
|
||||
156
src/views/NoteDetailView.vue
Normal file
156
src/views/NoteDetailView.vue
Normal 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
108
src/views/NotesListView.vue
Normal 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
77
src/views/SearchView.vue
Normal 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>
|
||||
98
src/views/SettingsView.vue
Normal file
98
src/views/SettingsView.vue
Normal 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
25
src/views/TagsView.vue
Normal 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
28
src/views/TrashView.vue
Normal 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
87
src/views/WelcomeView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user