Files
ThinkFlowAI/src/App.vue
2026-01-21 20:28:46 +08:00

1443 lines
65 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, reactive, h, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
import {
Sparkles,
Search,
Folder,
Monitor,
BookOpen,
Code,
Sun,
Globe,
LogIn,
Plus,
MousePointer2,
Zap,
ArrowRight,
RefreshCw,
Image as ImageIcon,
Download,
Settings,
Palette,
Grid3X3,
Trash2,
X,
Maximize2,
Terminal,
ChevronRight,
ChevronDown,
ChevronUp,
Menu,
MoreHorizontal,
LayoutDashboard,
Focus,
Target,
Map,
Link as LinkIcon,
Shield,
Key,
Activity
} from 'lucide-vue-next'
import { VueFlow, useVueFlow, Position, MarkerType, Handle } from '@vue-flow/core'
import { Background, BackgroundVariant } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
// 导入 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'
// API 配置
const API_KEY = ''
// API 详情配置
const apiConfig = reactive({
mode: localStorage.getItem('api_mode') || 'default', // 'default' | 'custom'
chat: {
baseUrl: localStorage.getItem('chat_baseUrl') || '',
model: localStorage.getItem('chat_model') || '',
apiKey: localStorage.getItem('chat_apiKey') || ''
},
image: {
baseUrl: localStorage.getItem('image_baseUrl') || '',
model: localStorage.getItem('image_model') || '',
apiKey: localStorage.getItem('image_apiKey') || ''
}
})
// 默认配置常量
const DEFAULT_CONFIG = {
chat: {
baseUrl: 'https://thinkflow.lz-t.top/chat/completions',
model: 'glm-4-flash',
apiKey: ''
},
image: {
baseUrl: 'https://thinkflow.lz-t.top/images/generations',
model: 'cogview-3-flash',
apiKey: ''
}
}
// 监听配置变化并保存
watch(
() => locale.value,
newVal => {
localStorage.setItem('language', newVal)
}
)
watch(
() => apiConfig,
newVal => {
localStorage.setItem('api_mode', newVal.mode)
localStorage.setItem('chat_baseUrl', newVal.chat.baseUrl)
localStorage.setItem('chat_model', newVal.chat.model)
localStorage.setItem('chat_apiKey', newVal.chat.apiKey)
localStorage.setItem('image_baseUrl', newVal.image.baseUrl)
localStorage.setItem('image_model', newVal.image.model)
localStorage.setItem('image_apiKey', newVal.image.apiKey)
},
{ deep: true }
)
const showSettings = ref(false)
// VueFlow 实例
const { addNodes, addEdges, onConnect, setNodes, setEdges, nodes: flowNodes, edges: flowEdges, updateNode, fitView, onNodeDragStart, onNodeDragStop } = useVueFlow()
// 状态管理
const ideaInput = ref('')
const isLoading = ref(false)
const isToolsExpanded = ref(false)
const hoveredNodeId = ref<string | null>(null)
const focusedNodeId = ref<string | null>(null)
const draggingNodeId = ref<string | null>(null)
const previewImageUrl = ref<string | null>(null)
const showResetConfirm = ref(false)
// 画布控制状态
const panOnDrag = ref(true)
const isSpacePressed = ref(false)
// 键盘监听:按住空格键开启抓手拖拽
window.addEventListener('keydown', e => {
if (e.code === 'Space' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
isSpacePressed.value = true
panOnDrag.value = true
}
})
window.addEventListener('keyup', e => {
if (e.code === 'Space') {
isSpacePressed.value = false
}
})
// 拖拽监听
onNodeDragStart(e => {
draggingNodeId.value = e.node.id
})
onNodeDragStop(() => {
draggingNodeId.value = null
})
// 计算当前是否有节点处于“活跃”状态(被选中、聚焦、拖拽或正在生成)
const activeNodeId = computed(() => {
const expandingNode = flowNodes.value.find(n => n.data.isExpanding)
const selectedNode = flowNodes.value.find(n => n.selected)
return expandingNode?.id || selectedNode?.id || draggingNodeId.value || focusedNodeId.value
})
/**
* 递归获取所有子节点 ID
*/
const getDescendantIds = (nodeId: string, ids: Set<string> = new Set()): Set<string> => {
flowEdges.value.forEach(edge => {
if (edge.source === nodeId) {
ids.add(edge.target)
getDescendantIds(edge.target, ids)
}
})
return ids
}
// 计算当前活跃节点的相关路径(向上追溯到根,向下包含所有子孙)
const activePath = computed(() => {
const nodeIds = new Set<string>()
const edgeIds = new Set<string>()
if (!activeNodeId.value) return { nodeIds, edgeIds }
const targetId = activeNodeId.value
nodeIds.add(targetId)
// 1. 向上追溯到根节点
let currentId = targetId
while (currentId) {
const edge = flowEdges.value.find(e => e.target === currentId)
if (edge) {
edgeIds.add(edge.id)
nodeIds.add(edge.source)
currentId = edge.source
} else {
break
}
}
// 2. 向下包含所有子孙节点和相关连线
const descendantIds = getDescendantIds(targetId)
descendantIds.forEach(id => nodeIds.add(id))
// 3. 收集子孙节点之间的连线
flowEdges.value.forEach(edge => {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
edgeIds.add(edge.id)
}
})
return { nodeIds, edgeIds }
})
// 画布配置
const config = reactive({
edgeColor: '#fed7aa',
edgeStyle: 'smoothstep',
backgroundVariant: BackgroundVariant.Lines,
showControls: true,
showMiniMap: true
})
const lastAppliedStatus = ref('')
// 监听 activePath 和配置变化,动态更新连线状态
watch(
[() => activeNodeId.value, () => config.edgeColor, () => flowEdges.value.length, () => flowNodes.value.some(n => n.data.isExpanding)],
([newNodeId, newColor, newLength, anyExpanding]) => {
const { edgeIds } = activePath.value
const edgeIdsStr = Array.from(edgeIds).sort().join(',')
// 状态标识:包含高亮边、颜色、以及是否有节点在发散(影响动画)
const currentStatus = `${edgeIdsStr}-${newColor}-${anyExpanding}`
if (lastAppliedStatus.value === currentStatus) return
lastAppliedStatus.value = currentStatus
setEdges(
flowEdges.value.map(edge => {
const isHighlighted = edgeIds.has(edge.id)
const isExpanding = !!flowNodes.value.find(n => n.id === edge.source)?.data.isExpanding
return {
...edge,
animated: isHighlighted || isExpanding,
style: {
...edge.style,
stroke: isHighlighted ? newColor : `${newColor}33`,
strokeWidth: isHighlighted ? 3 : 2,
transition: 'all 0.3s ease'
}
}
})
)
},
{ immediate: true }
)
/**
* 统一错误处理
*/
const getErrorMessage = (error: any) => {
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
return t('common.error.cors')
}
if (error.status === 429) return t('common.error.rateLimit')
if (error.status === 400) return t('common.error.badRequest')
if (error.status >= 500) return t('common.error.serverError')
return error.message || t('common.error.unknown')
}
/**
* 聚焦到根节点
*/
const centerRoot = () => {
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (rootNode) {
fitView({ nodes: [rootNode.id], padding: 2, duration: 800 })
}
}
/**
* 重置布局
*/
const resetLayout = () => {
// 找到根节点
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (!rootNode) return
// 重新计算所有节点位置
const visited = new Set<string>()
const layoutNode = (nodeId: string, x: number, y: number) => {
if (visited.has(nodeId)) return
visited.add(nodeId)
const node = flowNodes.value.find(n => n.id === nodeId)
if (node) {
node.position = { x, y }
// 找到所有子节点
const childEdges = flowEdges.value.filter(e => e.source === nodeId)
childEdges.forEach((edge, index) => {
const offsetX = 450
// 计算子节点垂直分布
const totalHeight = (childEdges.length - 1) * 280
const startY = y - totalHeight / 2
const offsetY = index * 280
layoutNode(edge.target, x + offsetX, startY + offsetY)
})
}
}
layoutNode(rootNode.id, 50, 300)
// 动画过渡到合适视图
setTimeout(() => {
fitView({ padding: 0.2, duration: 800 })
}, 100)
}
/**
* 导出为图片
*/
const exportMarkdown = () => {
if (flowNodes.value.length === 0) return
// 找到根节点
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (!rootNode) return
let markdown = `# ${rootNode.data.label}\n\n`
// 递归构建 Markdown 内容
const buildMarkdown = (parentId: string, level: number) => {
const children = flowEdges.value
.filter(e => e.source === parentId)
.map(e => flowNodes.value.find(n => n.id === e.target))
.filter(n => n !== undefined)
children.forEach(child => {
const indent = ' '.repeat(level - 1)
markdown += `${indent}- ${child!.data.label}\n`
if (child!.data.detailedContent) {
const detailIndent = ' '.repeat(level)
markdown += `${detailIndent}> ${child!.data.detailedContent.replace(/\n/g, `\n${detailIndent}> `)}\n`
}
buildMarkdown(child!.id, level + 1)
})
}
buildMarkdown(rootNode.id, 1)
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.download = `thinkflow-${rootNode.data.label}-${Date.now()}.md`
link.href = url
link.click()
URL.revokeObjectURL(url)
}
/**
* 调用智谱AI生成图片
*/
const generateNodeImage = async (nodeId: string, prompt: string) => {
const node = flowNodes.value.find(n => n.id === nodeId)
if (!node || node.data.isImageLoading) return
// 激活节点
updateNode(nodeId, { selected: true, zIndex: 1000 })
updateNode(nodeId, { data: { ...node.data, isImageLoading: true, error: null } })
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.image : apiConfig.image
// 自定义模式下完全使用用户输入,不进行项目 Key 兜底
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
prompt: t('prompts.image', { prompt })
})
})
if (!response.ok) {
const error: any = new Error('Image request failed')
error.status = response.status
throw error
}
const data = await response.json()
const imageUrl = data.data[0].url
updateNode(nodeId, { data: { ...node.data, imageUrl, isImageLoading: false, error: null } })
} catch (error: any) {
console.error('Image Generation Error:', error)
updateNode(nodeId, { data: { ...node.data, isImageLoading: false, error: getErrorMessage(error) } })
}
}
/**
* 深度解析节点内容
*/
const deepDive = async (nodeId: string, topic: string) => {
const node = flowNodes.value.find(n => n.id === nodeId)
if (!node) return
// 激活节点并置顶
updateNode(nodeId, { selected: true, zIndex: 1000 })
// 如果已经有内容且当前是收起状态,则直接展开
if (node.data.detailedContent && !node.data.isDetailExpanded) {
updateNode(nodeId, { data: { ...node.data, isDetailExpanded: true } })
return
}
// 如果已经在加载,则不重复请求
if (node.data.isDeepDiving) return
updateNode(nodeId, { data: { ...node.data, isDeepDiving: true, isDetailExpanded: true, error: null } })
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.chat : apiConfig.chat
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
messages: [{ role: 'user', content: t('prompts.deepDivePrompt', { topic }) }]
})
})
if (!response.ok) {
const error: any = new Error('Deep dive request failed')
error.status = response.status
throw error
}
const data = await response.json()
const content = data.choices[0].message.content
updateNode(nodeId, { data: { ...node.data, detailedContent: content, isDeepDiving: false, error: null } })
} catch (error: any) {
console.error('Deep Dive Error:', error)
updateNode(nodeId, { data: { ...node.data, isDeepDiving: false, error: getErrorMessage(error) } })
}
}
/**
* 递归获取从根节点到当前节点的路径
*/
const findPathToNode = (nodeId: string): string[] => {
const path: string[] = []
let currentId = nodeId
while (currentId) {
const node = flowNodes.value.find(n => n.id === currentId)
if (node) {
path.unshift(`${node.data.label} (${node.data.description})`)
// 查找连入该节点的边
const edge = flowEdges.value.find(e => e.target === currentId)
currentId = edge ? edge.source : ''
} else {
break
}
}
return path
}
/**
* 调用智谱AI生成思维发散节点
*/
const expandIdea = async (param?: any, customInput?: string) => {
// 判断是点击了节点上的按钮还是主输入框
const parentNode = param && param.id ? param : undefined
const text = customInput || (parentNode ? parentNode.data.label : ideaInput.value)
if (!text || (parentNode ? parentNode.data.isExpanding : isLoading.value)) return
let currentParentId = parentNode?.id
// 如果是第一次生成,立即创建根节点并清空画布
if (!parentNode) {
isLoading.value = true
setNodes([])
setEdges([])
const rootId = 'root-' + Date.now()
currentParentId = rootId
addNodes({
id: rootId,
type: 'window',
position: { x: 50, y: 300 },
data: {
label: text,
description: t('node.coreIdea'),
type: 'root',
isExpanding: true,
isTitleExpanded: false,
followUp: '',
error: null
},
sourcePosition: Position.Right,
targetPosition: Position.Left
})
ideaInput.value = ''
} else {
const node = flowNodes.value.find(n => n.id === parentNode.id)
if (node) {
updateNode(parentNode.id, {
data: {
...node.data,
isExpanding: true,
isDetailExpanded: false, // 开始发散时隐藏详情
error: null
}
})
}
}
const systemPrompt = t('prompts.system')
let userMessage = ''
if (parentNode) {
const path = findPathToNode(parentNode.id)
userMessage = `[${t('prompts.contextPath')}]: ${path.join(' -> ')}\n[${t('prompts.selectedNode')}]: ${parentNode.data.label}\n[${t('prompts.newRequirement')}]: ${customInput || t('prompts.continue')}`
} else {
userMessage = `${t('prompts.coreIdeaPrefix')}: ${text}`
}
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.chat : apiConfig.chat
// 自定义模式下完全使用用户输入,不进行项目 Key 兜底
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }
],
response_format: { type: 'json_object' },
temperature: 0.8
})
})
if (!response.ok) {
const error: any = new Error('AI request failed')
error.status = response.status
throw error
}
const data = await response.json()
const result = JSON.parse(data.choices[0].message.content)
// 使用最新的节点位置计算子节点位置
const parentNodeObj = flowNodes.value.find(n => n.id === currentParentId)
const startX = parentNodeObj ? parentNodeObj.position.x : 50
const startY = parentNodeObj ? parentNodeObj.position.y : 300
processSubNodes(result.nodes, currentParentId, startX, startY)
// 首次输入后优化缩放比例展示根节点和大约3个二级节点
if (!parentNode) {
setTimeout(() => {
const childEdges = flowEdges.value.filter(e => e.source === currentParentId)
const childIds = childEdges.map(e => e.target)
// 选取前3个二级节点作为缩放参考这样可以保证缩放比例适中约看到3个二级的大小
const nodesToFit = [currentParentId, ...childIds.slice(0, 3)]
fitView({
nodes: nodesToFit,
padding: 0.25,
duration: 1000
})
}, 100)
}
} catch (error: any) {
console.error('Expansion Error:', error)
const node = flowNodes.value.find(n => n.id === currentParentId)
if (node) {
updateNode(currentParentId, { data: { ...node.data, error: getErrorMessage(error) } })
}
} finally {
const node = flowNodes.value.find(n => n.id === currentParentId)
if (node) {
node.data.isExpanding = false
}
isLoading.value = false
}
}
const processSubNodes = (subNodes: any[], parentId: string, baseX: number, baseY: number) => {
subNodes.forEach((item: any, index: number) => {
const childId = `node-${Date.now()}-${index}`
const offsetX = 450
const offsetY = (index - (subNodes.length - 1) / 2) * 280
addNodes({
id: childId,
type: 'window',
position: { x: baseX + offsetX, y: baseY + offsetY },
data: {
label: item.text,
description: item.description,
type: 'child',
followUp: '',
isExpanding: false,
isImageLoading: false,
isTitleExpanded: false,
error: null
},
sourcePosition: Position.Right,
targetPosition: Position.Left
})
addEdges({
id: `e-${parentId}-${childId}`,
source: parentId,
target: childId,
animated: true,
style: { stroke: config.edgeColor, strokeWidth: 2 },
markerEnd: MarkerType.ArrowClosed
})
})
}
const executeReset = () => {
ideaInput.value = ''
setNodes([])
setEdges([])
showResetConfirm.value = false
}
const startNewSession = () => {
if (flowNodes.value.length > 0) {
showResetConfirm.value = true
return
}
executeReset()
}
</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">
<!-- 顶部导航栏 (工具栏) -->
<nav class="flex-none bg-white/80 backdrop-blur-md border-b border-slate-200 px-3 md:px-6 py-2 md:py-3 flex items-center justify-between shadow-sm z-50">
<div class="flex items-center gap-2 md:gap-6 flex-grow mr-2">
<div class="flex items-center gap-2 flex-shrink-0">
<div class="w-3 h-3 bg-orange-500 rounded-sm rotate-45"></div>
<span class="font-black text-slate-900 tracking-tighter text-base md:text-lg">ThinkFlow</span>
</div>
<div class="h-6 w-[1px] bg-slate-200 mx-1 md:mx-2 flex-shrink-0"></div>
<!-- 桌面端工具按钮组 -->
<div class="hidden md:flex items-center gap-2">
<!-- 布局控制 -->
<button @click="fitView({ padding: 0.2, duration: 800 })" class="toolbar-btn text-blue-500 hover:bg-blue-50 border-blue-100 flex-shrink-0" :title="t('nav.fit')">
<Focus class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ t('nav.fit') }}</span>
</button>
<button @click="resetLayout" class="toolbar-btn text-purple-500 hover:bg-purple-50 border-purple-100 flex-shrink-0" :title="t('nav.layout')">
<LayoutDashboard class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ t('nav.layout') }}</span>
</button>
<button @click="centerRoot" class="toolbar-btn text-orange-500 hover:bg-orange-50 border-orange-100 flex-shrink-0" :title="t('nav.center')">
<Target class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ t('nav.center') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<!-- 重置画布 -->
<button @click="startNewSession" class="toolbar-btn text-red-500 hover:bg-red-50 border-red-100 flex-shrink-0" :title="t('nav.reset')">
<Trash2 class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ 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="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">{{ t('nav.edge') }}</span>
</div>
<!-- 背景样式 -->
<select v-model="config.backgroundVariant" class="toolbar-select flex-shrink-0">
<option :value="BackgroundVariant.Lines">{{ t('nav.lines') }}</option>
<option :value="BackgroundVariant.Dots">{{ t('nav.dots') }}</option>
</select>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<!-- 小地图开关 -->
<button
@click="config.showMiniMap = !config.showMiniMap"
class="toolbar-btn border-slate-100 flex-shrink-0"
:class="config.showMiniMap ? 'text-blue-500 bg-blue-50 border-blue-100' : 'text-slate-400 hover:text-slate-600'"
:title="t('nav.map')"
>
<Map class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ t('nav.map') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<!-- 导出选项 -->
<button @click="exportMarkdown" class="toolbar-btn text-indigo-600 hover:bg-indigo-50 border-indigo-100 flex-shrink-0" :title="t('nav.export')">
<Download class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ t('nav.export') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<!-- 设置按钮 -->
<button @click="showSettings = true" class="toolbar-btn text-slate-600 hover:bg-slate-50 border-slate-100 flex-shrink-0">
<Settings class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ t('common.settings') }}</span>
</button>
</div>
<!-- 移动端工具切换按钮 -->
<div class="md:hidden flex items-center">
<button
@click="isToolsExpanded = !isToolsExpanded"
class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg text-slate-600 active:bg-slate-100 transition-colors"
>
<Menu v-if="!isToolsExpanded" class="w-4 h-4" />
<X v-else class="w-4 h-4" />
<span class="text-xs font-bold">{{ t('common.tools' || 'Tools') }}</span>
<ChevronDown v-if="!isToolsExpanded" class="w-3 h-3 opacity-50" />
<ChevronUp v-else class="w-3 h-3 opacity-50" />
</button>
</div>
</div>
<div class="flex items-center gap-2 md:gap-3 flex-shrink-0">
<button
@click="locale = locale === 'zh' ? 'en' : 'zh'"
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" /> {{ locale === 'zh' ? 'EN' : 'ZH' }}
</button>
<button v-if="false"
class="flex items-center gap-1.5 md:gap-2 px-3 md:px-4 py-1.5 md:py-2 bg-slate-900 text-white rounded-lg md:rounded-xl text-[10px] md:text-xs font-black tracking-widest hover:bg-slate-800 transition-all shadow-lg shadow-slate-200"
>
<span>{{ t('common.signin') }}</span> <LogIn class="w-3 h-3 md:w-3.5 h-3.5 md:ml-1" />
</button>
</div>
</nav>
<!-- 移动端折叠工具栏 -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform -translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
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">
<!-- 布局控制 -->
<button @click="fitView({ padding: 0.2, duration: 800 }); isToolsExpanded = false" class="toolbar-btn text-blue-500 hover:bg-blue-50 border-blue-100" :title="t('nav.fit')">
<Focus class="w-4 h-4" />
<span>{{ t('nav.fit') }}</span>
</button>
<button @click="resetLayout(); isToolsExpanded = false" class="toolbar-btn text-purple-500 hover:bg-purple-50 border-purple-100" :title="t('nav.layout')">
<LayoutDashboard class="w-4 h-4" />
<span>{{ t('nav.layout') }}</span>
</button>
<button @click="centerRoot(); isToolsExpanded = false" class="toolbar-btn text-orange-500 hover:bg-orange-50 border-orange-100" :title="t('nav.center')">
<Target class="w-4 h-4" />
<span>{{ t('nav.center') }}</span>
</button>
<!-- 重置画布 -->
<button @click="startNewSession(); isToolsExpanded = false" class="toolbar-btn text-red-500 hover:bg-red-50 border-red-100" :title="t('nav.reset')">
<Trash2 class="w-4 h-4" />
<span>{{ t('nav.reset') }}</span>
</button>
<!-- 连线颜色 -->
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-100">
<Palette class="w-4 h-4 text-slate-400" />
<input type="color" v-model="config.edgeColor" class="w-4 h-4 rounded cursor-pointer bg-transparent border-none" />
<span class="text-[10px] font-bold text-slate-500 uppercase">{{ t('nav.edge') }}</span>
</div>
<!-- 背景样式 -->
<select v-model="config.backgroundVariant" class="toolbar-select">
<option :value="BackgroundVariant.Lines">{{ t('nav.lines') }}</option>
<option :value="BackgroundVariant.Dots">{{ t('nav.dots') }}</option>
</select>
<!-- 小地图开关 -->
<button
@click="config.showMiniMap = !config.showMiniMap"
class="toolbar-btn border-slate-100"
:class="config.showMiniMap ? 'text-blue-500 bg-blue-50 border-blue-100' : 'text-slate-400 hover:text-slate-600'"
:title="t('nav.map')"
>
<Map class="w-4 h-4" />
<span>{{ t('nav.map') }}</span>
</button>
<!-- 导出选项 -->
<button @click="exportMarkdown(); isToolsExpanded = false" class="toolbar-btn text-indigo-600 hover:bg-indigo-50 border-indigo-100" :title="t('nav.export')">
<Download class="w-4 h-4" />
<span>{{ t('nav.export') }}</span>
</button>
<!-- 设置按钮 -->
<button @click="showSettings = true; isToolsExpanded = false" class="toolbar-btn text-slate-600 hover:bg-slate-50 border-slate-100">
<Settings class="w-4 h-4" />
<span>{{ t('common.settings') }}</span>
</button>
</div>
</Transition>
<!-- 主内容区VueFlow 画布 -->
<div class="flex-grow relative">
<VueFlow
:default-edge-options="{ type: 'smoothstep' }"
:fit-view-on-init="true"
class="bg-white"
:class="{ 'space-pressed': isSpacePressed }"
:pan-on-drag="panOnDrag"
:selection-key-code="'Shift'"
>
<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="config.showControls" />
<MiniMap v-if="config.showMiniMap" pannable zoomable />
<!-- 自定义节点插槽 -->
<template #node-window="{ id, data, selected }">
<div
class="window-node group transition-all duration-500"
:class="{
'opacity-40 grayscale-[0.4] blur-[0.5px] scale-[0.98] pointer-events-none': activeNodeId && !activePath.nodeIds.has(id),
'opacity-100 grayscale-0 blur-0 scale-105 z-50 ring-2 ring-offset-4': activePath.nodeIds.has(id),
'!w-[450px]': data.isDetailExpanded
}"
:style="{
borderColor: activePath.nodeIds.has(id) ? config.edgeColor : config.edgeColor + '40',
boxShadow: activeNodeId === id ? `0 20px 50px -12px ${config.edgeColor}40` : '',
'--tw-ring-color': selected ? config.edgeColor + '40' : 'transparent'
}"
@mouseenter="hoveredNodeId = id"
@mouseleave="hoveredNodeId = null"
>
<!-- 增加连接锚点 -->
<Handle type="target" :position="Position.Left" class="!bg-transparent !border-none" />
<Handle type="source" :position="Position.Right" class="!bg-transparent !border-none" />
<!-- Window Header -->
<div class="window-header" :style="{ backgroundColor: activePath.nodeIds.has(id) ? config.edgeColor + '15' : config.edgeColor + '05' }">
<div class="flex gap-1.5">
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: activePath.nodeIds.has(id) ? config.edgeColor : config.edgeColor + '40' }"></div>
<div class="w-2 h-2 rounded-full bg-slate-200"></div>
<div class="w-2 h-2 rounded-full bg-slate-200"></div>
</div>
<span class="window-title" :style="{ color: activePath.nodeIds.has(id) ? config.edgeColor : '' }">
{{ data.type === 'root' ? t('node.mainTitle') : t('node.moduleTitle') }}
</span>
</div>
<!-- Loading Overlay for Node -->
<div
v-if="data.isExpanding"
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-white/60 backdrop-blur-[2px] rounded-2xl transition-all duration-300"
>
<div class="relative">
<RefreshCw class="w-8 h-8 text-slate-900 animate-spin mb-3" :style="{ color: config.edgeColor }" />
<div class="absolute inset-0 blur-xl opacity-20 animate-pulse" :style="{ backgroundColor: config.edgeColor }"></div>
</div>
<span class="text-[10px] font-black tracking-widest uppercase text-slate-500">{{ t('common.expanding') }}</span>
</div>
<!-- Window Content -->
<div class="window-content">
<!-- Image Display -->
<div
v-if="data.imageUrl || data.isImageLoading"
class="mb-4 rounded-lg overflow-hidden bg-slate-50 border border-slate-100 aspect-video flex items-center justify-center relative group/img cursor-pointer"
@click.stop="data.imageUrl ? (previewImageUrl = data.imageUrl) : null"
>
<img v-if="data.imageUrl" :src="data.imageUrl" class="w-full h-full object-cover" />
<div v-if="data.isImageLoading" class="absolute inset-0 flex flex-col items-center justify-center bg-white/80 backdrop-blur-sm cursor-default">
<RefreshCw class="w-6 h-6 text-orange-500 animate-spin mb-2" />
<span class="text-[8px] font-bold text-slate-400 uppercase">{{ t('common.generating') }}</span>
</div>
<div
v-if="data.imageUrl"
class="absolute inset-0 bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center gap-2"
>
<button class="p-2 bg-white/20 hover:bg-white/40 rounded-full backdrop-blur-md transition-all" :title="t('node.view')">
<Maximize2 class="w-4 h-4 text-white" />
</button>
<button
@click.stop="generateNodeImage(id, data.label)"
class="p-2 bg-white/20 hover:bg-white/40 rounded-full backdrop-blur-md transition-all"
:title="t('node.regenerate')"
>
<RefreshCw class="w-4 h-4 text-white" />
</button>
</div>
</div>
<div class="flex items-start gap-2 mb-2">
<span class="font-bold shrink-0 mt-0.5" :style="{ color: config.edgeColor }">></span>
<h3
class="font-black text-slate-900 tracking-tight cursor-pointer hover:text-orange-600 transition-colors"
:class="data.isTitleExpanded ? 'whitespace-normal' : 'truncate'"
@click.stop="updateNode(id, { data: { ...data, isTitleExpanded: !data.isTitleExpanded } })"
>
{{ data.label }}
</h3>
</div>
<p class="text-[10px] text-slate-500 leading-relaxed font-medium line-clamp-3">
{{ data.description }}
</p>
<!-- 错误反馈显示 -->
<div v-if="data.error" class="mt-3 p-2.5 bg-red-50 border border-red-100 rounded-lg animate-in fade-in slide-in-from-top-1 duration-300">
<div class="flex items-start gap-2">
<Shield class="w-3.5 h-3.5 text-red-500 shrink-0 mt-0.5" />
<div class="flex-grow space-y-1">
<p class="text-[10px] font-black text-red-600 leading-tight">{{ t('common.error.title') }}</p>
<p class="text-[9px] text-red-500 leading-relaxed">{{ data.error }}</p>
</div>
<button
@click.stop="data.imageUrl === null && data.isImageLoading === false ? generateNodeImage(id, data.label) : expandIdea({ id, data, position: flowNodes.find(n => n.id === id)?.position })"
class="p-1 hover:bg-red-100 rounded transition-colors"
:title="t('common.error.retry')"
>
<RefreshCw class="w-3 h-3 text-red-600" />
</button>
</div>
</div>
<!-- Node Actions -->
<div class="pt-3 mt-3 border-t border-slate-50">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-1.5 shrink-0">
<div class="w-1.5 h-1.5 rounded-full animate-pulse" :style="{ backgroundColor: data.isExpanding ? config.edgeColor : '#34d399' }"></div>
<span class="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{{
data.isExpanding ? t('common.expanding') : t('common.active')
}}</span>
</div>
<div class="flex items-center gap-2">
<button
@click.stop="deepDive(id, data.label)"
class="action-btn text-orange-500 hover:bg-orange-50"
:title="t('node.deepDive')"
>
<BookOpen class="w-2.5 h-2.5" />
<span>{{ t('node.deepDive') }}</span>
</button>
<button
v-if="!data.imageUrl && !data.isImageLoading"
@click.stop="generateNodeImage(id, data.label)"
class="action-btn text-blue-500 hover:bg-blue-50"
>
<ImageIcon class="w-2.5 h-2.5" />
<span>{{ t('node.imgAction') }}</span>
</button>
</div>
</div>
<!-- Detailed Content Display -->
<div v-if="data.isDetailExpanded" class="mb-4 pt-4 border-t border-slate-100 animate-in fade-in slide-in-from-top-2 duration-300">
<div class="flex items-center justify-between mb-2">
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ t('node.deepDive') }}</span>
<button @click.stop="updateNode(id, { data: { ...data, isDetailExpanded: false } })" class="text-slate-300 hover:text-slate-500">
<X class="w-3 h-3" />
</button>
</div>
<div v-if="data.isDeepDiving" class="flex flex-col items-center py-6">
<div class="relative mb-3">
<RefreshCw class="w-6 h-6 text-orange-400 animate-spin" />
<div class="absolute inset-0 blur-lg bg-orange-200 opacity-50 animate-pulse"></div>
</div>
<span class="text-[9px] font-black text-slate-300 uppercase tracking-widest animate-pulse">{{ t('common.loading') }}</span>
</div>
<div v-else class="text-[11px] text-slate-600 leading-relaxed font-medium whitespace-pre-wrap max-h-[350px] overflow-y-auto custom-scrollbar pr-2 selection:bg-orange-100 nowheel">
{{ data.detailedContent }}
</div>
</div>
<!-- Follow-up Input -->
<div class="relative group/input">
<div
class="flex items-center gap-2 bg-slate-50 rounded-lg px-2.5 py-2 border border-slate-100 focus-within:bg-white transition-all"
:style="{ borderColor: data.followUp || focusedNodeId === id ? config.edgeColor : '' }"
>
<ChevronRight v-if="!data.followUp" class="w-3 h-3 text-slate-400" />
<Terminal v-else class="w-3 h-3" :style="{ color: config.edgeColor }" />
<input
v-model="data.followUp"
@focus="focusedNodeId = id"
@blur="focusedNodeId = null"
@keyup.enter="expandIdea({ id, data, position: flowNodes.find(n => n.id === id)?.position }, data.followUp)"
:placeholder="t('node.followUp')"
class="bg-transparent border-none outline-none text-[10px] font-bold text-slate-700 flex-grow placeholder:text-slate-300"
:disabled="data.isExpanding"
/>
<button
@click.stop="expandIdea({ id, data, position: flowNodes.find(n => n.id === id)?.position }, data.followUp)"
:disabled="!data.followUp?.trim() || data.isExpanding"
class="transition-all transform active:scale-90"
:style="{ color: data.followUp?.trim() ? config.edgeColor : '#cbd5e1' }"
>
<RefreshCw v-if="data.isExpanding" class="w-3.5 h-3.5 animate-spin" />
<ArrowRight v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</VueFlow>
<!-- 浮动 UI -->
<div class="absolute inset-0 pointer-events-none z-10 p-12"></div>
<!-- 设置弹窗 -->
<div v-if="showSettings" class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" @click.self="showSettings = false">
<div class="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden border border-slate-200 animate-in fade-in zoom-in duration-300">
<div class="px-8 py-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div class="flex items-center gap-3">
<div class="p-2 bg-slate-900 rounded-xl text-white">
<Settings class="w-5 h-5" />
</div>
<div>
<h3 class="text-lg font-black text-slate-900 tracking-tight">{{ t('settings.title') }}</h3>
<p class="text-xs text-slate-500 font-bold uppercase tracking-wider">{{ t('settings.subtitle') }}</p>
</div>
</div>
<button @click="showSettings = false" class="p-2 hover:bg-slate-200 rounded-xl transition-colors">
<X class="w-5 h-5 text-slate-500" />
</button>
</div>
<div class="p-8 space-y-8 max-h-[70vh] overflow-y-auto custom-scrollbar">
<!-- 模式切换 -->
<div class="flex p-1.5 bg-slate-100 rounded-2xl w-fit">
<button
@click="apiConfig.mode = 'default'"
class="px-6 py-2 rounded-xl text-xs font-black tracking-widest transition-all"
:class="apiConfig.mode === 'default' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
>
{{ t('common.default') }}
</button>
<button
@click="apiConfig.mode = 'custom'"
class="px-6 py-2 rounded-xl text-xs font-black tracking-widest transition-all"
:class="apiConfig.mode === 'custom' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
>
{{ t('common.custom') }}
</button>
</div>
<div v-if="apiConfig.mode === 'custom'" class="space-y-4 animate-in slide-in-from-top-2 duration-300">
<div class="flex items-center gap-2 text-slate-900">
<Sparkles class="w-4 h-4 text-orange-500" />
<span class="text-sm font-black uppercase tracking-widest">{{ t('settings.textGen') }}</span>
</div>
<div class="grid grid-cols-1 gap-4 p-5 bg-slate-50 rounded-2xl border border-slate-100">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<LinkIcon class="w-3 h-3" /> {{ t('settings.baseUrl') }}
</label>
<input
v-model="apiConfig.chat.baseUrl"
type="text"
:placeholder="t('settings.placeholderUrl')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 outline-none transition-all font-mono"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<Shield class="w-3 h-3" /> {{ t('settings.modelName') }}
</label>
<input
v-model="apiConfig.chat.model"
type="text"
:placeholder="t('settings.placeholderModel')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 outline-none transition-all font-mono"
/>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<Key class="w-3 h-3" /> {{ t('settings.apiKey') }}
</label>
<input
v-model="apiConfig.chat.apiKey"
type="password"
:placeholder="t('settings.placeholderKey')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 outline-none transition-all font-mono"
/>
</div>
</div>
</div>
</div>
<div v-if="apiConfig.mode === 'custom'" class="space-y-4 animate-in slide-in-from-top-2 duration-300">
<div class="flex items-center gap-2 text-slate-900">
<ImageIcon class="w-4 h-4 text-blue-500" />
<span class="text-sm font-black uppercase tracking-widest">{{ t('settings.imageGen') }}</span>
</div>
<div class="grid grid-cols-1 gap-4 p-5 bg-slate-50 rounded-2xl border border-slate-100">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<LinkIcon class="w-3 h-3" /> {{ t('settings.baseUrl') }}
</label>
<input
v-model="apiConfig.image.baseUrl"
type="text"
:placeholder="t('settings.placeholderUrl')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<Shield class="w-3 h-3" /> {{ t('settings.modelName') }}
</label>
<input
v-model="apiConfig.image.model"
type="text"
:placeholder="t('settings.placeholderModel')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono"
/>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<Key class="w-3 h-3" /> {{ t('settings.apiKey') }}
</label>
<input
v-model="apiConfig.image.apiKey"
type="password"
:placeholder="t('settings.placeholderKey')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono"
/>
</div>
</div>
</div>
</div>
<!-- 默认模式提示 -->
<div v-if="apiConfig.mode === 'default'" class="flex flex-col items-center justify-center py-12 text-center space-y-4 animate-in fade-in duration-500">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center">
<Activity class="w-8 h-8 text-slate-400" />
</div>
<div class="space-y-1">
<h4 class="text-sm font-black text-slate-900 uppercase">{{ t('settings.defaultModeTitle') }}</h4>
<p class="text-xs text-slate-500 max-w-[280px]">{{ t('settings.defaultModeDesc') }}</p>
</div>
</div>
</div>
<div class="px-8 py-6 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-3">
<button
@click="showSettings = false"
class="px-8 py-2.5 bg-slate-900 text-white rounded-xl text-xs font-black tracking-widest hover:bg-slate-800 transition-all shadow-lg shadow-slate-200 active:scale-95"
>
{{ t('common.save') }}
</button>
</div>
</div>
</div>
<!-- 全局图片预览弹窗 -->
<Transition name="fade">
<div v-if="previewImageUrl" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-10" @click="previewImageUrl = null">
<div class="relative max-w-full max-h-full rounded-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-300" @click.stop>
<button @click="previewImageUrl = null" class="absolute top-4 right-4 p-2 bg-black/50 hover:bg-black/70 text-white rounded-full transition-colors z-10">
<X class="w-5 h-5" />
</button>
<img :src="previewImageUrl" class="max-w-screen max-h-screen object-contain" />
</div>
</div>
</Transition>
<!-- 自定义重置确认弹窗 -->
<Transition name="fade">
<div v-if="showResetConfirm" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="showResetConfirm = false"></div>
<div class="relative bg-white rounded-2xl shadow-2xl border border-slate-100 p-6 w-full max-w-sm overflow-hidden group animate-in zoom-in duration-300">
<!-- 背景装饰 -->
<div class="absolute -top-12 -right-12 w-24 h-24 bg-orange-50 rounded-full blur-2xl group-hover:bg-orange-100 transition-colors"></div>
<div class="relative flex flex-col items-center text-center space-y-4">
<div class="w-16 h-16 bg-orange-50 rounded-2xl flex items-center justify-center text-orange-500 mb-2 ring-4 ring-orange-50/50">
<RefreshCw class="w-8 h-8 animate-spin-slow" />
</div>
<div class="space-y-2">
<h3 class="text-lg font-bold text-slate-800 tracking-tight">{{ t('nav.reset') }}</h3>
<p class="text-sm text-slate-500 leading-relaxed px-4">
{{ t('common.confirmReset') }}
</p>
</div>
<div class="flex items-center gap-3 w-full pt-2">
<button
@click="showResetConfirm = false"
class="flex-1 px-4 py-2.5 rounded-xl border border-slate-200 text-slate-600 font-medium hover:bg-slate-50 transition-colors active:scale-95"
>
{{ t('common.cancel') || 'Cancel' }}
</button>
<button
@click="executeReset"
class="flex-1 px-4 py-2.5 rounded-xl bg-orange-500 text-white font-medium hover:bg-orange-600 shadow-lg shadow-orange-500/30 transition-all active:scale-95"
>
{{ t('common.confirm') || 'Confirm' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- 底部全局操作栏 -->
<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="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"
>
<Terminal class="w-4 h-4 md:w-5 h-5 text-slate-400 flex-shrink-0" />
<input
v-model="ideaInput"
:placeholder="t('nav.placeholder')"
class="flex-grow bg-transparent border-none outline-none text-xs md:text-sm font-bold text-slate-700 placeholder:text-slate-300 min-w-0"
@keyup.enter="expandIdea"
/>
<button
@click="expandIdea"
:disabled="isLoading || !ideaInput.trim()"
class="flex items-center gap-1.5 md:gap-2 px-3 md:px-4 py-2 md:py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-lg md:rounded-xl transition-all active:scale-95 disabled:opacity-20 disabled:grayscale disabled:cursor-not-allowed group/btn flex-shrink-0"
>
<span class="text-[9px] md:text-[10px] font-black tracking-widest uppercase">{{ t('nav.execute') }}</span>
<Zap v-if="!isLoading" class="w-3.5 h-3.5 md:w-4 h-4 text-orange-400 group-hover/btn:scale-110 transition-transform" />
<RefreshCw v-else class="w-3.5 h-3.5 md:w-4 h-4 animate-spin" />
</button>
</div>
</div>
</div>
</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;
}
.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;
margin: 1.5rem !important;
bottom: 80px !important;
right: 0 !important;
width: 180px !important;
height: 120px !important;
}
@media (min-width: 768px) {
.vue-flow__minimap {
bottom: 0 !important;
width: 220px !important;
height: 160px !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>