This commit is contained in:
2026-07-03 00:56:13 +08:00
parent 82a64787c3
commit bc96296670
13 changed files with 611 additions and 378 deletions

View File

@@ -1,292 +1,283 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, computed } from "vue";
import TreeEntry from "./TreeEntry.vue";
interface DirEntry {
name: string;
path: string;
is_dir: boolean;
children?: DirEntry[];
}
const props = defineProps<{
folderPath: string;
entries: DirEntry[];
const props = withDefaults(
defineProps<{
folderPath: string;
entries: DirEntry[];
content?: string;
currentFilePath?: string | null;
}>(),
{
content: "",
currentFilePath: null,
},
);
const emit = defineEmits<{
headingClick: [heading: { text: string; level: number }];
openFolder: [];
toggleFolder: [path: string];
openFile: [path: string];
}>();
const collapsed = ref(false);
// ---- sidebar view mode ----
type ViewMode = "folder" | "search" | "outline";
const activeView = ref<ViewMode>("folder");
const collapsed = ref(true);
const toggleCollapsed = () => {
collapsed.value = !collapsed.value;
const viewLabels: Record<ViewMode, string> = {
folder: "文件",
search: "搜索",
outline: "大纲",
};
// ---- file tree state (for opened folder) ----
interface FileItem {
id: string;
name: string;
type: "file" | "folder";
path: string;
children?: FileItem[];
expanded?: boolean;
function switchView(view: ViewMode) {
if (collapsed.value) {
collapsed.value = false;
activeView.value = view;
} else if (activeView.value === view) {
collapsed.value = true;
} else {
activeView.value = view;
}
}
const expandedFolders = ref<Set<string>>(new Set());
function collapseSidebar() {
collapsed.value = true;
}
const toggleFolder = (item: FileItem) => {
if (item.type === "folder") {
if (expandedFolders.value.has(item.path)) {
expandedFolders.value.delete(item.path);
} else {
expandedFolders.value.add(item.path);
// ---- outline state ----
interface OutlineItem {
text: string;
level: number;
id: string;
}
const headings = computed<OutlineItem[]>(() => {
if (!props.content) return [];
const result: OutlineItem[] = [];
const regex = /^(#{1,3})\s+(.+)$/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(props.content)) !== null) {
const level = match[1].length;
// Strip any remaining markdown formatting from the heading text
const text = match[2].replace(/\*{1,3}|_{1,3}|`+|~~|\[([^\]]*)\]\([^)]*\)/g, (_, linkText) => linkText || "").trim();
if (text) {
result.push({ text, level, id: `heading-${result.length}` });
}
// Trigger reactivity
expandedFolders.value = new Set(expandedFolders.value);
}
};
return result;
});
const newFile = () => {
const name = prompt("文件名称(含扩展名):");
if (name) {
// files are managed externally
}
};
function onHeadingClick(item: OutlineItem) {
emit("headingClick", { text: item.text, level: item.level });
}
const newFolder = () => {
const name = prompt("文件夹名称:");
if (name) {
// folders are managed externally
}
};
// ---- tag state ----
const tags = ref(["技术", "随笔", "待办", "灵感"]);
const newTagInput = ref("");
const addingTag = ref(false);
const addTag = () => {
const t = newTagInput.value.trim();
if (t && !tags.value.includes(t)) {
tags.value.push(t);
}
newTagInput.value = "";
addingTag.value = false;
};
const removeTag = (tag: string) => {
tags.value = tags.value.filter((t) => t !== tag);
};
// ---- settings modal ----
const showSettings = ref(false);
</script>
<template>
<aside
:class="[
'flex flex-col border-r border-gray-800 bg-gray-900/60 transition-all duration-300 ease-in-out overflow-hidden shrink-0',
'flex flex-col border-r border-[#e8e4da] bg-[#f4f1eb]/80 transition-all duration-300 ease-in-out overflow-hidden shrink-0',
collapsed ? 'w-12' : 'w-56',
]"
>
<!-- Header / toggle -->
<div
class="flex items-center justify-between px-3 py-3 border-b border-gray-800"
:class="collapsed ? 'justify-center' : ''"
>
<span v-if="!collapsed" class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
语柔
</span>
<button
class="p-1 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
@click="toggleCollapsed"
>
<!-- hamburger / panel-close icon -->
<svg
v-if="!collapsed"
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
<!-- ============ COLLAPSED: icon bar ============ -->
<template v-if="collapsed">
<div class="flex flex-col items-center gap-1 py-3 flex-1">
<!-- Folder -->
<button
class="p-2 rounded-md transition-colors"
:class="activeView === 'folder' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
title="文件"
@click="switchView('folder')"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
<i class="ri-folder-line text-base"></i>
</button>
<!-- Search -->
<button
class="p-2 rounded-md transition-colors"
:class="activeView === 'search' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
title="搜索"
@click="switchView('search')"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Collapsed icon-only view -->
<div v-if="collapsed" class="flex flex-col items-center gap-1 py-3">
<!-- new file -->
<button
class="p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
title="新建文件"
@click="newFile"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
<!-- new folder -->
<button
class="p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
title="新建文件夹"
@click="newFolder"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</button>
</div>
<!-- Expanded content -->
<div v-if="!collapsed" class="flex flex-col flex-1 overflow-hidden">
<!-- File tree -->
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
<!-- Folder path indicator -->
<div
v-if="folderPath"
class="px-2 py-1 text-xs text-gray-500 truncate mb-2 border-b border-gray-800/50 pb-2"
:title="folderPath"
<i class="ri-search-line text-base"></i>
</button>
<!-- Outline -->
<button
class="p-2 rounded-md transition-colors"
:class="activeView === 'outline' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
title="大纲"
@click="switchView('outline')"
>
📁 {{ folderPath.split(/[/\\]/).pop() || folderPath }}
</div>
<i class="ri-menu-line text-base"></i>
</button>
</div>
</template>
<!-- Empty state -->
<div
v-if="!folderPath"
class="px-2 py-4 text-xs text-gray-500 text-center"
<!-- ============ EXPANDED: full sidebar ============ -->
<template v-else>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-3 border-b border-[#e8e4da]">
<span class="text-xs font-semibold text-[#8c877d] uppercase tracking-wider">
{{ viewLabels[activeView] }}
</span>
<button
class="p-1 rounded-md text-[#8c877d] hover:text-[#38342e] hover:bg-[#e0dbcf] transition-colors"
title="折叠侧边栏"
@click="collapseSidebar"
>
点击 文件 打开文件夹 或按 Ctrl+O
</div>
<i class="ri-arrow-left-double-line text-base"></i>
</button>
</div>
<template v-for="entry in entries" :key="entry.path">
<!-- Folder -->
<!-- ===== FOLDER VIEW ===== -->
<div v-if="activeView === 'folder'" class="flex flex-col flex-1 overflow-hidden">
<!-- File tree -->
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
<!-- Folder path indicator -->
<div
v-if="entry.is_dir"
class="flex items-center gap-1 px-2 py-1 rounded-md text-sm text-gray-300 hover:bg-gray-800 cursor-pointer select-none"
@click="toggleFolder({ id: entry.path, name: entry.name, type: 'folder', path: entry.path })"
v-if="folderPath"
class="px-2 py-1 text-xs text-[#b8b3a8] truncate mb-2 border-b border-[#e8e4da] pb-2"
:title="folderPath"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5 text-gray-500 transition-transform"
:class="expandedFolders.has(entry.path) ? 'rotate-90' : ''"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-yellow-500/70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span class="truncate">{{ entry.name }}</span>
📁 {{ folderPath.split(/[/\\]/).pop() || folderPath }}
</div>
<!-- File -->
<!-- Empty state -->
<div
v-else
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-200 cursor-pointer"
v-if="!folderPath"
class="px-2 py-4 text-xs text-[#b8b3a8] text-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5 text-indigo-400/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
<p class="mb-3">暂无打开的文件夹</p>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-[#fdf0e5] text-[#bf6a3b] border border-[#d4a574]/40 hover:bg-[#fdf0e5]/80 hover:text-[#bf6a3b] transition-colors"
@click="$emit('openFolder')"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate">{{ entry.name }}</span>
<i class="ri-folder-open-line"></i>
打开文件夹
</button>
</div>
<TreeEntry
v-for="entry in entries"
:key="entry.path"
:entry="entry"
:depth="0"
@toggle-folder="(path: string) => emit('toggleFolder', path)"
@open-file="(path: string) => emit('openFile', path)"
/>
</div>
</div>
<!-- ===== SEARCH VIEW ===== -->
<div v-if="activeView === 'search'" class="flex flex-col flex-1 overflow-hidden">
<div class="px-3 py-3">
<div class="relative">
<i class="ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-[#b8b3a8]"></i>
<input
class="w-full pl-8 pr-3 py-1.5 text-xs bg-[#ede8de] border border-[#e0dbcf] rounded-md text-[#38342e] outline-none focus:border-[#bf6a3b] placeholder-[#b8b3a8]"
placeholder="搜索文件内容..."
disabled
/>
</div>
</div>
<div class="flex-1 flex items-center justify-center px-3 pb-8">
<p class="text-xs text-[#b8b3a8] text-center leading-relaxed">
搜索功能即将上线
</p>
</div>
</div>
<!-- ===== OUTLINE VIEW ===== -->
<div v-if="activeView === 'outline'" class="flex-1 overflow-y-auto px-2 py-2">
<template v-if="!currentFilePath">
<p class="text-xs text-[#b8b3a8] px-2 py-4 text-center leading-relaxed">
暂无打开的文件
</p>
</template>
<template v-else-if="headings.length === 0">
<p class="text-xs text-[#b8b3a8] px-2 py-4 text-center leading-relaxed">
暂无标题<br />使用 H1-H3 标题<br />自动生成大纲
</p>
</template>
<template v-else>
<div
v-for="item in headings"
:key="item.id"
:class="[
'px-2 py-1 rounded-md text-sm cursor-pointer hover:bg-[#ede8de] transition-colors truncate',
item.level === 1
? 'text-[#38342e] font-medium ml-0'
: item.level === 2
? 'text-[#8c877d] ml-3'
: 'text-[#b8b3a8] ml-6 text-xs',
]"
:title="item.text"
@click="onHeadingClick(item)"
>
<span
:class="[
'inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle',
item.level === 1
? 'bg-[#bf6a3b]'
: item.level === 2
? 'bg-[#7ba587]/60'
: 'bg-gray-500',
]"
/>
{{ item.text }}
</div>
</template>
</div>
<!-- Action buttons -->
<div class="flex gap-1 px-3 py-2 border-t border-gray-800/60">
<!-- ===== BOTTOM: Settings ===== -->
<div class="border-t border-[#e8e4da] px-3 py-2">
<button
class="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs text-gray-400 hover:text-white hover:bg-gray-800 rounded-md transition-colors"
@click="newFile"
class="w-full flex items-center gap-2 px-2 py-1.5 text-xs text-[#8c877d] hover:text-[#38342e] hover:bg-[#ede8de] rounded-md transition-colors"
@click="showSettings = true"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
新建文件
</button>
<button
class="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs text-gray-400 hover:text-white hover:bg-gray-800 rounded-md transition-colors"
@click="newFolder"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
新建文件夹
<i class="ri-settings-line text-sm"></i>
设置
</button>
</div>
</template>
</aside>
<!-- Tags section -->
<div class="border-t border-gray-800/60 px-3 py-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">标签</span>
<!-- ===== SETTINGS MODAL ===== -->
<Teleport to="body">
<div
v-if="showSettings"
class="fixed inset-0 z-50 flex items-center justify-center bg-[#38342e]/25 backdrop-blur-sm"
@click.self="showSettings = false"
>
<div class="bg-white border border-[#e0dbcf] rounded-xl shadow-2xl w-80 max-h-[70vh] overflow-y-auto">
<div class="flex items-center justify-between px-4 py-3 border-b border-[#e8e4da]">
<span class="text-sm font-semibold text-[#38342e]">设置</span>
<button
class="text-gray-500 hover:text-gray-300 transition-colors"
@click="addingTag = !addingTag"
class="p-1 rounded-md text-[#8c877d] hover:text-[#38342e] hover:bg-[#e0dbcf] transition-colors"
@click="showSettings = false"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
<i class="ri-close-line text-base"></i>
</button>
</div>
<!-- Add tag input -->
<div v-if="addingTag" class="flex gap-1 mb-2">
<input
v-model="newTagInput"
class="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded-md text-gray-200 outline-none focus:border-indigo-500"
placeholder="标签名称"
@keyup.enter="addTag"
/>
<button
class="px-2 py-1 text-xs bg-indigo-600 text-white rounded-md hover:bg-indigo-500 transition-colors"
@click="addTag"
>
确定
</button>
</div>
<!-- Tag list -->
<div class="flex flex-wrap gap-1.5">
<span
v-for="tag in tags"
:key="tag"
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-gray-800 text-gray-300 border border-gray-700/50 group"
>
{{ tag }}
<button
class="text-gray-600 hover:text-red-400 transition-colors"
@click="removeTag(tag)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
<div class="px-4 py-6">
<p class="text-xs text-[#b8b3a8] text-center leading-relaxed">
设置功能即将上线
</p>
</div>
</div>
</div>
</aside>
</Teleport>
</template>