first commit
This commit is contained in:
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm init *)",
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"Bash(npm uninstall *)",
|
||||||
|
"Bash(npm ls *)",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(npm config *)",
|
||||||
|
"Bash(export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/)",
|
||||||
|
"Bash(node *)",
|
||||||
|
"Bash(ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node *)",
|
||||||
|
"Bash(pkill -f \"electron-vite\")",
|
||||||
|
"Bash(npm list *)",
|
||||||
|
"Bash(npx electron-vite *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.claudeignore
Normal file
6
.claudeignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.idea/
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/tunji.iml" filepath="$PROJECT_DIR$/.idea/tunji.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/tunji.iml
generated
Normal file
8
.idea/tunji.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
46
electron.vite.config.mjs
Normal file
46
electron.vite.config.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/main/index.js')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/main/indexpreload.js')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
root: resolve(__dirname, 'src/renderer'),
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/renderer/index.html')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss()
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src/renderer/src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
7787
package-lock.json
generated
Normal file
7787
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "tunji",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A Markdown editor inspired by Typora",
|
||||||
|
"main": "./out/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build",
|
||||||
|
"preview": "electron-vite preview",
|
||||||
|
"package": "electron-builder"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"markdown",
|
||||||
|
"editor",
|
||||||
|
"electron"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@lucide/vue": "^1.17.0",
|
||||||
|
"@primeuix/themes": "^2.0.3",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"electron": "^42.3.0",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
|
"primevue": "^4.5.5",
|
||||||
|
"rehype-stringify": "^10.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"vue": "^3.5.35"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"electron-builder": "^26.8.1",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"vite": "^7.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/main/index.js
Normal file
74
src/main/index.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { is } from '@electron-toolkit/utils'
|
||||||
|
|
||||||
|
let mainWindow = null
|
||||||
|
|
||||||
|
// 标题栏颜色配置
|
||||||
|
const titleBarColors = {
|
||||||
|
light: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
symbolColor: '#111827'
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
color: '#1e1e2e',
|
||||||
|
symbolColor: '#cdd6f4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
// 根据系统主题设置初始标题栏颜色
|
||||||
|
const isDark = nativeTheme.shouldUseDarkColors
|
||||||
|
const colors = isDark ? titleBarColors.dark : titleBarColors.light
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: colors.color,
|
||||||
|
symbolColor: colors.symbolColor,
|
||||||
|
height: 36
|
||||||
|
},
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
|
||||||
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
|
mainWindow.webContents.openDevTools({ mode: 'right' })
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理渲染进程的主题切换请求
|
||||||
|
ipcMain.handle('theme:set', (_, theme) => {
|
||||||
|
if (!mainWindow) return
|
||||||
|
|
||||||
|
const colors = theme === 'dark' ? titleBarColors.dark : titleBarColors.light
|
||||||
|
mainWindow.setTitleBarOverlay({
|
||||||
|
color: colors.color,
|
||||||
|
symbolColor: colors.symbolColor,
|
||||||
|
height: 36
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(createWindow)
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
7
src/main/indexpreload.js
Normal file
7
src/main/indexpreload.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
// 安全地暴露 API 给渲染进程
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
platform: process.platform,
|
||||||
|
setTheme: (theme) => ipcRenderer.invoke('theme:set', theme)
|
||||||
|
})
|
||||||
22
src/renderer/index.html
Normal file
22
src/renderer/index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tunji - Markdown Editor</title>
|
||||||
|
<script>
|
||||||
|
// 防止主题闪烁:在 Vue 挂载前同步应用暗色类
|
||||||
|
;(function () {
|
||||||
|
var t = localStorage.getItem('tunji-theme')
|
||||||
|
var dark =
|
||||||
|
t === 'dark' ||
|
||||||
|
(t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
if (dark) document.documentElement.classList.add('dark')
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
128
src/renderer/src/App.vue
Normal file
128
src/renderer/src/App.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SideBar from './components/SideBar.vue'
|
||||||
|
import NotesOverview from './components/NotesOverview.vue'
|
||||||
|
import EditorPanel from './components/EditorPanel.vue'
|
||||||
|
import { useTheme } from './composables/useTheme'
|
||||||
|
|
||||||
|
// 初始化主题系统
|
||||||
|
const { theme, resolvedTheme, isDark, toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
const currentNav = ref('all')
|
||||||
|
const currentNote = ref(null)
|
||||||
|
const filterType = ref('all')
|
||||||
|
const filterValue = ref(null)
|
||||||
|
|
||||||
|
// Mock data shared between overview and sidebar
|
||||||
|
const allNotes = ref([
|
||||||
|
{ id: 1, title: '项目需求文档', preview: '本文档描述了Tunji编辑器的核心功能需求和设计方案...', date: '2026-05-31', notebook: '工作', tags: ['重要', '参考'] },
|
||||||
|
{ id: 2, title: '读书笔记 - 深入理解计算机系统', preview: '第一章:计算机系统是由硬件和系统软件组成的...', date: '2026-05-30', notebook: '学习', tags: ['参考'] },
|
||||||
|
{ id: 3, title: '周报 - 第22周', preview: '本周完成了编辑器基础布局搭建,实现了三栏式布局...', date: '2026-05-29', notebook: '工作', tags: ['待办'] },
|
||||||
|
{ id: 4, title: 'Vue 3 学习笔记', preview: 'Composition API 提供了更灵活的代码组织方式,setup函数...', date: '2026-05-28', notebook: '学习', tags: ['参考'] },
|
||||||
|
{ id: 5, title: '旅行计划 - 日本', preview: '东京 -> 京都 -> 大阪,预计行程7天,预算约15000元...', date: '2026-05-27', notebook: '生活', tags: ['灵感'] },
|
||||||
|
{ id: 6, title: 'Markdown 语法速查表', preview: '标题用#号,加粗用**,斜体用*,代码用反引号...', date: '2026-05-26', notebook: '技术', tags: ['参考'] },
|
||||||
|
{ id: 7, title: '会议纪要 - 产品评审', preview: '参会人员:产品、设计、开发。讨论了v1.0的优先级...', date: '2026-05-25', notebook: '工作', tags: ['会议', '重要'] },
|
||||||
|
{ id: 8, title: '健身计划', preview: '周一:胸+三头,周二:背+二头,周三:休息,周四:腿...', date: '2026-05-24', notebook: '生活', tags: ['待办'] },
|
||||||
|
])
|
||||||
|
|
||||||
|
const overviewNotes = ref([])
|
||||||
|
|
||||||
|
function computeOverviewNotes() {
|
||||||
|
let result = allNotes.value
|
||||||
|
if (filterType.value === 'notebook' && filterValue.value) {
|
||||||
|
result = result.filter(n => n.notebook === filterValue.value.name)
|
||||||
|
} else if (filterType.value === 'tag' && filterValue.value) {
|
||||||
|
result = result.filter(n => n.tags.includes(filterValue.value.name))
|
||||||
|
}
|
||||||
|
overviewNotes.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNavigate(id) {
|
||||||
|
currentNav.value = id
|
||||||
|
filterType.value = 'all'
|
||||||
|
filterValue.value = null
|
||||||
|
currentNote.value = null
|
||||||
|
computeOverviewNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectNote(note) {
|
||||||
|
currentNote.value = note
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackToOverview() {
|
||||||
|
currentNote.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreateNote() {
|
||||||
|
// TODO: Implement note creation
|
||||||
|
console.log('Creating new note...')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectNotebook(notebook) {
|
||||||
|
filterType.value = 'notebook'
|
||||||
|
filterValue.value = notebook
|
||||||
|
currentNote.value = null
|
||||||
|
computeOverviewNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectTag(tag) {
|
||||||
|
filterType.value = 'tag'
|
||||||
|
filterValue.value = tag
|
||||||
|
currentNote.value = null
|
||||||
|
computeOverviewNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
computeOverviewNotes()
|
||||||
|
|
||||||
|
function getOverviewTitle() {
|
||||||
|
if (filterType.value === 'notebook' && filterValue.value) {
|
||||||
|
return filterValue.value.name
|
||||||
|
}
|
||||||
|
if (filterType.value === 'tag' && filterValue.value) {
|
||||||
|
return `标签: ${filterValue.value.name}`
|
||||||
|
}
|
||||||
|
const titleMap = {
|
||||||
|
all: '所有笔记',
|
||||||
|
recent: '最近编辑',
|
||||||
|
favorites: '收藏夹',
|
||||||
|
trash: '回收站'
|
||||||
|
}
|
||||||
|
return titleMap[currentNav.value] || '所有笔记'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-screen flex flex-col overflow-hidden bg-surface-50">
|
||||||
|
<!-- Main Layout: Two Columns -->
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<SideBar
|
||||||
|
@navigate="onNavigate"
|
||||||
|
@create-note="onCreateNote"
|
||||||
|
@select-notebook="onSelectNotebook"
|
||||||
|
@select-tag="onSelectTag"
|
||||||
|
@select-note="onSelectNote"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Right Content Area -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<!-- Notes Overview (when no note is selected) -->
|
||||||
|
<NotesOverview
|
||||||
|
v-if="!currentNote"
|
||||||
|
:notes="overviewNotes"
|
||||||
|
:title="getOverviewTitle()"
|
||||||
|
@select-note="onSelectNote"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Editor Panel (when a note is selected) -->
|
||||||
|
<EditorPanel
|
||||||
|
v-else
|
||||||
|
:note="currentNote"
|
||||||
|
@back="onBackToOverview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
209
src/renderer/src/assets/styles/main.css
Normal file
209
src/renderer/src/assets/styles/main.css
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
* Tunji Theme System
|
||||||
|
* 使用 CSS 自定义变量实现浅/暗双主题
|
||||||
|
* ============================================ */
|
||||||
|
|
||||||
|
/* 配置 Tailwind 使用 class 策略的暗色模式 */
|
||||||
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
/* ----- 浅色模式变量 ----- */
|
||||||
|
:root {
|
||||||
|
/* Primary - Indigo 色系 */
|
||||||
|
--theme-primary-50: #eef2ff;
|
||||||
|
--theme-primary-100: #e0e7ff;
|
||||||
|
--theme-primary-200: #c7d2fe;
|
||||||
|
--theme-primary-300: #a5b4fc;
|
||||||
|
--theme-primary-400: #818cf8;
|
||||||
|
--theme-primary-500: #6366f1;
|
||||||
|
--theme-primary-600: #4f46e5;
|
||||||
|
--theme-primary-700: #4338ca;
|
||||||
|
--theme-primary-800: #3730a3;
|
||||||
|
--theme-primary-900: #312e81;
|
||||||
|
--theme-primary-950: #1e1b4b;
|
||||||
|
|
||||||
|
/* Neutral - Gray 色系 */
|
||||||
|
--theme-gray-50: #f9fafb;
|
||||||
|
--theme-gray-100: #f3f4f6;
|
||||||
|
--theme-gray-200: #e5e7eb;
|
||||||
|
--theme-gray-300: #d1d5db;
|
||||||
|
--theme-gray-400: #9ca3af;
|
||||||
|
--theme-gray-500: #6b7280;
|
||||||
|
--theme-gray-600: #4b5563;
|
||||||
|
--theme-gray-700: #374151;
|
||||||
|
--theme-gray-800: #1f2937;
|
||||||
|
--theme-gray-900: #111827;
|
||||||
|
--theme-gray-950: #030712;
|
||||||
|
|
||||||
|
/* Semantic 语义色 */
|
||||||
|
--theme-success: #22c55e;
|
||||||
|
--theme-warning: #f59e0b;
|
||||||
|
--theme-danger: #ef4444;
|
||||||
|
--theme-info: #3b82f6;
|
||||||
|
|
||||||
|
/* App-level 应用级色彩 */
|
||||||
|
--theme-bg: #f9fafb;
|
||||||
|
--theme-bg-elevated: #ffffff;
|
||||||
|
--theme-text: #111827;
|
||||||
|
--theme-text-secondary: #4b5563;
|
||||||
|
--theme-text-muted: #9ca3af;
|
||||||
|
--theme-border: #e5e7eb;
|
||||||
|
--theme-ring: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- 暗色模式变量 ----- */
|
||||||
|
.dark {
|
||||||
|
/* Primary - Indigo (暗色下使用更亮的色阶) */
|
||||||
|
--theme-primary-50: #1e1b4b;
|
||||||
|
--theme-primary-100: #312e81;
|
||||||
|
--theme-primary-200: #3730a3;
|
||||||
|
--theme-primary-300: #4338ca;
|
||||||
|
--theme-primary-400: #4f46e5;
|
||||||
|
--theme-primary-500: #818cf8;
|
||||||
|
--theme-primary-600: #a5b4fc;
|
||||||
|
--theme-primary-700: #c7d2fe;
|
||||||
|
--theme-primary-800: #e0e7ff;
|
||||||
|
--theme-primary-900: #eef2ff;
|
||||||
|
--theme-primary-950: #f5f7ff;
|
||||||
|
|
||||||
|
/* Neutral - Catppuccin Mocha 风格 */
|
||||||
|
--theme-gray-50: #1e1e2e;
|
||||||
|
--theme-gray-100: #181825;
|
||||||
|
--theme-gray-200: #313244;
|
||||||
|
--theme-gray-300: #45475a;
|
||||||
|
--theme-gray-400: #585b70;
|
||||||
|
--theme-gray-500: #6c7086;
|
||||||
|
--theme-gray-600: #a6adc8;
|
||||||
|
--theme-gray-700: #bac2de;
|
||||||
|
--theme-gray-800: #cdd6f4;
|
||||||
|
--theme-gray-900: #e2e8f0;
|
||||||
|
--theme-gray-950: #f5f7ff;
|
||||||
|
|
||||||
|
/* Semantic 语义色 (暗色下更亮) */
|
||||||
|
--theme-success: #4ade80;
|
||||||
|
--theme-warning: #fbbf24;
|
||||||
|
--theme-danger: #f87171;
|
||||||
|
--theme-info: #60a5fa;
|
||||||
|
|
||||||
|
/* App-level 应用级色彩 */
|
||||||
|
--theme-bg: #11111b;
|
||||||
|
--theme-bg-elevated: #1e1e2e;
|
||||||
|
--theme-text: #cdd6f4;
|
||||||
|
--theme-text-secondary: #a6adc8;
|
||||||
|
--theme-text-muted: #585b70;
|
||||||
|
--theme-border: #313244;
|
||||||
|
--theme-ring: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- 注册到 Tailwind ----- */
|
||||||
|
@theme {
|
||||||
|
/* Primary 色系 -> 可用 bg-primary-500, text-primary-600 等 */
|
||||||
|
--color-primary-50: var(--theme-primary-50);
|
||||||
|
--color-primary-100: var(--theme-primary-100);
|
||||||
|
--color-primary-200: var(--theme-primary-200);
|
||||||
|
--color-primary-300: var(--theme-primary-300);
|
||||||
|
--color-primary-400: var(--theme-primary-400);
|
||||||
|
--color-primary-500: var(--theme-primary-500);
|
||||||
|
--color-primary-600: var(--theme-primary-600);
|
||||||
|
--color-primary-700: var(--theme-primary-700);
|
||||||
|
--color-primary-800: var(--theme-primary-800);
|
||||||
|
--color-primary-900: var(--theme-primary-900);
|
||||||
|
--color-primary-950: var(--theme-primary-950);
|
||||||
|
|
||||||
|
/* Gray 色系 -> 可用 bg-gray-100, text-gray-700 等 */
|
||||||
|
--color-gray-50: var(--theme-gray-50);
|
||||||
|
--color-gray-100: var(--theme-gray-100);
|
||||||
|
--color-gray-200: var(--theme-gray-200);
|
||||||
|
--color-gray-300: var(--theme-gray-300);
|
||||||
|
--color-gray-400: var(--theme-gray-400);
|
||||||
|
--color-gray-500: var(--theme-gray-500);
|
||||||
|
--color-gray-600: var(--theme-gray-600);
|
||||||
|
--color-gray-700: var(--theme-gray-700);
|
||||||
|
--color-gray-800: var(--theme-gray-800);
|
||||||
|
--color-gray-900: var(--theme-gray-900);
|
||||||
|
--color-gray-950: var(--theme-gray-950);
|
||||||
|
|
||||||
|
/* Semantic 语义色 -> 可用 bg-success, text-danger 等 */
|
||||||
|
--color-success: var(--theme-success);
|
||||||
|
--color-warning: var(--theme-warning);
|
||||||
|
--color-danger: var(--theme-danger);
|
||||||
|
--color-info: var(--theme-info);
|
||||||
|
|
||||||
|
/* App-level 应用级 -> 可用 bg-app, text-app-text 等 */
|
||||||
|
--color-app: var(--theme-bg);
|
||||||
|
--color-app-elevated: var(--theme-bg-elevated);
|
||||||
|
--color-app-text: var(--theme-text);
|
||||||
|
--color-app-text-secondary: var(--theme-text-secondary);
|
||||||
|
--color-app-text-muted: var(--theme-text-muted);
|
||||||
|
--color-app-border: var(--theme-border);
|
||||||
|
--color-app-ring: var(--theme-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- 统一按钮样式 ----- */
|
||||||
|
|
||||||
|
/* PrimeVue Button 统一 12px 圆角 */
|
||||||
|
.p-button {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮统一交互过渡 */
|
||||||
|
.btn-icon {
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 激活态按钮 */
|
||||||
|
.btn-active {
|
||||||
|
background: var(--theme-bg-elevated) !important;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
|
||||||
|
color: var(--theme-primary-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 实心主色按钮 */
|
||||||
|
.btn-filled:not(.p-button-text) {
|
||||||
|
background: #00bf66 !important;
|
||||||
|
border-color: #00bf66 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-filled:not(.p-button-text):hover {
|
||||||
|
background: #00a555 !important;
|
||||||
|
border-color: #00a555 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-filled:not(.p-button-text):active {
|
||||||
|
background: #00914b !important;
|
||||||
|
border-color: #00914b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- 全局基础样式 ----- */
|
||||||
|
|
||||||
|
/* 主题切换平滑过渡 */
|
||||||
|
html {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式下的滚动条 */
|
||||||
|
.dark ::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: var(--theme-gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--theme-gray-400);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--theme-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中文本颜色 */
|
||||||
|
.dark ::selection {
|
||||||
|
background-color: rgba(129, 140, 248, 0.3);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
90
src/renderer/src/components/ButtonIcon.vue
Normal file
90
src/renderer/src/components/ButtonIcon.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
icon: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 18
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'var(--text-color-secondary)'
|
||||||
|
},
|
||||||
|
activeColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'var(--primary-color)'
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
|
const isAnimating = ref(false)
|
||||||
|
|
||||||
|
function handleClick(event) {
|
||||||
|
isAnimating.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
isAnimating.value = false
|
||||||
|
}, 200)
|
||||||
|
emit('click', event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-tooltip="tooltip || undefined"
|
||||||
|
:class="['btn-icon', { 'btn-active': active, 'btn-click-animate': isAnimating }]"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="icon"
|
||||||
|
:size="size"
|
||||||
|
:color="active ? activeColor : color"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btn-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-active {
|
||||||
|
background-color: var(--primary-50);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-click-animate {
|
||||||
|
animation: clickScale 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes clickScale {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.25); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
410
src/renderer/src/components/EditorPanel.vue
Normal file
410
src/renderer/src/components/EditorPanel.vue
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Toolbar from 'primevue/toolbar'
|
||||||
|
import Divider from 'primevue/divider'
|
||||||
|
import SelectButton from 'primevue/selectbutton'
|
||||||
|
import {
|
||||||
|
ArrowLeft, ChevronLeft, ChevronRight,
|
||||||
|
Bold, Italic, Underline, Strikethrough,
|
||||||
|
AlignLeft, AlignCenter, AlignRight,
|
||||||
|
List, ListOrdered, Code, Link, Image
|
||||||
|
} from '@lucide/vue'
|
||||||
|
import { unified } from 'unified'
|
||||||
|
import remarkParse from 'remark-parse'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkRehype from 'remark-rehype'
|
||||||
|
import rehypeStringify from 'rehype-stringify'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
note: { type: Object, default: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['back'])
|
||||||
|
|
||||||
|
const noteTitle = ref('')
|
||||||
|
const noteContent = ref('')
|
||||||
|
|
||||||
|
// View mode: edit | preview | split
|
||||||
|
const viewMode = ref('edit')
|
||||||
|
const viewModeOptions = [
|
||||||
|
{ value: 'edit', label: '编辑' },
|
||||||
|
{ value: 'preview', label: '预览' },
|
||||||
|
{ value: 'split', label: '分屏' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Outline collapse state
|
||||||
|
const outlineCollapsed = ref(false)
|
||||||
|
|
||||||
|
// Parsed headings for outline
|
||||||
|
const headings = ref([])
|
||||||
|
|
||||||
|
// Rendered HTML for preview
|
||||||
|
const renderedHtml = ref('')
|
||||||
|
|
||||||
|
// Update local state when note prop changes
|
||||||
|
watch(() => props.note, (newNote) => {
|
||||||
|
if (newNote) {
|
||||||
|
noteTitle.value = newNote.title || ''
|
||||||
|
noteContent.value = newNote.content || getDefaultContent(newNote.title)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Parse headings from markdown content
|
||||||
|
watch(noteContent, (content) => {
|
||||||
|
const result = []
|
||||||
|
const regex = /^(#{1,6})\s+(.+)$/gm
|
||||||
|
let match
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
const level = match[1].length
|
||||||
|
const text = match[2].trim()
|
||||||
|
const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w一-鿿-]/g, '')
|
||||||
|
result.push({ level, text, id })
|
||||||
|
}
|
||||||
|
headings.value = result
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Render markdown to HTML
|
||||||
|
watch(noteContent, async (content) => {
|
||||||
|
if (!content) {
|
||||||
|
renderedHtml.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const file = await unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeStringify)
|
||||||
|
.process(content)
|
||||||
|
renderedHtml.value = String(file)
|
||||||
|
} catch {
|
||||||
|
renderedHtml.value = '<p style="color: #ef4444;">渲染出错</p>'
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function getDefaultContent(title) {
|
||||||
|
return `# ${title}
|
||||||
|
|
||||||
|
在这里开始写作...
|
||||||
|
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
emit('back')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOutline() {
|
||||||
|
outlineCollapsed.value = !outlineCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarItems = [
|
||||||
|
{ icon: Bold, title: '加粗' },
|
||||||
|
{ icon: Italic, title: '斜体' },
|
||||||
|
{ icon: Underline, title: '下划线' },
|
||||||
|
{ icon: Strikethrough, title: '删除线' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const toolbarItems2 = [
|
||||||
|
{ icon: AlignLeft, title: '左对齐' },
|
||||||
|
{ icon: AlignCenter, title: '居中' },
|
||||||
|
{ icon: AlignRight, title: '右对齐' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const toolbarItems3 = [
|
||||||
|
{ icon: List, title: '无序列表' },
|
||||||
|
{ icon: ListOrdered, title: '有序列表' },
|
||||||
|
{ icon: Code, title: '代码块' },
|
||||||
|
{ icon: Link, title: '链接' },
|
||||||
|
{ icon: Image, title: '图片' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full bg-surface-0">
|
||||||
|
<!-- Header with back button -->
|
||||||
|
<div class="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||||
|
<Button text severity="secondary" size="small" @click="goBack" v-tooltip.right="'返回笔记列表'" class="btn-icon w-8 h-8 shrink-0">
|
||||||
|
<template #icon>
|
||||||
|
<ArrowLeft :size="18" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
v-model="noteTitle"
|
||||||
|
class="w-full text-2xl font-bold text-surface-900 border-none outline-none bg-transparent placeholder:text-surface-300"
|
||||||
|
placeholder="输入标题..."
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-3 mt-1 text-xs text-surface-400">
|
||||||
|
<span v-if="note?.date">最后编辑: {{ formatDate(note.date) }}</span>
|
||||||
|
<span v-if="note?.date && note?.notebook">|</span>
|
||||||
|
<span v-if="note?.notebook">{{ note.notebook }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<Toolbar class="border-none bg-surface-0 px-4">
|
||||||
|
<template #start>
|
||||||
|
<div class="flex items-center gap-0.5">
|
||||||
|
<Button v-for="item in toolbarItems" :key="item.title" :title="item.title" text severity="secondary" size="small" class="btn-icon w-8 h-8">
|
||||||
|
<template #icon>
|
||||||
|
<component :is="item.icon" :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Divider layout="vertical" class="mx-1 h-5" />
|
||||||
|
<Button v-for="item in toolbarItems2" :key="item.title" :title="item.title" text severity="secondary" size="small" class="btn-icon w-8 h-8">
|
||||||
|
<template #icon>
|
||||||
|
<component :is="item.icon" :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Divider layout="vertical" class="mx-1 h-5" />
|
||||||
|
<Button v-for="item in toolbarItems3" :key="item.title" :title="item.title" text severity="secondary" size="small" class="btn-icon w-8 h-8">
|
||||||
|
<template #icon>
|
||||||
|
<component :is="item.icon" :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #end>
|
||||||
|
<SelectButton
|
||||||
|
v-model="viewMode"
|
||||||
|
:options="viewModeOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
:allow-empty="false"
|
||||||
|
class="text-xs"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<Divider class="m-0" />
|
||||||
|
|
||||||
|
<!-- Main content area: outline + editor/preview -->
|
||||||
|
<div class="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Outline panel -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 border-r border-app-border bg-surface-50 transition-all duration-300 overflow-hidden flex flex-col"
|
||||||
|
:class="outlineCollapsed ? 'w-0' : 'w-48'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-app-border shrink-0">
|
||||||
|
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">大纲</span>
|
||||||
|
<Button text severity="secondary" size="small" @click="toggleOutline" v-tooltip.right="'收缩大纲'" class="btn-icon w-6 h-6">
|
||||||
|
<template #icon>
|
||||||
|
<ChevronLeft :size="14" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto py-2">
|
||||||
|
<div v-if="headings.length === 0" class="px-3 py-2 text-xs text-surface-400">
|
||||||
|
暂无标题
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(heading, index) in headings"
|
||||||
|
:key="index"
|
||||||
|
class="px-3 py-1.5 text-sm text-surface-600 hover:bg-surface-100 cursor-pointer truncate transition-colors"
|
||||||
|
:style="{ paddingLeft: `${(heading.level - 1) * 12 + 12}px` }"
|
||||||
|
:title="heading.text"
|
||||||
|
>
|
||||||
|
{{ heading.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Outline toggle button (when collapsed) -->
|
||||||
|
<div
|
||||||
|
v-if="outlineCollapsed"
|
||||||
|
class="shrink-0 flex items-start pt-2 px-0.5 border-r border-app-border bg-surface-50"
|
||||||
|
>
|
||||||
|
<Button text severity="secondary" size="small" @click="toggleOutline" v-tooltip.right="'展开大纲'" class="btn-icon w-6 h-6">
|
||||||
|
<template #icon>
|
||||||
|
<ChevronRight :size="14" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor / Preview / Split area -->
|
||||||
|
<div class="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Edit mode: full width textarea -->
|
||||||
|
<div v-if="viewMode === 'edit'" class="flex-1 overflow-auto px-6 py-4">
|
||||||
|
<textarea
|
||||||
|
v-model="noteContent"
|
||||||
|
class="w-full h-full resize-none border-none outline-none text-surface-800 leading-relaxed bg-transparent font-mono text-sm"
|
||||||
|
placeholder="开始写作..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview mode: full width rendered HTML -->
|
||||||
|
<div v-else-if="viewMode === 'preview'" class="flex-1 overflow-auto px-6 py-4">
|
||||||
|
<div class="markdown-body" v-html="renderedHtml"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Split mode: left editor + right preview -->
|
||||||
|
<template v-else-if="viewMode === 'split'">
|
||||||
|
<div class="flex-1 overflow-auto px-4 py-4 border-r border-app-border">
|
||||||
|
<textarea
|
||||||
|
v-model="noteContent"
|
||||||
|
class="w-full h-full resize-none border-none outline-none text-surface-800 leading-relaxed bg-transparent font-mono text-sm"
|
||||||
|
placeholder="开始写作..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto px-4 py-4">
|
||||||
|
<div class="markdown-body" v-html="renderedHtml"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Markdown preview styles */
|
||||||
|
.markdown-body :deep(h1) {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
border-bottom: 1px solid var(--theme-border);
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(h2) {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--theme-border);
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(h3) {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.8rem 0 0.4rem;
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(h4) {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.6rem 0 0.3rem;
|
||||||
|
color: var(--p-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(h5),
|
||||||
|
.markdown-body :deep(h6) {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.5rem 0 0.3rem;
|
||||||
|
color: var(--p-surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(p) {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--p-surface-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(em) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(code) {
|
||||||
|
background: var(--p-surface-100);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875em;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
color: var(--p-primary-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(pre) {
|
||||||
|
background: var(--p-surface-900);
|
||||||
|
color: var(--p-surface-100);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(pre code) {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(blockquote) {
|
||||||
|
border-left: 4px solid var(--p-primary-400);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
background: var(--p-primary-50);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
color: var(--p-surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(ul),
|
||||||
|
.markdown-body :deep(ol) {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(li) {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--p-surface-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--theme-border);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(a) {
|
||||||
|
color: var(--p-primary-600);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(a:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(th),
|
||||||
|
.markdown-body :deep(td) {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(th) {
|
||||||
|
background: var(--p-surface-50);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
142
src/renderer/src/components/NoteList.vue
Normal file
142
src/renderer/src/components/NoteList.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import IconField from 'primevue/iconfield'
|
||||||
|
import InputIcon from 'primevue/inputicon'
|
||||||
|
import ScrollPanel from 'primevue/scrollpanel'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
import { Search, Filter } from '@lucide/vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
filterType: { type: String, default: 'all' },
|
||||||
|
filterValue: { type: [String, Object], default: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-note'])
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedNoteId = ref(1)
|
||||||
|
|
||||||
|
const notes = ref([
|
||||||
|
{ id: 1, title: '项目需求文档', preview: '本文档描述了Tunji编辑器的核心功能需求和设计方案...', date: '2026-05-31', notebook: '工作', tags: ['重要', '参考'] },
|
||||||
|
{ id: 2, title: '读书笔记 - 深入理解计算机系统', preview: '第一章:计算机系统是由硬件和系统软件组成的...', date: '2026-05-30', notebook: '学习', tags: ['参考'] },
|
||||||
|
{ id: 3, title: '周报 - 第22周', preview: '本周完成了编辑器基础布局搭建,实现了三栏式布局...', date: '2026-05-29', notebook: '工作', tags: ['待办'] },
|
||||||
|
{ id: 4, title: 'Vue 3 学习笔记', preview: 'Composition API 提供了更灵活的代码组织方式,setup函数...', date: '2026-05-28', notebook: '学习', tags: ['参考'] },
|
||||||
|
{ id: 5, title: '旅行计划 - 日本', preview: '东京 -> 京都 -> 大阪,预计行程7天,预算约15000元...', date: '2026-05-27', notebook: '生活', tags: ['灵感'] },
|
||||||
|
{ id: 6, title: 'Markdown 语法速查表', preview: '标题用#号,加粗用**,斜体用*,代码用反引号...', date: '2026-05-26', notebook: '技术', tags: ['参考'] },
|
||||||
|
{ id: 7, title: '会议纪要 - 产品评审', preview: '参会人员:产品、设计、开发。讨论了v1.0的优先级...', date: '2026-05-25', notebook: '工作', tags: ['会议', '重要'] },
|
||||||
|
{ id: 8, title: '健身计划', preview: '周一:胸+三头,周二:背+二头,周三:休息,周四:腿...', date: '2026-05-24', notebook: '生活', tags: ['待办'] },
|
||||||
|
])
|
||||||
|
|
||||||
|
const filteredNotes = computed(() => {
|
||||||
|
let result = notes.value
|
||||||
|
|
||||||
|
// Apply filter based on filterType
|
||||||
|
if (props.filterType === 'notebook' && props.filterValue) {
|
||||||
|
result = result.filter(n => n.notebook === props.filterValue.name)
|
||||||
|
} else if (props.filterType === 'tag' && props.filterValue) {
|
||||||
|
result = result.filter(n => n.tags.includes(props.filterValue.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search query
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
n => n.title.toLowerCase().includes(q) || n.preview.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectNote(note) {
|
||||||
|
selectedNoteId.value = note.id
|
||||||
|
emit('select-note', note)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const month = d.getMonth() + 1
|
||||||
|
const day = d.getDate()
|
||||||
|
return `${month}月${day}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagSeverity(tag) {
|
||||||
|
const severityMap = {
|
||||||
|
'重要': 'danger',
|
||||||
|
'待办': 'warning',
|
||||||
|
'参考': 'info',
|
||||||
|
'灵感': 'success',
|
||||||
|
'会议': 'secondary'
|
||||||
|
}
|
||||||
|
return severityMap[tag] || 'secondary'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full bg-surface-0 border-r border-app-border">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="p-3 border-b border-app-border">
|
||||||
|
<IconField>
|
||||||
|
<InputIcon>
|
||||||
|
<Search :size="16" />
|
||||||
|
</InputIcon>
|
||||||
|
<InputText
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索笔记..."
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Info -->
|
||||||
|
<div v-if="filterType !== 'all'" class="px-3 py-2 bg-surface-50 border-b border-app-border flex items-center gap-2">
|
||||||
|
<Filter :size="14" class="text-surface-400" />
|
||||||
|
<span class="text-sm text-surface-600">
|
||||||
|
{{ filterType === 'notebook' ? '笔记本' : '标签' }}: {{ filterValue?.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note List -->
|
||||||
|
<ScrollPanel class="flex-1">
|
||||||
|
<div
|
||||||
|
v-for="note in filteredNotes"
|
||||||
|
:key="note.id"
|
||||||
|
class="px-3 py-3 cursor-pointer border-b border-surface-100 transition-colors"
|
||||||
|
:class="selectedNoteId === note.id ? 'bg-primary-50' : 'hover:bg-surface-50'"
|
||||||
|
@click="selectNote(note)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-sm font-semibold text-surface-900 truncate">{{ note.title }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-surface-500 line-clamp-2 mb-1.5">{{ note.preview }}</p>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-surface-400">{{ formatDate(note.date) }}</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Tag
|
||||||
|
v-for="tag in note.tags.slice(0, 2)"
|
||||||
|
:key="tag"
|
||||||
|
:value="tag"
|
||||||
|
:severity="getTagSeverity(tag)"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
<span v-if="note.tags.length > 2" class="text-xs text-surface-400">+{{ note.tags.length - 2 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredNotes.length === 0" class="p-4 text-center text-surface-400 text-sm">
|
||||||
|
没有找到笔记
|
||||||
|
</div>
|
||||||
|
</ScrollPanel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
202
src/renderer/src/components/NotesOverview.vue
Normal file
202
src/renderer/src/components/NotesOverview.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import IconField from 'primevue/iconfield'
|
||||||
|
import InputIcon from 'primevue/inputicon'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
import ScrollPanel from 'primevue/scrollpanel'
|
||||||
|
import { Search, LayoutGrid, List, FileText, Clock, Calendar, Folder } from '@lucide/vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
notes: { type: Array, required: true },
|
||||||
|
title: { type: String, default: '所有笔记' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-note'])
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const viewMode = ref('card') // 'card' | 'list'
|
||||||
|
|
||||||
|
const filteredNotes = computed(() => {
|
||||||
|
if (!searchQuery.value) return props.notes
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return props.notes.filter(
|
||||||
|
n => n.title.toLowerCase().includes(q) || n.preview.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectNote(note) {
|
||||||
|
emit('select-note', note)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const month = d.getMonth() + 1
|
||||||
|
const day = d.getDate()
|
||||||
|
return `${month}月${day}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagSeverity(tag) {
|
||||||
|
const severityMap = {
|
||||||
|
'重要': 'danger',
|
||||||
|
'待办': 'warning',
|
||||||
|
'参考': 'info',
|
||||||
|
'灵感': 'success',
|
||||||
|
'会议': 'secondary'
|
||||||
|
}
|
||||||
|
return severityMap[tag] || 'secondary'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full bg-surface-50">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="px-6 pt-5 pb-3">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h1 class="text-xl font-bold text-surface-900">{{ title }}</h1>
|
||||||
|
<div class="flex items-center gap-1 bg-surface-100 rounded-lg p-0.5">
|
||||||
|
<Button text :severity="viewMode === 'card' ? 'primary' : 'secondary'" size="small" @click="viewMode = 'card'" v-tooltip.top="'卡片视图'" :class="['btn-icon w-8 h-8', viewMode === 'card' && 'btn-active']">
|
||||||
|
<template #icon>
|
||||||
|
<LayoutGrid :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button text :severity="viewMode === 'list' ? 'primary' : 'secondary'" size="small" @click="viewMode = 'list'" v-tooltip.top="'列表视图'" :class="['btn-icon w-8 h-8', viewMode === 'list' && 'btn-active']">
|
||||||
|
<template #icon>
|
||||||
|
<List :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<IconField class="flex-1">
|
||||||
|
<InputIcon>
|
||||||
|
<Search :size="16" />
|
||||||
|
</InputIcon>
|
||||||
|
<InputText
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索笔记..."
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
<span class="text-sm text-surface-400 shrink-0">{{ filteredNotes.length }} 篇笔记</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<ScrollPanel class="flex-1">
|
||||||
|
<div class="px-6 pb-6">
|
||||||
|
<!-- Card View -->
|
||||||
|
<div v-if="viewMode === 'card'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pt-1">
|
||||||
|
<div
|
||||||
|
v-for="note in filteredNotes"
|
||||||
|
:key="note.id"
|
||||||
|
class="group bg-surface-0 rounded-xl border border-surface-200 p-4 cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary-200 hover:-translate-y-0.5"
|
||||||
|
@click="selectNote(note)"
|
||||||
|
>
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="flex items-start gap-2 mb-3">
|
||||||
|
<FileText :size="18" class="text-primary-400 shrink-0 mt-0.5" />
|
||||||
|
<h3 class="text-sm font-semibold text-surface-900 line-clamp-2 group-hover:text-primary-600 transition-colors">
|
||||||
|
{{ note.title }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<p class="text-xs text-surface-500 line-clamp-3 mb-3 leading-relaxed">
|
||||||
|
{{ note.preview }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="flex flex-wrap gap-1 mb-3">
|
||||||
|
<Tag
|
||||||
|
v-for="tag in note.tags.slice(0, 3)"
|
||||||
|
:key="tag"
|
||||||
|
:value="tag"
|
||||||
|
:severity="getTagSeverity(tag)"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-between pt-2 border-t border-surface-100">
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-surface-400">
|
||||||
|
<Clock :size="12" />
|
||||||
|
<span>{{ formatDate(note.date) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-surface-400">
|
||||||
|
<Folder :size="12" />
|
||||||
|
<span>{{ note.notebook }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<div v-else class="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
v-for="note in filteredNotes"
|
||||||
|
:key="note.id"
|
||||||
|
class="group flex items-center gap-4 bg-surface-0 rounded-lg border border-surface-200 px-4 py-3 cursor-pointer transition-all duration-200 hover:shadow-sm hover:border-primary-200"
|
||||||
|
@click="selectNote(note)"
|
||||||
|
>
|
||||||
|
<FileText :size="18" class="text-primary-400 shrink-0" />
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-900 truncate group-hover:text-primary-600 transition-colors">
|
||||||
|
{{ note.title }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-surface-500 truncate mt-0.5">{{ note.preview }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<Tag
|
||||||
|
v-for="tag in note.tags.slice(0, 2)"
|
||||||
|
:key="tag"
|
||||||
|
:value="tag"
|
||||||
|
:severity="getTagSeverity(tag)"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 shrink-0 text-xs text-surface-400">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Folder :size="12" />
|
||||||
|
<span>{{ note.notebook }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Calendar :size="12" />
|
||||||
|
<span>{{ formatDate(note.date) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="filteredNotes.length === 0" class="flex flex-col items-center justify-center py-20 text-surface-400">
|
||||||
|
<FileText :size="48" class="mb-4 opacity-30" />
|
||||||
|
<p class="text-sm">没有找到笔记</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollPanel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
218
src/renderer/src/components/SideBar.vue
Normal file
218
src/renderer/src/components/SideBar.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import IconField from 'primevue/iconfield'
|
||||||
|
import InputIcon from 'primevue/inputicon'
|
||||||
|
import ScrollPanel from 'primevue/scrollpanel'
|
||||||
|
import {
|
||||||
|
Search, Plus, File, Star,
|
||||||
|
Trash2, Settings, Sun, Moon, Monitor
|
||||||
|
} from '@lucide/vue'
|
||||||
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
import ButtonIcon from "./ButtonIcon.vue";
|
||||||
|
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
const emit = defineEmits(['navigate', 'create-note', 'select-notebook', 'select-tag'])
|
||||||
|
|
||||||
|
// Collapse state
|
||||||
|
const collapsed = ref(false)
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation state
|
||||||
|
const activeNav = ref('all')
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Mock data - notes for search
|
||||||
|
const allNotes = ref([
|
||||||
|
{ id: 1, title: '项目需求文档', notebook: '工作' },
|
||||||
|
{ id: 2, title: '读书笔记 - 深入理解计算机系统', notebook: '学习' },
|
||||||
|
{ id: 3, title: '周报 - 第22周', notebook: '工作' },
|
||||||
|
{ id: 4, title: 'Vue 3 学习笔记', notebook: '学习' },
|
||||||
|
{ id: 5, title: '旅行计划 - 日本', notebook: '生活' },
|
||||||
|
{ id: 6, title: 'Markdown 语法速查表', notebook: '技术' },
|
||||||
|
{ id: 7, title: '会议纪要 - 产品评审', notebook: '工作' },
|
||||||
|
{ id: 8, title: '健身计划', notebook: '生活' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Filtered notes for search dropdown
|
||||||
|
const filteredNotes = computed(() => {
|
||||||
|
if (!searchQuery.value) return []
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return allNotes.value.filter(n => n.title.toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation items
|
||||||
|
const navSections = [
|
||||||
|
{
|
||||||
|
id: 'notes',
|
||||||
|
items: [
|
||||||
|
{ id: 'all', icon: File, label: '所有笔记' },
|
||||||
|
{ id: 'favorites', icon: Star, label: '收藏夹' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
function handleNav(id) {
|
||||||
|
activeNav.value = id
|
||||||
|
emit('navigate', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNote() {
|
||||||
|
emit('create-note')
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSearchNote(note) {
|
||||||
|
emit('select-note', note)
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar-container flex flex-col h-full bg-surface-50 border-r border-app-border transition-all duration-300"
|
||||||
|
:class="collapsed ? 'w-14' : 'w-64'">
|
||||||
|
|
||||||
|
<!-- Top Bar: Toggle + (Search & Add when expanded) -->
|
||||||
|
<div class="p-3 border-b border-app-border flex items-center gap-2">
|
||||||
|
<template v-if="!collapsed">
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<IconField>
|
||||||
|
<InputIcon>
|
||||||
|
<Search :size="16" />
|
||||||
|
</InputIcon>
|
||||||
|
<InputText
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索笔记..."
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
<!-- Search Results Dropdown -->
|
||||||
|
<div
|
||||||
|
v-if="filteredNotes.length > 0"
|
||||||
|
class="absolute top-full left-0 right-0 mt-1 bg-surface-0 border border-app-border rounded-lg shadow-lg z-50 max-h-48 overflow-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="note in filteredNotes"
|
||||||
|
:key="note.id"
|
||||||
|
class="px-3 py-2 hover:bg-surface-50 cursor-pointer flex items-center gap-2"
|
||||||
|
@click="selectSearchNote(note)"
|
||||||
|
>
|
||||||
|
<File :size="14" class="text-surface-400" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm text-surface-800 truncate">{{ note.title }}</div>
|
||||||
|
<div class="text-xs text-surface-400">{{ note.notebook }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button icon="pi pi-search" size="small" severity="success" aria-label="Search" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="w-full flex justify-center">
|
||||||
|
<Button severity="primary" size="small" @click="createNote" v-tooltip.right="'新建笔记'" class="btn-filled w-8 h-8">
|
||||||
|
<template #icon>
|
||||||
|
<Plus :size="20" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Content -->
|
||||||
|
<ScrollPanel class="flex-1">
|
||||||
|
<!-- Collapsed: Icon-only nav -->
|
||||||
|
<template v-if="collapsed">
|
||||||
|
<div class="py-3 flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
v-for="item in navSections[0].items"
|
||||||
|
:key="item.id"
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
|
||||||
|
:class="activeNav === item.id ? 'bg-primary-50 text-primary-600' : 'text-surface-500 hover:bg-surface-100'"
|
||||||
|
@click="handleNav(item.id)"
|
||||||
|
v-tooltip.right="item.label"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" :size="18" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Expanded: Full nav -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Notes Section -->
|
||||||
|
<div class="py-3">
|
||||||
|
<div class="px-4 py-1 text-xs font-semibold text-surface-400 uppercase tracking-wider">
|
||||||
|
笔记
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="item in navSections[0].items"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors"
|
||||||
|
:class="activeNav === item.id ? 'bg-primary-50 text-primary-600' : 'text-surface-700 hover:bg-surface-100'"
|
||||||
|
@click="handleNav(item.id)"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" :size="18" />
|
||||||
|
<span class="text-sm">{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</ScrollPanel>
|
||||||
|
|
||||||
|
<!-- Bottom Actions -->
|
||||||
|
<div class="p-3 border-t border-app-border">
|
||||||
|
<template v-if="collapsed">
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<Button text severity="secondary" size="small" @click="toggleTheme" v-tooltip.right="theme === 'light' ? '切换暗色模式' : theme === 'dark' ? '跟随系统' : '切换亮色模式'" class="btn-icon w-8 h-8">
|
||||||
|
<template #icon>
|
||||||
|
<Sun v-if="theme === 'light'" :size="16" />
|
||||||
|
<Moon v-else-if="theme === 'dark'" :size="16" />
|
||||||
|
<Monitor v-else :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button text severity="secondary" size="small" @click="handleNav('trash')" v-tooltip.right="'回收站'" :class="['btn-icon w-8 h-8', activeNav === 'trash' && 'btn-active']">
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button text severity="secondary" size="small" @click="handleNav('settings')" v-tooltip.right="'设置'" class="btn-icon w-8 h-8">
|
||||||
|
<template #icon>
|
||||||
|
<Settings :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button text severity="secondary" size="small" @click="handleNav('trash')" :class="['btn-icon flex-1', activeNav === 'trash' && 'btn-active']">
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 :size="16" class="mr-2" />
|
||||||
|
</template>
|
||||||
|
<span class="text-sm">回收站</span>
|
||||||
|
</Button>
|
||||||
|
<Button text severity="secondary" size="small" @click="toggleTheme" v-tooltip.top="theme === 'light' ? '切换暗色模式' : theme === 'dark' ? '跟随系统' : '切换亮色模式'" class="btn-icon w-8 h-8">
|
||||||
|
<template #icon>
|
||||||
|
<Sun v-if="theme === 'light'" :size="16" />
|
||||||
|
<Moon v-else-if="theme === 'dark'" :size="16" />
|
||||||
|
<Monitor v-else :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button text severity="secondary" size="small" @click="handleNav('settings')" class="btn-icon w-8 h-8">
|
||||||
|
<template #icon>
|
||||||
|
<Settings :size="16" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
112
src/renderer/src/composables/useTheme.js
Normal file
112
src/renderer/src/composables/useTheme.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题管理 composable
|
||||||
|
* 支持 'light' | 'dark' | 'system' 三种模式
|
||||||
|
* 持久化到 localStorage,system 模式跟随系统偏好
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'tunji-theme'
|
||||||
|
|
||||||
|
// 全局状态(所有组件共享)
|
||||||
|
const theme = ref('system')
|
||||||
|
|
||||||
|
// 媒体查询对象
|
||||||
|
let mediaQuery = null
|
||||||
|
let mediaHandler = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析后的实际主题(light 或 dark)
|
||||||
|
*/
|
||||||
|
const resolvedTheme = computed(() => {
|
||||||
|
if (theme.value === 'system') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
return theme.value
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为暗色模式
|
||||||
|
*/
|
||||||
|
const isDark = computed(() => resolvedTheme.value === 'dark')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将主题应用到 DOM
|
||||||
|
*/
|
||||||
|
function applyTheme(dark) {
|
||||||
|
const html = document.documentElement
|
||||||
|
if (dark) {
|
||||||
|
html.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
html.classList.remove('dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知 Electron 主进程更新标题栏颜色
|
||||||
|
if (window.electronAPI?.setTheme) {
|
||||||
|
window.electronAPI.setTheme(dark ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换主题:light -> dark -> system -> light
|
||||||
|
*/
|
||||||
|
function toggleTheme() {
|
||||||
|
const order = ['light', 'dark', 'system']
|
||||||
|
const idx = order.indexOf(theme.value)
|
||||||
|
theme.value = order[(idx + 1) % order.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置指定主题
|
||||||
|
* @param {'light' | 'dark' | 'system'} t
|
||||||
|
*/
|
||||||
|
function setTheme(t) {
|
||||||
|
theme.value = t
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 theme 变化并持久化 + 应用
|
||||||
|
watch(theme, (newTheme) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, newTheme)
|
||||||
|
applyTheme(isDark.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 resolvedTheme 变化(system 模式下系统偏好改变时触发)
|
||||||
|
watch(resolvedTheme, (dark) => {
|
||||||
|
applyTheme(dark === 'dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
onMounted(() => {
|
||||||
|
// 从 localStorage 恢复主题
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved && ['light', 'dark', 'system'].includes(saved)) {
|
||||||
|
theme.value = saved
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用当前主题
|
||||||
|
applyTheme(isDark.value)
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
mediaHandler = (e) => {
|
||||||
|
if (theme.value === 'system') {
|
||||||
|
applyTheme(e.matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaQuery.addEventListener('change', mediaHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (mediaQuery && mediaHandler) {
|
||||||
|
mediaQuery.removeEventListener('change', mediaHandler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
resolvedTheme,
|
||||||
|
isDark,
|
||||||
|
toggleTheme,
|
||||||
|
setTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/renderer/src/main.js
Normal file
21
src/renderer/src/main.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import Aura from '@primeuix/themes/aura'
|
||||||
|
import Tooltip from 'primevue/tooltip'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './assets/styles/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(PrimeVue, {
|
||||||
|
theme: {
|
||||||
|
preset: Aura,
|
||||||
|
options: {
|
||||||
|
darkModeSelector: '.dark'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.directive('tooltip', Tooltip)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
Reference in New Issue
Block a user