样式优化

This commit is contained in:
liuziting
2026-01-21 15:49:59 +08:00
parent 52b059fe1e
commit a97e8392a3
3 changed files with 301 additions and 96 deletions

7
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@vue-flow/core": "^1.48.1",
"@vue-flow/node-resizer": "^1.5.0",
"axios": "^1.6.7",
"html-to-image": "^1.11.13",
"lucide-vue-next": "^0.322.0",
"vue": "^3.4.15"
},
@@ -2029,6 +2030,12 @@
"he": "bin/he"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",

View File

@@ -14,6 +14,7 @@
"@vue-flow/core": "^1.48.1",
"@vue-flow/node-resizer": "^1.5.0",
"axios": "^1.6.7",
"html-to-image": "^1.11.13",
"lucide-vue-next": "^0.322.0",
"vue": "^3.4.15"
},

View File

@@ -1,9 +1,31 @@
<script setup lang="ts">
import { ref, reactive, h } from 'vue'
import { Sparkles, Search, Folder, Monitor, BookOpen, Code, Sun, Globe, LogIn, Plus, MousePointer2, Zap, ArrowRight, RefreshCw, Image as ImageIcon } from 'lucide-vue-next'
import { ref, reactive, h, watch, computed } from 'vue'
import {
Sparkles,
Search,
Folder,
Monitor,
BookOpen,
Code,
Sun,
Globe,
LogIn,
Plus,
MousePointer2,
Zap,
ArrowRight,
RefreshCw,
Image as ImageIcon,
Download,
Settings,
Palette,
Grid3X3,
Trash2
} from 'lucide-vue-next'
import { VueFlow, useVueFlow, Position, MarkerType } from '@vue-flow/core'
import { Background, BackgroundVariant } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { toPng } from 'html-to-image'
// 导入 VueFlow 样式
import '@vue-flow/core/dist/style.css'
@@ -13,11 +35,62 @@ import '@vue-flow/core/dist/theme-default.css'
const API_KEY = import.meta.env.VITE_ZHIPU_AI_API_KEY
// VueFlow 实例
const { addNodes, addEdges, onConnect, setNodes, setEdges, nodes: flowNodes, edges: flowEdges, updateNode } = useVueFlow()
const { addNodes, addEdges, onConnect, setNodes, setEdges, nodes: flowNodes, edges: flowEdges, updateNode, fitView } = useVueFlow()
// 状态管理
const ideaInput = ref('')
const isLoading = ref(false)
const hoveredNodeId = ref<string | null>(null)
const focusedNodeId = ref<string | null>(null)
// 计算当前是否有节点处于“活跃”状态(被聚焦、悬停或正在生成)
const activeNodeId = computed(() => {
const expandingNode = flowNodes.value.find(n => n.data.isExpanding)
return expandingNode?.id || focusedNodeId.value || hoveredNodeId.value
})
// 画布配置
const config = reactive({
edgeColor: '#fed7aa',
edgeStyle: 'smoothstep',
backgroundVariant: BackgroundVariant.Lines,
showControls: true
})
// 监听配置变化更新现有连线
watch(
() => config.edgeColor,
newColor => {
setEdges(
flowEdges.value.map(edge => ({
...edge,
style: { ...edge.style, stroke: newColor }
}))
)
}
)
/**
* 导出为图片
*/
const exportImage = async () => {
const el = document.querySelector('.vue-flow') as HTMLElement
if (!el) return
try {
const dataUrl = await toPng(el, {
backgroundColor: '#ffffff',
quality: 1,
pixelRatio: 2
})
const link = document.createElement('a')
link.download = `thinkflow-${Date.now()}.png`
link.href = dataUrl
link.click()
} catch (err) {
console.error('Export failed:', err)
}
}
/**
* 调用智谱AI生成图片
@@ -55,13 +128,18 @@ const generateNodeImage = async (nodeId: string, prompt: string) => {
/**
* 调用智谱AI生成思维发散节点
*/
const expandIdea = async (param?: any) => {
const expandIdea = async (param?: any, customInput?: string) => {
// 判断是点击了节点上的按钮还是主输入框
const parentNode = param && param.id ? param : undefined
const text = parentNode ? parentNode.data.label : ideaInput.value
const text = customInput || (parentNode ? parentNode.data.label : ideaInput.value)
if (!text || isLoading.value) return
if (!text || (parentNode ? parentNode.data.isExpanding : isLoading.value)) return
if (parentNode) {
updateNode(parentNode.id, { data: { ...parentNode.data, isExpanding: true } })
} else {
isLoading.value = true
}
// 如果是第一次生成,清空画布
if (!parentNode) {
@@ -69,9 +147,45 @@ const expandIdea = async (param?: any) => {
setEdges([])
}
const systemPrompt = `你是一个思维发散专家。给定一个想法,请将其发散出 3-5 个更深层或相关维度的子想法
每个子想法包含:简短的名称 (text) 和 极简的描述 (description)。
返回格式必须为 JSON: { "nodes": [ { "text": "...", "description": "..." } ] }`
const systemPrompt = `你是一个思维发散助手,帮助用户将想法逐层展开,构建思维树
工作流程:
1. 用户给出一个初始想法(或选择一个已有节点继续追问)。
2. 你根据当前想法和已有对话历史,生成 3-5 个更深层或相关维度的子想法。
3. 每个子想法包含简短名称和极简描述。
4. 如果用户的问题明显是针对某个已有节点的追问,请结合上下文做针对性发散。
返回格式必须为严格 JSON
{
"nodes": [
{ "text": "子想法1名称", "description": "一句话描述" },
{ "text": "子想法2名称", "description": "一句话描述" }
]
}
示例:
用户:"年夜饭推荐"
你返回:{
"nodes": [
{ "text": "传统年菜", "description": "饺子、鱼、年糕等经典菜谱" },
{ "text": "创新年菜", "description": "融合中西风格的新式年夜饭" },
{ "text": "素食年夜饭", "description": "适合素食者的丰盛菜单" },
{ "text": "快手年夜饭", "description": "省时省力又显丰盛的方案" }
]
}
当用户追问时(例如用户说:"详细说说传统年菜"),你结合上下文发散出更细的子节点:
{
"nodes": [
{ "text": "北方饺子", "description": "多种馅料与蘸料搭配" },
{ "text": "清蒸鱼", "description": "寓意年年有余的做法与选鱼技巧" },
{ "text": "年糕甜品", "description": "不同地区的甜味或咸味年糕" }
]
}
注意:只返回 JSON不附加解释。`
const userMessage = parentNode && customInput ? `核心想法: ${parentNode.data.label}\n用户追问: ${customInput}` : text
try {
const response = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
@@ -84,7 +198,7 @@ const expandIdea = async (param?: any) => {
model: 'glm-4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
{ role: 'user', content: userMessage }
],
response_format: { type: 'json_object' },
temperature: 0.8
@@ -106,7 +220,9 @@ const expandIdea = async (param?: any) => {
id: rootId,
type: 'window',
position: { x: startX, y: startY },
data: { label: text, description: '核心想法', type: 'root' }
data: { label: text, description: '核心想法', type: 'root' },
sourcePosition: Position.Right,
targetPosition: Position.Left
})
// 为后续子节点计算位置
@@ -117,21 +233,27 @@ const expandIdea = async (param?: any) => {
} catch (error) {
console.error('Expansion Error:', error)
} finally {
if (parentNode) {
updateNode(parentNode.id, { data: { ...parentNode.data, isExpanding: false, followUp: '' } })
} else {
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 = 400
const offsetY = (index - (subNodes.length - 1) / 2) * 250
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' }
data: { label: item.text, description: item.description, type: 'child' },
sourcePosition: Position.Right,
targetPosition: Position.Left
})
addEdges({
@@ -139,7 +261,7 @@ const processSubNodes = (subNodes: any[], parentId: string, baseX: number, baseY
source: parentId,
target: childId,
animated: true,
style: { stroke: '#fed7aa', strokeWidth: 2 },
style: { stroke: config.edgeColor, strokeWidth: 2 },
markerEnd: MarkerType.ArrowClosed
})
})
@@ -154,42 +276,57 @@ const startNewSession = () => {
<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-6 py-2 flex items-center justify-between shadow-sm z-50">
<!-- 顶部导航栏 (工具栏) -->
<nav class="flex-none bg-white/80 backdrop-blur-md border-b border-slate-200 px-6 py-3 flex items-center justify-between shadow-sm z-50">
<div class="flex items-center gap-6">
<div class="flex items-center gap-2 mr-4">
<div class="w-3 h-3 bg-orange-500 rounded-sm rotate-45"></div>
<span class="font-black text-slate-900 tracking-tighter text-lg">ThinkFlow</span>
</div>
<div class="h-6 w-[1px] bg-slate-200 mx-2"></div>
<!-- 工具按钮组 -->
<div class="flex items-center gap-2">
<div class="w-2.5 h-2.5 bg-emerald-400 rounded-full animate-pulse"></div>
<span class="text-slate-400">ready</span>
<span class="text-slate-300 mx-1">~/</span>
<span class="font-bold text-slate-900 tracking-tight">ThinkFlow AI</span>
<span class="w-1.5 h-5 bg-orange-200 ml-1"></span>
<!-- 重置画布 -->
<button @click="startNewSession" class="toolbar-btn text-red-500 hover:bg-red-50 border-red-100" title="Reset Canvas">
<Trash2 class="w-4 h-4" />
<span>RESET</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1"></div>
<!-- 连线颜色 -->
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-100">
<Palette class="w-3.5 h-3.5 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">Edge</span>
</div>
<!-- 背景样式 -->
<select v-model="config.backgroundVariant" class="toolbar-select">
<option :value="BackgroundVariant.Lines">LINES</option>
<option :value="BackgroundVariant.Dots">DOTS</option>
</select>
<div class="h-4 w-[1px] bg-slate-100 mx-1"></div>
<!-- 导出图片 -->
<button @click="exportImage" class="toolbar-btn text-emerald-600 hover:bg-emerald-50 border-emerald-100">
<Download class="w-4 h-4" />
<span>EXPORT</span>
</button>
</div>
</div>
<div class="hidden lg:flex items-center gap-2">
<button class="nav-btn group">
<span class="text-orange-500 font-bold">$</span>
<span class="text-slate-600">ai</span>
<span class="text-slate-300 ml-1">--search</span>
</button>
<button class="nav-btn">
<span class="text-emerald-500 font-bold">$</span>
<span class="text-slate-600">cd</span>
<span class="text-slate-300 ml-1">/categories</span>
</button>
<button class="nav-btn">
<span class="text-blue-500 font-bold">$</span>
<span class="text-slate-600">watch</span>
<span class="font-bold ml-1">stats</span>
</button>
<div class="h-4 w-[1px] bg-slate-200 mx-2"></div>
<div class="flex items-center gap-3">
<button class="p-2 hover:bg-slate-100 rounded-md transition-colors text-slate-400 font-bold text-xs flex items-center gap-1">
<Globe class="w-3.5 h-3.5" /> ZH
</button>
<button
class="flex items-center gap-2 px-4 py-1.5 bg-emerald-50 text-emerald-600 rounded-md text-xs font-bold border border-emerald-100 hover:bg-emerald-100 transition-all"
class="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-xl text-xs font-black tracking-widest hover:bg-slate-800 transition-all shadow-lg shadow-slate-200"
>
<span class="text-emerald-500">$</span> 登录 <LogIn class="w-3.5 h-3.5 ml-1" />
SIGN IN <LogIn class="w-3.5 h-3.5 ml-1" />
</button>
</div>
</nav>
@@ -197,20 +334,32 @@ const startNewSession = () => {
<!-- 主内容区VueFlow 画布 -->
<div class="flex-grow relative">
<VueFlow :default-edge-options="{ type: 'smoothstep' }" :fit-view-on-init="true" class="bg-white">
<Background :variant="BackgroundVariant.Lines" pattern-color="#f2f2f2" :gap="24" :size="0.5" />
<Controls />
<Background :variant="config.backgroundVariant" pattern-color="#f2f2f2" :gap="24" :size="0.5" />
<Controls v-if="config.showControls" />
<!-- 自定义节点插槽 -->
<template #node-window="{ id, data }">
<div class="window-node group">
<div
class="window-node group transition-all duration-500"
:class="{
'opacity-20 grayscale-[0.5] blur-[0.5px] scale-[0.98]': activeNodeId && activeNodeId !== id,
'opacity-100 grayscale-0 blur-0 scale-105 z-50': activeNodeId === id
}"
:style="{
borderColor: data.isExpanding || activeNodeId === id ? config.edgeColor : '',
boxShadow: activeNodeId === id ? `0 20px 50px -12px ${config.edgeColor}40` : ''
}"
@mouseenter="hoveredNodeId = id"
@mouseleave="hoveredNodeId = null"
>
<!-- Window Header -->
<div class="window-header">
<div class="window-header" :style="{ backgroundColor: data.isExpanding || activeNodeId === id ? config.edgeColor + '10' : '' }">
<div class="flex gap-1.5">
<div class="w-2 h-2 rounded-full bg-red-400/80"></div>
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: data.isExpanding || activeNodeId === id ? config.edgeColor : '#f87171' }"></div>
<div class="w-2 h-2 rounded-full bg-amber-400/80"></div>
<div class="w-2 h-2 rounded-full bg-emerald-400/80"></div>
</div>
<span class="window-title">
<span class="window-title" :style="{ color: data.isExpanding || activeNodeId === id ? config.edgeColor : '' }">
{{ data.type === 'root' ? 'main.ts' : 'module.tsx' }}
</span>
</div>
@@ -238,7 +387,7 @@ const startNewSession = () => {
</div>
<div class="flex items-center gap-2 mb-2">
<span class="text-orange-500 font-bold">></span>
<span class="font-bold" :style="{ color: config.edgeColor }">></span>
<h3 class="font-black text-slate-900 tracking-tight truncate">{{ data.label }}</h3>
</div>
<p class="text-[10px] text-slate-500 leading-relaxed font-medium line-clamp-3">
@@ -246,13 +395,14 @@ const startNewSession = () => {
</p>
<!-- Node Actions -->
<div class="pt-3 mt-3 border-t border-slate-50 flex items-center justify-between gap-2">
<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 bg-emerald-400 animate-pulse"></div>
<span class="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">active</span>
<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 ? 'Expanding' : 'active' }}</span>
</div>
<div class="flex items-center gap-2 overflow-hidden">
<div class="flex items-center gap-2">
<button
v-if="!data.imageUrl && !data.isImageLoading"
@click.stop="generateNodeImage(id, data.label)"
@@ -261,18 +411,39 @@ const startNewSession = () => {
<ImageIcon class="w-2.5 h-2.5" />
<span>IMG</span>
</button>
</div>
</div>
<button
@click.stop="expandIdea({ id, data, position: flowNodes.find(n => n.id === id)?.position })"
class="action-btn text-orange-500 hover:bg-orange-50"
<!-- 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 : '' }"
>
<ArrowRight class="w-2.5 h-2.5" />
<span>EXPAND</span>
<span class="font-black text-[10px] select-none" :style="{ color: config.edgeColor }">$</span>
<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="Ask a follow-up..."
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>
@@ -288,38 +459,52 @@ const startNewSession = () => {
</div>
<!-- 底部全局操作栏 -->
<div class="fixed bottom-10 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4">
<!-- 核心输入框 -->
<div class="fixed bottom-12 left-1/2 -translate-x-1/2 z-50 flex flex-col items-center gap-4">
<!-- 路径提示 (参考图片风格) -->
<div
class="bg-slate-900 rounded-2xl p-2 pl-6 flex items-center gap-4 shadow-2xl shadow-slate-300 border border-slate-800 w-[600px] group transition-all focus-within:ring-2 focus-within:ring-orange-500/20"
class="flex items-center gap-2 px-4 py-1.5 bg-white/90 backdrop-blur-md border border-slate-200 rounded-lg shadow-sm self-start ml-2 mb-[-8px] z-10 scale-90 origin-bottom-left"
>
<span class="text-orange-500 font-black text-xl select-none">></span>
<span class="text-emerald-500 font-bold">$</span>
<span class="text-slate-400 text-xs font-bold">pwd:</span>
<span class="text-slate-300 text-xs">~ /</span>
<span class="text-slate-600 text-xs font-bold">think-flow</span>
</div>
<div class="flex items-center gap-3">
<!-- 核心输入框容器 -->
<div
class="bg-white rounded-2xl p-1.5 flex items-center gap-3 shadow-2xl shadow-slate-200 border border-slate-200 w-[640px] group transition-all focus-within:ring-4 focus-within:ring-orange-500/5 focus-within:border-orange-200"
>
<div class="flex items-center gap-3 pl-4 flex-grow">
<span class="text-orange-500 font-black text-lg select-none">$</span>
<input
v-model="ideaInput"
@keyup.enter="expandIdea"
placeholder="Input your idea to expand..."
class="bg-transparent border-none outline-none text-white placeholder:text-slate-500 flex-grow font-bold tracking-tight"
placeholder="Type your idea and press Enter..."
class="bg-transparent border-none outline-none text-slate-800 placeholder:text-slate-300 flex-grow font-bold tracking-tight text-sm"
/>
<button
@click="expandIdea"
:disabled="isLoading"
class="p-3 bg-orange-500 hover:bg-orange-600 text-white rounded-xl transition-all active:scale-90 disabled:opacity-50"
>
<Zap v-if="!isLoading" class="w-5 h-5" />
<RefreshCw v-else class="w-5 h-5 animate-spin" />
</button>
</div>
<!-- 清空按钮 -->
<div class="flex items-center gap-1 pr-1">
<button
@click="startNewSession"
class="bg-white text-slate-900 p-4 rounded-2xl shadow-2xl border border-slate-200 hover:bg-slate-50 transition-all active:scale-95 group"
title="Clear Canvas"
@click="expandIdea"
:disabled="isLoading || !ideaInput.trim()"
class="flex items-center gap-2 px-4 py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl transition-all active:scale-95 disabled:opacity-20 disabled:grayscale disabled:cursor-not-allowed group/btn"
>
<Plus class="w-6 h-6 group-hover:rotate-90 transition-transform text-slate-400" />
<span class="text-[10px] font-black tracking-widest uppercase mr-1">Execute</span>
<Zap v-if="!isLoading" class="w-4 h-4 text-orange-400 group-hover/btn:scale-110 transition-transform" />
<RefreshCw v-else class="w-4 h-4 animate-spin" />
</button>
</div>
</div>
<!-- 底部按钮组 (仅保留核心功能) -->
<div class="flex items-center gap-2">
<!-- 这里可以根据需要放一些其他快捷按钮 -->
</div>
</div>
</div>
</div>
</template>
<style>
@@ -335,6 +520,18 @@ body {
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;
}