feat(ui): 添加侧边导航栏并重构顶部导航栏

- 新增 SideNav 组件实现侧边导航功能
- 将原顶部导航栏中的部分功能移至侧边导航栏
- 优化顶部导航栏布局和样式
- 调整底部工具栏的间距和样式
This commit is contained in:
liuziting
2026-01-22 08:43:38 +08:00
parent 0a3540445a
commit c2970c3348
4 changed files with 190 additions and 124 deletions

View File

@@ -28,6 +28,7 @@ import ResetConfirmModal from './components/ResetConfirmModal.vue'
import SettingsModal from './components/SettingsModal.vue' import SettingsModal from './components/SettingsModal.vue'
import SummaryModal from './components/SummaryModal.vue' import SummaryModal from './components/SummaryModal.vue'
import TopNav from './components/TopNav.vue' import TopNav from './components/TopNav.vue'
import SideNav from './components/SideNav.vue'
import WindowNode from './components/WindowNode.vue' import WindowNode from './components/WindowNode.vue'
// 业务层:统一的状态与动作入口 // 业务层:统一的状态与动作入口
@@ -43,7 +44,7 @@ const isFullscreen = ref(false)
const toggleFullscreen = () => { const toggleFullscreen = () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => { document.documentElement.requestFullscreen().catch(err => {
console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`) console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`)
}) })
} else { } else {
@@ -150,6 +151,8 @@ const fitToView = () => {
@toggle-locale="toggleLocale" @toggle-locale="toggleLocale"
/> />
<SideNav :t="t" :locale="locale" :config="config" />
<div class="flex-grow relative"> <div class="flex-grow relative">
<VueFlow <VueFlow
:default-edge-options="{ type: config.edgeType }" :default-edge-options="{ type: config.edgeType }"
@@ -168,7 +171,7 @@ const fitToView = () => {
:gap="24" :gap="24"
:size="config.backgroundVariant === BackgroundVariant.Dots ? 1 : 0.5" :size="config.backgroundVariant === BackgroundVariant.Dots ? 1 : 0.5"
/> />
<Controls v-if="config.showControls" :show-fullscreen="false" :show-fit-view="false"> <Controls v-if="false" :show-fullscreen="false" :show-fit-view="false">
<ControlButton @click="toggleFullscreen" :title="isFullscreen ? t('nav.exitFullscreen') : t('nav.fullscreen')"> <ControlButton @click="toggleFullscreen" :title="isFullscreen ? t('nav.exitFullscreen') : t('nav.fullscreen')">
<component :is="isFullscreen ? Minimize : Maximize" class="w-4 h-4 text-slate-500" /> <component :is="isFullscreen ? Minimize : Maximize" class="w-4 h-4 text-slate-500" />
</ControlButton> </ControlButton>

View File

@@ -32,9 +32,11 @@ const emit = defineEmits<{
</script> </script>
<template> <template>
<div class="fixed bottom-6 md:bottom-12 left-1/2 -translate-x-1/2 z-50 flex flex-col items-center gap-4 w-full max-w-2xl px-4 md:px-6"> <div class="fixed bottom-6 md:bottom-8 left-1/2 -translate-x-1/2 z-50 flex flex-col items-center gap-4 w-full max-w-2xl px-4 md:px-6">
<div class="flex items-center gap-2 md:gap-3 w-full"> <div class="flex items-center gap-2 md:gap-3 w-full">
<div class="flex-grow flex items-center gap-2 md:gap-4 bg-slate-50 border border-slate-200 rounded-xl md:rounded-2xl px-3 md:px-5 py-2 md:py-3 focus-within:bg-white focus-within:shadow-xl focus-within:shadow-slate-100 transition-all"> <div
class="flex-grow flex items-center gap-2 md:gap-4 bg-slate-50 border border-slate-200 rounded-xl md:rounded-2xl px-3 md:px-5 py-2 md:py-3 focus-within:bg-white focus-within:shadow-xl focus-within:shadow-slate-100 transition-all"
>
<Terminal class="w-4 h-4 md:w-5 h-5 text-slate-400 flex-shrink-0" /> <Terminal class="w-4 h-4 md:w-5 h-5 text-slate-400 flex-shrink-0" />
<input <input
:value="props.modelValue" :value="props.modelValue"
@@ -55,7 +57,9 @@ const emit = defineEmits<{
</div> </div>
</div> </div>
<div class="flex items-center gap-2 px-3 py-1 bg-white/60 backdrop-blur-sm border border-slate-200/50 rounded-full text-[10px] font-black tracking-widest uppercase select-none shadow-sm"> <div
class="flex items-center gap-2 px-3 py-1 bg-white/60 backdrop-blur-sm border border-slate-200/50 rounded-full text-[10px] font-black tracking-widest uppercase select-none shadow-sm"
>
<a <a
href="https://github.com/liu-ziting/ThinkFlowAI" href="https://github.com/liu-ziting/ThinkFlowAI"
target="_blank" target="_blank"

155
src/components/SideNav.vue Normal file
View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { BackgroundVariant } from '@vue-flow/background'
import { ArrowLeftRight, Map, Palette, Layers, Grid } from 'lucide-vue-next'
const props = defineProps<{
t: any
locale: string
config: any
}>()
const isEdgeTypeMenuOpen = ref(false)
const isBackgroundMenuOpen = ref(false)
const edgeTypeOptions = [
{ value: 'default', labelKey: 'nav.edgeTypes.default' },
{ value: 'straight', labelKey: 'nav.edgeTypes.straight' },
{ value: 'step', labelKey: 'nav.edgeTypes.step' },
{ value: 'smoothstep', labelKey: 'nav.edgeTypes.smoothstep' },
{ value: 'simplebezier', labelKey: 'nav.edgeTypes.simplebezier' }
]
const backgroundOptions = [
{ value: BackgroundVariant.Lines, labelKey: 'nav.lines' },
{ value: BackgroundVariant.Dots, labelKey: 'nav.dots' }
]
const closeMenus = () => {
isEdgeTypeMenuOpen.value = false
isBackgroundMenuOpen.value = false
}
const toggleEdgeTypeMenu = () => {
isEdgeTypeMenuOpen.value = !isEdgeTypeMenuOpen.value
if (isEdgeTypeMenuOpen.value) isBackgroundMenuOpen.value = false
}
const toggleBackgroundMenu = () => {
isBackgroundMenuOpen.value = !isBackgroundMenuOpen.value
if (isBackgroundMenuOpen.value) isEdgeTypeMenuOpen.value = false
}
const setEdgeType = (value: string) => {
props.config.edgeType = value
closeMenus()
}
const setBackgroundVariant = (value: any) => {
props.config.backgroundVariant = value
closeMenus()
}
const handleDocumentPointerDown = (e: Event) => {
const target = e.target as HTMLElement | null
if (!target) return
if (target.closest('[data-side-menu="true"]')) return
closeMenus()
}
onMounted(() => {
document.addEventListener('pointerdown', handleDocumentPointerDown)
})
onUnmounted(() => {
document.removeEventListener('pointerdown', handleDocumentPointerDown)
})
</script>
<template>
<div class="fixed left-4 top-1/2 -translate-y-1/2 z-40 hidden md:flex flex-col gap-3">
<div class="bg-white/80 backdrop-blur-md border border-slate-200 rounded-2xl shadow-xl p-2 flex flex-col gap-2">
<!-- 联动拖拽 -->
<button
@click="props.config.hierarchicalDragging = !props.config.hierarchicalDragging"
class="side-btn"
:class="props.config.hierarchicalDragging ? 'text-orange-500 bg-orange-50 border-orange-100' : 'text-slate-400 hover:text-slate-600'"
:title="props.t('nav.hierarchicalDragging')"
>
<ArrowLeftRight class="w-5 h-5" />
</button>
<!-- 小地图 -->
<button
@click="props.config.showMiniMap = !props.config.showMiniMap"
class="side-btn"
:class="props.config.showMiniMap ? 'text-blue-500 bg-blue-50 border-blue-100' : 'text-slate-400 hover:text-slate-600'"
:title="props.t('nav.map')"
>
<Map class="w-5 h-5" />
</button>
<div class="h-px bg-slate-100 mx-2 my-1"></div>
<!-- 连线颜色 -->
<div class="relative group p-2 flex flex-col items-center gap-1 bg-slate-50 rounded-xl border border-slate-100">
<Palette class="w-4 h-4 text-slate-400" />
<input type="color" v-model="props.config.edgeColor" class="w-5 h-5 rounded-md cursor-pointer bg-transparent border-none" />
<span class="text-[8px] font-black text-slate-400 uppercase">{{ props.t('nav.edge') }}</span>
</div>
<!-- 连线类型 -->
<div data-side-menu="true" class="relative">
<button @click="toggleEdgeTypeMenu" class="side-btn" :class="isEdgeTypeMenuOpen ? 'bg-slate-100 text-slate-900' : 'text-slate-400'" :title="props.t('nav.edge')">
<Layers class="w-5 h-5" />
</button>
<div v-if="isEdgeTypeMenuOpen" class="absolute left-full ml-3 top-0 bg-white border border-slate-200 rounded-xl shadow-2xl p-1.5 min-w-[120px] z-50 transition-all">
<button
v-for="opt in edgeTypeOptions"
:key="opt.value"
class="w-full text-left px-3 py-2 rounded-lg text-[10px] font-black tracking-widest uppercase transition-colors"
:class="opt.value === props.config.edgeType ? 'bg-orange-50 text-orange-600' : 'text-slate-600 hover:bg-slate-50'"
@click="setEdgeType(opt.value)"
>
{{ props.t(opt.labelKey) }}
</button>
</div>
</div>
<!-- 背景类型 -->
<div data-side-menu="true" class="relative">
<button
@click="toggleBackgroundMenu"
class="side-btn"
:class="isBackgroundMenuOpen ? 'bg-slate-100 text-slate-900' : 'text-slate-400'"
:title="props.t('nav.lines')"
>
<Grid class="w-5 h-5" />
</button>
<div
v-if="isBackgroundMenuOpen"
class="absolute left-full ml-3 top-0 bg-white border border-slate-200 rounded-xl shadow-2xl p-1.5 min-w-[100px] z-50 transition-all"
>
<button
v-for="opt in backgroundOptions"
:key="String(opt.value)"
class="w-full text-left px-3 py-2 rounded-lg text-[10px] font-black tracking-widest uppercase transition-colors"
:class="opt.value === props.config.backgroundVariant ? 'bg-orange-50 text-orange-600' : 'text-slate-600 hover:bg-slate-50'"
@click="setBackgroundVariant(opt.value)"
>
{{ props.t(opt.labelKey) }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.side-btn {
@apply w-10 h-10 flex items-center justify-center rounded-xl border border-transparent transition-all active:scale-90 relative;
}
.side-btn:hover {
@apply border-slate-200 shadow-sm;
}
</style>

View File

@@ -12,23 +12,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { BackgroundVariant } from '@vue-flow/background' import { BackgroundVariant } from '@vue-flow/background'
// 图标:所有按钮与状态展示 // 图标:所有按钮与状态展示
import { import { ChevronDown, ChevronUp, Download, Focus, LayoutDashboard, Menu, Sparkles, Target, X, Trash2, Globe, Settings } from 'lucide-vue-next'
ArrowLeftRight,
ChevronDown,
ChevronUp,
Download,
Focus,
Globe,
LayoutDashboard,
Map,
Menu,
Palette,
Settings,
Sparkles,
Target,
Trash2,
X
} from 'lucide-vue-next'
/** /**
* props * props
@@ -164,89 +148,6 @@ const callAndClose = (fn: () => void) => {
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div> <div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button
@click="props.config.hierarchicalDragging = !props.config.hierarchicalDragging"
class="toolbar-btn border-slate-100 flex-shrink-0"
:class="props.config.hierarchicalDragging ? 'text-orange-500 bg-orange-50 border-orange-100' : 'text-slate-400 hover:text-slate-600'"
:title="props.t('nav.hierarchicalDragging')"
>
<ArrowLeftRight class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.hierarchicalDragging') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button @click="props.onStartNewSession" class="toolbar-btn text-red-500 hover:bg-red-50 border-red-100 flex-shrink-0" :title="props.t('nav.reset')">
<Trash2 class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.reset') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<div class="flex items-center gap-2 px-2 md:px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-100 flex-shrink-0">
<Palette class="w-3 h-3 md:w-3.5 h-3.5 text-slate-400" />
<input type="color" v-model="props.config.edgeColor" class="w-3.5 h-3.5 md:w-4 h-4 rounded cursor-pointer bg-transparent border-none" />
<span class="text-[9px] md:text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
</div>
<div data-edge-type-menu="true" class="relative flex-shrink-0">
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleEdgeTypeMenu">
<span>{{ currentEdgeTypeLabel }}</span>
<ChevronDown class="w-3 h-3 opacity-60" />
</button>
<div
v-if="isEdgeTypeMenuOpen"
class="absolute top-full left-0 mt-2 min-w-[180px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50"
>
<button
v-for="opt in edgeTypeOptions"
:key="opt.value"
type="button"
class="w-full text-left px-3 py-2 rounded-md text-[10px] font-black tracking-widest uppercase transition-colors"
:class="opt.value === props.config.edgeType ? 'bg-orange-50 text-orange-600' : 'text-slate-600 hover:bg-slate-50'"
@click="setEdgeType(opt.value)"
>
{{ props.t(opt.labelKey) }}
</button>
</div>
</div>
<div data-background-menu="true" class="relative flex-shrink-0">
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleBackgroundMenu">
<span>{{ currentBackgroundLabel }}</span>
<ChevronDown class="w-3 h-3 opacity-60" />
</button>
<div
v-if="isBackgroundMenuOpen"
class="absolute top-full left-0 mt-2 min-w-[140px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50"
>
<button
v-for="opt in backgroundOptions"
:key="String(opt.value)"
type="button"
class="w-full text-left px-3 py-2 rounded-md text-[10px] font-black tracking-widest uppercase transition-colors"
:class="opt.value === props.config.backgroundVariant ? 'bg-orange-50 text-orange-600' : 'text-slate-600 hover:bg-slate-50'"
@click="setBackgroundVariant(opt.value)"
>
{{ props.t(opt.labelKey) }}
</button>
</div>
</div>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button
@click="props.config.showMiniMap = !props.config.showMiniMap"
class="toolbar-btn border-slate-100 flex-shrink-0"
:class="props.config.showMiniMap ? 'text-blue-500 bg-blue-50 border-blue-100' : 'text-slate-400 hover:text-slate-600'"
:title="props.t('nav.map')"
>
<Map class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.map') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button @click="props.onGenerateSummary" class="toolbar-btn text-orange-600 hover:bg-orange-50 border-orange-100 flex-shrink-0" :title="props.t('nav.summary')"> <button @click="props.onGenerateSummary" class="toolbar-btn text-orange-600 hover:bg-orange-50 border-orange-100 flex-shrink-0" :title="props.t('nav.summary')">
<Sparkles class="w-3.5 h-3.5 md:w-4 h-4" /> <Sparkles class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.summary') }}</span> <span>{{ props.t('nav.summary') }}</span>
@@ -259,9 +160,9 @@ const callAndClose = (fn: () => void) => {
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div> <div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button @click="props.onOpenSettings" class="toolbar-btn text-slate-600 hover:bg-slate-50 border-slate-100 flex-shrink-0"> <button @click="props.onStartNewSession" class="toolbar-btn text-red-500 hover:bg-red-50 border-red-100 flex-shrink-0" :title="props.t('nav.reset')">
<Settings class="w-3.5 h-3.5 md:w-4 h-4" /> <Trash2 class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('common.settings') }}</span> <span>{{ props.t('nav.reset') }}</span>
</button> </button>
</div> </div>
@@ -279,12 +180,23 @@ const callAndClose = (fn: () => void) => {
</div> </div>
</div> </div>
<div class="flex items-center gap-2 md:gap-3 flex-shrink-0"> <div class="flex items-center gap-1 md:gap-2 flex-shrink-0">
<button
@click="props.onOpenSettings"
class="p-1.5 md:p-2 hover:bg-slate-100 rounded-md transition-colors text-slate-400 flex items-center gap-1"
:title="props.t('common.settings')"
>
<Settings class="w-3.5 h-3.5 md:w-4 h-4" />
<span class="hidden md:inline text-[10px] md:text-xs font-bold">{{ props.t('common.settings') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-200 mx-1"></div>
<button <button
@click="emit('toggle-locale')" @click="emit('toggle-locale')"
class="p-1.5 md:p-2 hover:bg-slate-100 rounded-md transition-colors text-slate-400 font-bold text-[10px] md:text-xs flex items-center gap-1" class="p-1.5 md:p-2 hover:bg-slate-100 rounded-md transition-colors text-slate-400 font-bold text-[10px] md:text-xs flex items-center gap-1"
> >
<Globe class="w-3 h-3 md:w-3.5 h-3.5" /> {{ props.locale === 'zh' ? 'EN' : 'ZH' }} <Globe class="w-3.5 h-3.5 md:w-4 h-4" /> {{ props.locale === 'zh' ? 'EN' : 'ZH' }}
</button> </button>
</div> </div>
</nav> </nav>
@@ -297,7 +209,10 @@ const callAndClose = (fn: () => void) => {
leave-from-class="transform translate-y-0 opacity-100" leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform -translate-y-4 opacity-0" leave-to-class="transform -translate-y-4 opacity-0"
> >
<div v-if="isToolsExpanded" class="md:hidden absolute top-[57px] left-0 right-0 bg-white/95 backdrop-blur-md border-b border-slate-200 shadow-xl z-40 py-4 px-4 flex flex-wrap gap-3 justify-center"> <div
v-if="isToolsExpanded"
class="md:hidden absolute top-[57px] left-0 right-0 bg-white/95 backdrop-blur-md border-b border-slate-200 shadow-xl z-40 py-4 px-4 flex flex-wrap gap-3 justify-center"
>
<button @click="callAndClose(props.onFit)" class="toolbar-btn text-blue-500 hover:bg-blue-50 border-blue-100" :title="props.t('nav.fit')"> <button @click="callAndClose(props.onFit)" class="toolbar-btn text-blue-500 hover:bg-blue-50 border-blue-100" :title="props.t('nav.fit')">
<Focus class="w-4 h-4" /> <Focus class="w-4 h-4" />
<span>{{ props.t('nav.fit') }}</span> <span>{{ props.t('nav.fit') }}</span>
@@ -329,10 +244,7 @@ const callAndClose = (fn: () => void) => {
<span>{{ currentEdgeTypeLabel }}</span> <span>{{ currentEdgeTypeLabel }}</span>
<ChevronDown class="w-3 h-3 opacity-60" /> <ChevronDown class="w-3 h-3 opacity-60" />
</button> </button>
<div <div v-if="isEdgeTypeMenuOpen" class="absolute top-full left-0 mt-2 min-w-[180px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50">
v-if="isEdgeTypeMenuOpen"
class="absolute top-full left-0 mt-2 min-w-[180px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50"
>
<button <button
v-for="opt in edgeTypeOptions" v-for="opt in edgeTypeOptions"
:key="opt.value" :key="opt.value"
@@ -351,10 +263,7 @@ const callAndClose = (fn: () => void) => {
<span>{{ currentBackgroundLabel }}</span> <span>{{ currentBackgroundLabel }}</span>
<ChevronDown class="w-3 h-3 opacity-60" /> <ChevronDown class="w-3 h-3 opacity-60" />
</button> </button>
<div <div v-if="isBackgroundMenuOpen" class="absolute top-full left-0 mt-2 min-w-[140px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50">
v-if="isBackgroundMenuOpen"
class="absolute top-full left-0 mt-2 min-w-[140px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50"
>
<button <button
v-for="opt in backgroundOptions" v-for="opt in backgroundOptions"
:key="String(opt.value)" :key="String(opt.value)"
@@ -387,11 +296,6 @@ const callAndClose = (fn: () => void) => {
<Download class="w-4 h-4" /> <Download class="w-4 h-4" />
<span>{{ props.t('nav.export') }}</span> <span>{{ props.t('nav.export') }}</span>
</button> </button>
<button @click="callAndClose(props.onOpenSettings)" class="toolbar-btn text-slate-600 hover:bg-slate-50 border-slate-100">
<Settings class="w-4 h-4" />
<span>{{ props.t('common.settings') }}</span>
</button>
</div> </div>
</Transition> </Transition>
</template> </template>