first commit

This commit is contained in:
2026-05-31 11:12:06 +08:00
commit dedd837b18
22 changed files with 9561 additions and 0 deletions

View 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
View File

@@ -0,0 +1,6 @@
node_modules/
out/
dist/
.DS_Store
*.log
.idea/

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
out/
dist/
.DS_Store
*.log

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/

8
.idea/modules.xml generated Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
electron_mirror=https://npmmirror.com/mirrors/electron/

46
electron.vite.config.mjs Normal file
View 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

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View 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
View 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
View 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
View 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
View 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>

View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,112 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
/**
* 主题管理 composable
* 支持 'light' | 'dark' | 'system' 三种模式
* 持久化到 localStoragesystem 模式跟随系统偏好
*/
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
View 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')