Browse Source

feat(frontend): AI 执行链页面新增导出图片功能

- PipelineFlow.vue 增加导出 PNG/SVG 按钮,支持将当前渲染的流程图导出为高清图片
- 同步更新 static/sdk/test.html 构建产物资源引用
TDesign-AI-Chat
wanghanlin 9 hours ago
parent
commit
f0325d8f2d
  1. 136
      frontend/src/views/PipelineFlow.vue
  2. 14
      src/main/resources/static/sdk/test.html

136
frontend/src/views/PipelineFlow.vue

@ -1,11 +1,19 @@
<template>
<t-card :bordered="false">
<template #header>
<span style="font-size:16px;font-weight:600;">🔀 AI 执行链</span>
<p style="font-size:12px;color:var(--td-text-color-placeholder);margin:4px 0 0;">
下图展示从用户请求到 AI 回复的完整处理流程包含意图路由RAG 检索熔断保护和 Advisor
菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 虚线箭头 = 降级/异步路径
</p>
<div class="pipeline-header">
<div>
<span style="font-size:16px;font-weight:600;">🔀 AI 执行链</span>
<p style="font-size:12px;color:var(--td-text-color-placeholder);margin:4px 0 0;">
下图展示从用户请求到 AI 回复的完整处理流程包含意图路由RAG 检索熔断保护和 Advisor
菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 虚线箭头 = 降级/异步路径
</p>
</div>
<t-button variant="outline" size="small" :disabled="!svg" @click="exportImage">
<template #icon><DownloadIcon /></template>
导出图片
</t-button>
</div>
</template>
<!-- 图例 -->
@ -32,6 +40,8 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import mermaid from 'mermaid'
import { palette, paletteBg, paletteBorder } from '@/utils/palette'
@ -161,12 +171,128 @@ function retry() {
render()
}
/**
* 将当前渲染的流程图导出为 PNG 图片
* 实现思路克隆 SVG 序列化为 Blob 绘制到 Canvas 触发下载
* 注意SVG 中若引用跨域字体/图片会污染 Canvas因此强制使用系统字体并捕获异常降级为 SVG 下载
*/
async function exportImage() {
const svgEl = document.querySelector('.pipeline-diagram svg') as SVGSVGElement | null
if (!svgEl) {
MessagePlugin.warning('流程图尚未渲染完成,请稍后再试')
return
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')
let url = ''
try {
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
// 使 SVG Canvas
const systemFont = 'sans-serif, Arial, "Microsoft YaHei", "PingFang SC"'
clonedSvg.style.fontFamily = systemFont
clonedSvg.querySelectorAll('*').forEach(node => {
if (node instanceof SVGElement || node instanceof HTMLElement) {
node.style.fontFamily = systemFont
}
})
// 使 viewBox
const viewBox = svgEl.viewBox?.baseVal
let width = viewBox?.width || 0
let height = viewBox?.height || 0
if (!width || !height) {
width = parseFloat(svgEl.getAttribute('width') || '0')
height = parseFloat(svgEl.getAttribute('height') || '0')
}
if (!width || !height) {
const rect = svgEl.getBoundingClientRect()
width = rect.width
height = rect.height
}
clonedSvg.setAttribute('width', String(width))
clonedSvg.setAttribute('height', String(height))
const serializer = new XMLSerializer()
const svgString = serializer.serializeToString(clonedSvg)
// SVG
const downloadSvg = () => {
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' })
const svgUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = svgUrl
link.download = `ai-执行链-${timestamp}.svg`
link.click()
URL.revokeObjectURL(svgUrl)
MessagePlugin.warning('PNG 导出受限,已降级为 SVG 矢量图下载')
}
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' })
url = URL.createObjectURL(svgBlob)
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const scale = 2
canvas.width = Math.ceil(width * scale)
canvas.height = Math.ceil(height * scale)
const ctx = canvas.getContext('2d')
if (!ctx) {
URL.revokeObjectURL(url)
MessagePlugin.error('Canvas 上下文创建失败')
return
}
// /线
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
URL.revokeObjectURL(url)
const pngUrl = canvas.toDataURL('image/png')
const link = document.createElement('a')
link.href = pngUrl
link.download = `ai-执行链-${timestamp}.png`
link.click()
MessagePlugin.success('图片导出成功')
} catch (e: any) {
URL.revokeObjectURL(url)
console.error('Canvas 导出失败,降级为 SVG:', e)
downloadSvg()
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
console.error('SVG 图片加载失败,降级为 SVG 下载')
downloadSvg()
}
img.src = url
} catch (e: any) {
if (url) URL.revokeObjectURL(url)
console.error('导出图片失败:', e)
MessagePlugin.error(e.message || '导出失败')
}
}
onMounted(() => {
render()
})
</script>
<style scoped>
.pipeline-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.pipeline-legend {
display: flex;
flex-wrap: wrap;

14
src/main/resources/static/sdk/test.html

@ -4,15 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title>
<script type="module" crossorigin src="/assets/sdk-test-DwaiWnIv.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-Cd9KbbY5.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-Bw6jx4vm.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-UsDEYppZ.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-ssL4nTk2.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-Bu6ciAhh.js">
<script type="module" crossorigin src="/assets/sdk-test-BV9sVPfh.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-BEIBDEDo.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-BtnmJ2z8.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-CvvLoh5G.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-Dyjv1-p0.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-DFdkvd4y.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-wRid8JYa.css">
</head>
<body>

Loading…
Cancel
Save