feat: 添加Markdown渲染支持并增强UI功能

- 引入markdown-it库实现节点内容和摘要的Markdown渲染
- 添加多种边类型选择功能(直线/曲线/折线等)
- 实现全屏模式切换功能
- 优化底部工具栏和节点输入框的样式
- 更新i18n多语言支持新增功能文本
This commit is contained in:
liuziting
2026-01-21 22:43:48 +08:00
parent 843b6d08be
commit 1b9deed3a7
11 changed files with 272 additions and 20 deletions

92
package-lock.json generated
View File

@@ -16,10 +16,12 @@
"axios": "^1.6.7",
"html-to-image": "^1.11.13",
"lucide-vue-next": "^0.322.0",
"markdown-it": "^14.1.0",
"vue": "^3.4.15",
"vue-i18n": "^11.2.8"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.0.3",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
@@ -956,6 +958,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
@@ -1327,6 +1354,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2161,6 +2194,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/lucide-vue-next": {
"version": "0.322.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.322.0.tgz",
@@ -2179,6 +2221,35 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2188,6 +2259,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2528,6 +2605,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2861,6 +2947,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -17,10 +17,12 @@
"axios": "^1.6.7",
"html-to-image": "^1.11.13",
"lucide-vue-next": "^0.322.0",
"markdown-it": "^14.1.0",
"vue": "^3.4.15",
"vue-i18n": "^11.2.8"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.0.3",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",

View File

@@ -11,8 +11,9 @@ import { useI18n } from 'vue-i18n'
// 画布VueFlow 与可选插件
import { VueFlow } from '@vue-flow/core'
import { Background, BackgroundVariant } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
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'
@@ -31,9 +32,39 @@ import WindowNode from './components/WindowNode.vue'
// 业务层:统一的状态与动作入口
import { useThinkFlow } from './composables/useThinkFlow'
import { 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)
})
/**
* 从业务层拿到全局状态与动作。
* 说明:
@@ -102,7 +133,7 @@ const fitToView = () => {
<div class="flex-grow relative">
<VueFlow
:default-edge-options="{ type: 'smoothstep' }"
:default-edge-options="{ type: config.edgeType }"
:fit-view-on-init="true"
class="bg-white"
:class="{ 'space-pressed': isSpacePressed }"
@@ -115,7 +146,11 @@ const fitToView = () => {
:gap="24"
:size="config.backgroundVariant === BackgroundVariant.Dots ? 1 : 0.5"
/>
<Controls v-if="config.showControls" />
<Controls v-if="config.showControls" :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 }">

View File

@@ -39,14 +39,14 @@ const emit = defineEmits<{
<input
:value="props.modelValue"
:placeholder="props.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"
class="flex-grow bg-transparent border-none outline-none text-xs md:text-sm font-bold text-slate-700 placeholder:text-slate-400 min-w-0"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@keyup.enter="emit('expand')"
/>
<button
@click="emit('expand')"
:disabled="props.isLoading || !props.modelValue.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"
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-50 disabled:cursor-not-allowed group/btn flex-shrink-0"
>
<span class="text-[9px] md:text-[10px] font-black tracking-widest uppercase">{{ props.t('nav.execute') }}</span>
<Zap v-if="!props.isLoading" class="w-3.5 h-3.5 md:w-4 h-4 text-orange-400 group-hover/btn:scale-110 transition-transform" />

View File

@@ -9,6 +9,9 @@
// 图标:标题/关闭/加载
import { RefreshCw, Sparkles, X } from 'lucide-vue-next'
// Markdown 渲染
import MarkdownIt from 'markdown-it'
/**
* props
* - show弹窗显示开关
@@ -23,6 +26,12 @@ const props = defineProps<{
summaryContent: string
}>()
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
})
/**
* close关闭弹窗
*/
@@ -59,10 +68,8 @@ const emit = defineEmits<{
<RefreshCw class="w-8 h-8 text-orange-500 animate-spin" />
<p class="text-sm font-bold text-slate-400 animate-pulse">{{ props.t('common.summarizing') }}</p>
</div>
<div v-else class="prose prose-slate max-w-none">
<div class="whitespace-pre-wrap text-slate-600 leading-relaxed text-sm md:text-base font-medium">
{{ props.summaryContent }}
</div>
<div v-else class="markdown-body max-w-none">
<div class="text-slate-600 leading-relaxed text-sm md:text-base font-medium" v-html="md.render(props.summaryContent)"></div>
</div>
</div>

View File

@@ -108,6 +108,14 @@ const callAndClose = (fn: () => void) => {
<span class="text-[9px] md:text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
</div>
<select v-model="props.config.edgeType" class="toolbar-select flex-shrink-0">
<option value="default">{{ props.t('nav.edgeTypes.default') }}</option>
<option value="straight">{{ props.t('nav.edgeTypes.straight') }}</option>
<option value="step">{{ props.t('nav.edgeTypes.step') }}</option>
<option value="smoothstep">{{ props.t('nav.edgeTypes.smoothstep') }}</option>
<option value="simplebezier">{{ props.t('nav.edgeTypes.simplebezier') }}</option>
</select>
<select v-model="props.config.backgroundVariant" class="toolbar-select flex-shrink-0">
<option :value="BackgroundVariant.Lines">{{ props.t('nav.lines') }}</option>
<option :value="BackgroundVariant.Dots">{{ props.t('nav.dots') }}</option>
@@ -204,6 +212,14 @@ const callAndClose = (fn: () => void) => {
<span class="text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
</div>
<select v-model="props.config.edgeType" class="toolbar-select">
<option value="default">{{ props.t('nav.edgeTypes.default') }}</option>
<option value="straight">{{ props.t('nav.edgeTypes.straight') }}</option>
<option value="step">{{ props.t('nav.edgeTypes.step') }}</option>
<option value="smoothstep">{{ props.t('nav.edgeTypes.smoothstep') }}</option>
<option value="simplebezier">{{ props.t('nav.edgeTypes.simplebezier') }}</option>
</select>
<select v-model="props.config.backgroundVariant" class="toolbar-select">
<option :value="BackgroundVariant.Lines">{{ props.t('nav.lines') }}</option>
<option :value="BackgroundVariant.Dots">{{ props.t('nav.dots') }}</option>

View File

@@ -25,6 +25,9 @@ import {
X
} from 'lucide-vue-next'
// Markdown 渲染
import MarkdownIt from 'markdown-it'
/**
* props
* - id/data/selectedVueFlow 提供的节点数据
@@ -47,6 +50,12 @@ const props = defineProps<{
expandIdea: (param?: any, customInput?: string) => void
}>()
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
})
/**
* preview请求 App 打开图片预览弹窗
*/
@@ -201,9 +210,11 @@ const getNodePosition = (id: string) => props.flowNodes.find(n => n.id === id)?.
</div>
<span class="text-[9px] font-black text-slate-300 uppercase tracking-widest animate-pulse">{{ props.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">
{{ props.data.detailedContent }}
</div>
<div
v-else
class="markdown-body text-[11px] text-slate-600 leading-relaxed font-medium max-h-[350px] overflow-y-auto custom-scrollbar pr-2 selection:bg-orange-100 nowheel"
v-html="md.render(props.data.detailedContent)"
></div>
</div>
<div class="relative group/input">
@@ -219,14 +230,14 @@ const getNodePosition = (id: string) => props.flowNodes.find(n => n.id === id)?.
@blur="isFocused = false"
@keyup.enter="props.expandIdea({ id: props.id, data: props.data, position: getNodePosition(props.id) }, props.data.followUp)"
:placeholder="props.t('node.followUp')"
class="bg-transparent border-none outline-none text-[10px] font-bold text-slate-700 flex-grow placeholder:text-slate-300"
class="bg-transparent border-none outline-none text-[10px] font-bold text-slate-700 flex-grow placeholder:text-slate-400"
:disabled="props.data.isExpanding"
/>
<button
@click.stop="props.expandIdea({ id: props.id, data: props.data, position: getNodePosition(props.id) }, props.data.followUp)"
:disabled="!props.data.followUp?.trim() || props.data.isExpanding"
class="transition-all transform active:scale-90"
:style="{ color: props.data.followUp?.trim() ? props.config.edgeColor : '#cbd5e1' }"
:style="{ color: props.data.followUp?.trim() ? props.config.edgeColor : '#94a3b8' }"
>
<RefreshCw v-if="props.data.isExpanding" class="w-3.5 h-3.5 animate-spin" />
<ArrowRight v-else class="w-3.5 h-3.5" />

View File

@@ -232,7 +232,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
const config = reactive({
edgeColor: '#fed7aa',
edgeStyle: 'smoothstep',
edgeType: 'default',
backgroundVariant: BackgroundVariant.Lines,
showControls: true,
showMiniMap: true
@@ -245,12 +245,12 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
* - 通过 lastAppliedStatus 避免无效重复 setEdges
*/
watch(
[() => activeNodeId.value, () => config.edgeColor, () => flowEdges.value.length, () => flowNodes.value.some(n => n.data.isExpanding)],
([, newColor, , anyExpanding]) => {
[() => activeNodeId.value, () => config.edgeColor, () => config.edgeType, () => flowEdges.value.length, () => flowNodes.value.some(n => n.data.isExpanding)],
([, newColor, newType, , anyExpanding]) => {
const { edgeIds } = activePath.value
const edgeIdsStr = Array.from(edgeIds).sort().join(',')
const currentStatus = `${edgeIdsStr}-${newColor}-${anyExpanding}`
const currentStatus = `${edgeIdsStr}-${newColor}-${newType}-${anyExpanding}`
if (lastAppliedStatus.value === currentStatus) return
lastAppliedStatus.value = currentStatus
@@ -261,6 +261,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
return {
...edge,
type: newType,
animated: isHighlighted || isExpanding,
style: {
...edge.style,
@@ -574,6 +575,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
source: parentId,
target: childId,
animated: true,
type: config.edgeType,
style: { stroke: config.edgeColor, strokeWidth: 2 },
markerEnd: MarkerType.ArrowClosed
})

View File

@@ -37,11 +37,20 @@
"layout": "LAYOUT",
"center": "CENTER",
"edge": "EDGE",
"edgeTypes": {
"default": "Bezier",
"straight": "Straight",
"step": "Step",
"smoothstep": "Smooth",
"simplebezier": "Simple"
},
"lines": "LINES",
"dots": "DOTS",
"map": "MAP",
"summary": "SUMMARY",
"export": "EXPORT"
"export": "EXPORT",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit Fullscreen"
},
"settings": {
"title": "API Settings",

View File

@@ -37,11 +37,20 @@
"layout": "布局",
"center": "起点",
"edge": "连线",
"edgeTypes": {
"default": "曲线",
"straight": "直线",
"step": "折线",
"smoothstep": "圆角",
"simplebezier": "平滑"
},
"lines": "网格",
"dots": "点阵",
"map": "地图",
"summary": "总结",
"export": "导出"
"export": "导出",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏"
},
"settings": {
"title": "API 设置",

View File

@@ -29,3 +29,72 @@ body {
width: 100%;
margin: 0 auto;
}
/* Markdown 渲染基础样式 */
.markdown-body {
@apply text-slate-700;
}
.markdown-body h1 {
@apply text-lg font-black mt-4 mb-2 text-slate-900;
}
.markdown-body h2 {
@apply text-base font-black mt-3 mb-2 text-slate-900;
}
.markdown-body h3 {
@apply text-sm font-black mt-2 mb-1 text-slate-800;
}
.markdown-body p {
@apply mb-2 last:mb-0;
}
.markdown-body ul, .markdown-body ol {
@apply pl-4 mb-2 list-outside;
}
.markdown-body ul {
@apply list-disc;
}
.markdown-body ol {
@apply list-decimal;
}
.markdown-body li {
@apply mb-1;
}
.markdown-body code {
@apply px-1 py-0.5 bg-slate-100 rounded text-[0.9em] font-mono text-orange-600;
}
.markdown-body pre {
@apply p-3 bg-slate-900 text-slate-100 rounded-lg overflow-x-auto mb-3 font-mono text-[0.85em];
}
.markdown-body blockquote {
@apply pl-3 border-l-4 border-slate-200 text-slate-500 italic mb-2;
}
.markdown-body a {
@apply text-orange-500 hover:underline;
}
.markdown-body table {
@apply w-full border-collapse mb-3;
}
.markdown-body th, .markdown-body td {
@apply border border-slate-200 p-2 text-left;
}
.markdown-body th {
@apply bg-slate-50 font-bold;
}
.markdown-body hr {
@apply my-4 border-t border-slate-100;
}