396 lines
12 KiB
Vue
396 lines
12 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 应用入口组件
|
||
* - 负责组合:顶部导航、画布(VueFlow)、节点渲染、底部输入条、各类弹窗
|
||
* - 业务状态与动作全部由 useThinkFlow 提供,App.vue 仅做组装与事件转发
|
||
*/
|
||
|
||
// i18n:提供 t/locale,并把 locale 传入业务层做持久化
|
||
import { useI18n } from 'vue-i18n'
|
||
|
||
// 画布:VueFlow 与可选插件
|
||
import { VueFlow } from '@vue-flow/core'
|
||
import { Background, BackgroundVariant } from '@vue-flow/background'
|
||
import { Controls, ControlButton } from '@vue-flow/controls'
|
||
import { MiniMap } from '@vue-flow/minimap'
|
||
import { Maximize, Minimize } from 'lucide-vue-next'
|
||
|
||
// VueFlow 内置样式(必须引入,否则组件样式缺失)
|
||
import '@vue-flow/core/dist/style.css'
|
||
import '@vue-flow/core/dist/theme-default.css'
|
||
import '@vue-flow/minimap/dist/style.css'
|
||
import '@vue-flow/controls/dist/style.css'
|
||
|
||
// 页面 UI 子组件
|
||
import BottomBar from './components/BottomBar.vue'
|
||
import ImagePreviewModal from './components/ImagePreviewModal.vue'
|
||
import ResetConfirmModal from './components/ResetConfirmModal.vue'
|
||
import SettingsModal from './components/SettingsModal.vue'
|
||
import SummaryModal from './components/SummaryModal.vue'
|
||
import TopNav from './components/TopNav.vue'
|
||
import SideNav from './components/SideNav.vue'
|
||
import WindowNode from './components/WindowNode.vue'
|
||
|
||
// 业务层:统一的状态与动作入口
|
||
import { useThinkFlow } from './composables/useThinkFlow'
|
||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||
|
||
const { t, locale } = useI18n()
|
||
|
||
/**
|
||
* 全屏控制逻辑
|
||
*/
|
||
const isFullscreen = ref(false)
|
||
|
||
const toggleFullscreen = () => {
|
||
if (!document.fullscreenElement) {
|
||
document.documentElement.requestFullscreen().catch(err => {
|
||
console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`)
|
||
})
|
||
} else {
|
||
if (document.exitFullscreen) {
|
||
document.exitFullscreen()
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleFullscreenChange = () => {
|
||
isFullscreen.value = !!document.fullscreenElement
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||
})
|
||
|
||
/**
|
||
* 从业务层拿到全局状态与动作。
|
||
* 说明:
|
||
* - 展开/深挖/图片/总结等网络请求与数据写回都在 useThinkFlow 内完成
|
||
* - App.vue 只负责把这些能力传给对应 UI 组件
|
||
*/
|
||
const {
|
||
apiConfig,
|
||
showSettings,
|
||
ideaInput,
|
||
isLoading,
|
||
previewImageUrl,
|
||
showResetConfirm,
|
||
showSummaryModal,
|
||
isSummarizing,
|
||
summaryContent,
|
||
panOnDrag,
|
||
isSpacePressed,
|
||
config,
|
||
flowNodes,
|
||
activeNodeId,
|
||
activePath,
|
||
updateNode,
|
||
fitView,
|
||
resetLayout,
|
||
centerRoot,
|
||
handleNodeDrag,
|
||
alignmentGuides,
|
||
viewport,
|
||
toggleSubtreeCollapse,
|
||
isSubtreeCollapsed,
|
||
startNewSession,
|
||
executeReset,
|
||
generateSummary,
|
||
exportMarkdown,
|
||
generateNodeImage,
|
||
deepDive,
|
||
expandIdea
|
||
} = useThinkFlow({ t, locale })
|
||
|
||
const verticalGuideStyle = computed(() => {
|
||
const x = alignmentGuides.value.x
|
||
if (x == null) return null
|
||
const screenX = x * viewport.value.zoom + viewport.value.x
|
||
return { left: `${screenX}px` }
|
||
})
|
||
|
||
const horizontalGuideStyle = computed(() => {
|
||
const y = alignmentGuides.value.y
|
||
if (y == null) return null
|
||
const screenY = y * viewport.value.zoom + viewport.value.y
|
||
return { top: `${screenY}px` }
|
||
})
|
||
|
||
/**
|
||
* 切换语言(zh <-> en)
|
||
*/
|
||
const toggleLocale = () => {
|
||
locale.value = locale.value === 'zh' ? 'en' : 'zh'
|
||
}
|
||
|
||
/**
|
||
* 视图适配:缩放到当前内容的合适视野
|
||
*/
|
||
const fitToView = () => {
|
||
fitView({ padding: 0.2, duration: 800 })
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-screen w-screen bg-white font-mono text-slate-800 relative overflow-hidden flex flex-col selection:bg-orange-100">
|
||
<TopNav
|
||
:t="t"
|
||
:locale="locale"
|
||
:config="config"
|
||
:onFit="fitToView"
|
||
:onResetLayout="resetLayout"
|
||
:onCenterRoot="centerRoot"
|
||
:onStartNewSession="startNewSession"
|
||
:onGenerateSummary="generateSummary"
|
||
:onExportMarkdown="exportMarkdown"
|
||
:onOpenSettings="() => (showSettings = true)"
|
||
@toggle-locale="toggleLocale"
|
||
/>
|
||
|
||
<SideNav :t="t" :locale="locale" :config="config" />
|
||
|
||
<div class="flex-grow relative">
|
||
<VueFlow
|
||
:default-edge-options="{ type: config.edgeType }"
|
||
:fit-view-on-init="true"
|
||
class="bg-white"
|
||
:class="{ 'space-pressed': isSpacePressed }"
|
||
:pan-on-drag="panOnDrag"
|
||
:selection-key-code="'Shift'"
|
||
:snap-to-grid="config.snapToGrid"
|
||
:snap-grid="config.snapGrid"
|
||
@node-drag="handleNodeDrag"
|
||
>
|
||
<Background
|
||
:variant="config.backgroundVariant"
|
||
:pattern-color="config.backgroundVariant === BackgroundVariant.Dots ? '#cbd5e1' : '#f1f5f9'"
|
||
:gap="24"
|
||
:size="config.backgroundVariant === BackgroundVariant.Dots ? 1 : 0.5"
|
||
/>
|
||
<Controls v-if="false" :show-fullscreen="false" :show-fit-view="false">
|
||
<ControlButton @click="toggleFullscreen" :title="isFullscreen ? t('nav.exitFullscreen') : t('nav.fullscreen')">
|
||
<component :is="isFullscreen ? Minimize : Maximize" class="w-4 h-4 text-slate-500" />
|
||
</ControlButton>
|
||
</Controls>
|
||
<MiniMap v-if="config.showMiniMap" pannable zoomable />
|
||
|
||
<template #node-window="{ id, data, selected }">
|
||
<WindowNode
|
||
:id="id"
|
||
:data="data"
|
||
:selected="selected"
|
||
:t="t"
|
||
:config="config"
|
||
:fitView="fitView"
|
||
:activeNodeId="activeNodeId"
|
||
:activePath="activePath"
|
||
:flowNodes="flowNodes"
|
||
:updateNode="updateNode"
|
||
:deepDive="deepDive"
|
||
:generateNodeImage="generateNodeImage"
|
||
:expandIdea="expandIdea"
|
||
:toggleSubtreeCollapse="toggleSubtreeCollapse"
|
||
:isSubtreeCollapsed="isSubtreeCollapsed"
|
||
@preview="previewImageUrl = $event"
|
||
/>
|
||
</template>
|
||
</VueFlow>
|
||
|
||
<div class="absolute inset-0 pointer-events-none z-20">
|
||
<div v-if="config.showAlignmentGuides && verticalGuideStyle" class="absolute top-0 bottom-0 w-px bg-orange-300/70" :style="verticalGuideStyle"></div>
|
||
<div v-if="config.showAlignmentGuides && horizontalGuideStyle" class="absolute left-0 right-0 h-px bg-orange-300/70" :style="horizontalGuideStyle"></div>
|
||
</div>
|
||
|
||
<SettingsModal :show="showSettings" :t="t" :apiConfig="apiConfig" @close="showSettings = false" />
|
||
<ImagePreviewModal :url="previewImageUrl" @close="previewImageUrl = null" />
|
||
<ResetConfirmModal :show="showResetConfirm" :t="t" @close="showResetConfirm = false" @confirm="executeReset" />
|
||
<SummaryModal :show="showSummaryModal" :t="t" :isSummarizing="isSummarizing" :summaryContent="summaryContent" @close="showSummaryModal = false" />
|
||
</div>
|
||
|
||
<BottomBar :t="t" :isLoading="isLoading" v-model="ideaInput" @expand="expandIdea" />
|
||
</div>
|
||
</template>
|
||
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&display=swap');
|
||
|
||
body {
|
||
margin: 0;
|
||
overflow: hidden;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
.font-mono {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
@apply flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-[10px] font-black tracking-widest transition-all active:scale-95 uppercase;
|
||
}
|
||
|
||
.toolbar-btn:hover {
|
||
@apply border-current shadow-sm;
|
||
}
|
||
|
||
.toolbar-select {
|
||
@apply px-3 py-1.5 bg-slate-50 border border-slate-100 rounded-lg text-[10px] font-black tracking-widest text-slate-500 outline-none cursor-pointer hover:border-slate-200 transition-all uppercase;
|
||
}
|
||
|
||
.nav-btn {
|
||
@apply flex items-center gap-1 px-3 py-1 bg-white border border-slate-200 rounded-md text-xs font-medium text-slate-600 hover:border-slate-300 hover:shadow-sm transition-all;
|
||
}
|
||
|
||
/* VueFlow Custom Node Styles */
|
||
.window-node {
|
||
@apply w-[280px] bg-white rounded-xl border border-slate-200 shadow-xl overflow-hidden transition-all duration-300;
|
||
}
|
||
|
||
.window-node:hover {
|
||
@apply shadow-2xl shadow-orange-100 -translate-y-1 border-orange-200;
|
||
}
|
||
|
||
.window-header {
|
||
@apply bg-slate-50/80 px-3 py-1.5 border-b border-slate-100 flex items-center justify-between;
|
||
}
|
||
|
||
.window-title {
|
||
@apply text-[9px] font-bold text-slate-300 tracking-widest uppercase;
|
||
}
|
||
|
||
.window-content {
|
||
@apply p-4;
|
||
}
|
||
|
||
.action-btn {
|
||
@apply flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-bold transition-all active:scale-95 uppercase tracking-tighter whitespace-nowrap border border-transparent;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
@apply border-current bg-opacity-10;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-track {
|
||
@apply bg-transparent;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||
@apply bg-slate-200 rounded-full hover:bg-slate-300 transition-colors;
|
||
}
|
||
|
||
/* VueFlow Overrides */
|
||
.vue-flow__node-window {
|
||
@apply p-0 border-none bg-transparent !important;
|
||
}
|
||
|
||
.vue-flow__node.selected {
|
||
z-index: 1000 !important;
|
||
}
|
||
|
||
.vue-flow__controls {
|
||
@apply !bg-white !border-slate-200 !shadow-xl !rounded-lg !left-4 md:!left-6 !bottom-28 md:!bottom-6 !transition-all;
|
||
}
|
||
|
||
@media (max-width: 767px) {
|
||
.vue-flow__controls {
|
||
margin-left: 0 !important;
|
||
left: 1rem !important;
|
||
}
|
||
}
|
||
|
||
.vue-flow__controls-button {
|
||
@apply !border-slate-100 !fill-slate-400 hover:!bg-slate-50 !transition-colors;
|
||
}
|
||
|
||
.vue-flow__minimap {
|
||
@apply !bg-white/80 !backdrop-blur-md !border-slate-200 !shadow-2xl !rounded-xl !overflow-hidden !transition-all;
|
||
bottom: 130px !important;
|
||
right: 1rem !important;
|
||
width: 140px !important;
|
||
height: 100px !important;
|
||
margin: 0 !important;
|
||
}
|
||
|
||
.vue-flow__minimap svg {
|
||
display: block !important;
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.vue-flow__minimap {
|
||
bottom: 1.5rem !important;
|
||
right: 1.5rem !important;
|
||
width: 220px !important;
|
||
height: 160px !important;
|
||
margin: 0 !important;
|
||
}
|
||
}
|
||
|
||
.vue-flow__minimap-mask {
|
||
@apply !fill-slate-500/5;
|
||
}
|
||
|
||
.vue-flow__minimap-node {
|
||
@apply !fill-slate-200 !stroke-none;
|
||
}
|
||
|
||
/* Custom Controls for Space Dragging */
|
||
.vue-flow__pane {
|
||
cursor: default;
|
||
}
|
||
|
||
.vue-flow__pane.space-pressed {
|
||
cursor: grab;
|
||
}
|
||
|
||
.vue-flow__pane.space-pressed:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.vue-flow__background {
|
||
@apply !bg-white;
|
||
}
|
||
|
||
/* Animation */
|
||
@keyframes spin-slow {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
input::placeholder {
|
||
@apply opacity-30;
|
||
}
|
||
|
||
input:focus::placeholder {
|
||
@apply opacity-10;
|
||
}
|
||
|
||
.line-clamp-3 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.no-scrollbar::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.no-scrollbar {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
</style>
|