e0a5f57793
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
591 lines
12 KiB
Markdown
591 lines
12 KiB
Markdown
# ECharts 集成
|
||
|
||
`markstream-vue` 支持通过自定义组件渲染 ECharts 图表。本文介绍三种推荐方案,从最简单到最高级。
|
||
|
||
## 为什么在 markstream-vue 中使用 ECharts?
|
||
|
||
ECharts 是一个强大的图表库,与 `markstream-vue` 的流式架构天然契合。你可以:
|
||
|
||
- 直接在 Markdown 代码块中编写图表配置
|
||
- 流式更新图表(适合 AI 生成的内容)
|
||
- 复用现有的 ECharts 主题和配置
|
||
- 导出图表为图片
|
||
|
||
---
|
||
|
||
## 方案一:自定义 CodeBlock 组件(推荐)
|
||
|
||
此方案将 ECharts 作为一种特殊的代码块语言,类似于 Mermaid 的处理方式。这是对用户最友好的方式,也符合现有架构模式。
|
||
|
||
### 1.1 创建 ECharts 组件
|
||
|
||
创建 `EChartsBlockNode.vue`:
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import * as echarts from 'echarts'
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
|
||
interface Props {
|
||
node: {
|
||
type: 'code_block'
|
||
language: string
|
||
code: string
|
||
loading?: boolean
|
||
}
|
||
isDark?: boolean
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
// 只渲染 echarts 代码块
|
||
const isECharts = computed(() => props.node.language === 'echarts' || props.node.language === 'chart')
|
||
|
||
const chartRef = ref<HTMLElement>()
|
||
let chartInstance: echarts.ECharts | null = null
|
||
|
||
async function initChart() {
|
||
if (!isECharts.value || !chartRef.value)
|
||
return
|
||
|
||
// 销毁已存在的实例
|
||
if (chartInstance) {
|
||
chartInstance.dispose()
|
||
}
|
||
|
||
// 根据暗黑模式初始化主题
|
||
const theme = props.isDark ? 'dark' : undefined
|
||
chartInstance = echarts.init(chartRef.value, theme)
|
||
|
||
try {
|
||
// 从代码中解析 JSON 配置
|
||
const option = JSON.parse(props.node.code)
|
||
chartInstance.setOption(option, true)
|
||
}
|
||
catch (error) {
|
||
console.error('[ECharts] 配置无效:', error)
|
||
chartInstance?.dispose()
|
||
chartInstance = null
|
||
}
|
||
}
|
||
|
||
// 主题切换时重新初始化
|
||
watch(() => props.isDark, () => {
|
||
initChart()
|
||
})
|
||
|
||
// 代码变化时更新图表
|
||
watch(() => props.node.code, () => {
|
||
if (chartInstance) {
|
||
try {
|
||
const option = JSON.parse(props.node.code)
|
||
chartInstance.setOption(option, true)
|
||
}
|
||
catch (error) {
|
||
console.error('[ECharts] 配置无效:', error)
|
||
}
|
||
}
|
||
})
|
||
|
||
onMounted(initChart)
|
||
|
||
onBeforeUnmount(() => {
|
||
chartInstance?.dispose()
|
||
chartInstance = null
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="isECharts" ref="chartRef" class="echarts-container" style="width: 100%; height: 400px" />
|
||
<slot v-else />
|
||
</template>
|
||
|
||
<style scoped>
|
||
.echarts-container {
|
||
margin: 1rem 0;
|
||
border-radius: 0.5rem;
|
||
overflow: hidden;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
### 1.2 注册自定义组件
|
||
|
||
```ts
|
||
// main.ts 或组件入口文件
|
||
import { setCustomComponents } from 'markstream-vue'
|
||
import EChartsBlockNode from './components/EChartsBlockNode.vue'
|
||
|
||
setCustomComponents({
|
||
code_block: EChartsBlockNode
|
||
})
|
||
```
|
||
|
||
### 1.3 在 Markdown 中使用
|
||
|
||
现在可以直接在 Markdown 中编写 ECharts 图表:
|
||
|
||
```markdown
|
||
\```echarts
|
||
{
|
||
"title": { "text": "销售数据" },
|
||
"tooltip": {},
|
||
"xAxis": {
|
||
"type": "category",
|
||
"data": ["周一", "周二", "周三", "周四", "周五"]
|
||
},
|
||
"yAxis": { "type": "value" },
|
||
"series": [{
|
||
"type": "bar",
|
||
"data": [120, 200, 150, 80, 70]
|
||
}]
|
||
}
|
||
\```
|
||
```
|
||
|
||
### 1.4 安装 ECharts
|
||
|
||
```bash
|
||
pnpm add echarts
|
||
# 或
|
||
npm install echarts
|
||
```
|
||
|
||
### 优缺点
|
||
|
||
**优点:**
|
||
- 熟悉的代码块语法
|
||
- 遵循现有的 Mermaid 模式
|
||
- 支持流式更新
|
||
- 易于实现
|
||
|
||
**缺点:**
|
||
- 会覆盖所有 `code_block` 的渲染(需要处理非 echarts 代码块)
|
||
- 需要 JSON 解析(不支持 JS 表达式)
|
||
|
||
---
|
||
|
||
## 方案二:自定义 HTML 标签
|
||
|
||
使用自定义 HTML 类标签来渲染 ECharts 图表,与常规代码块分离更清晰。
|
||
|
||
### 2.1 配置自定义 HTML 标签
|
||
|
||
```ts
|
||
// 解析器配置(或在 MarkdownRender 上直接传 `custom-html-tags`)
|
||
import { getMarkdown } from 'markstream-vue'
|
||
|
||
const md = getMarkdown('echarts', {
|
||
customHtmlTags: ['echarts', 'chart'],
|
||
})
|
||
```
|
||
|
||
### 2.2 注册组件
|
||
|
||
```ts
|
||
import { setCustomComponents } from 'markstream-vue'
|
||
import EChartsComponent from './components/EChartsComponent.vue'
|
||
|
||
setCustomComponents({
|
||
echarts: EChartsComponent,
|
||
chart: EChartsComponent
|
||
})
|
||
```
|
||
|
||
### 2.3 创建组件
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import * as echarts from 'echarts'
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
|
||
interface Props {
|
||
node: {
|
||
type: string
|
||
tag: string
|
||
content: string
|
||
attrs?: Record<string, string>
|
||
}
|
||
isDark?: boolean
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
const chartRef = ref<HTMLElement>()
|
||
let chartInstance: echarts.ECharts | null = null
|
||
|
||
// 从属性中解析宽度/高度
|
||
const width = computed(() => props.node.attrs?.width || '100%')
|
||
const height = computed(() => props.node.attrs?.height || '400px')
|
||
|
||
async function initChart() {
|
||
if (!chartRef.value)
|
||
return
|
||
|
||
if (chartInstance) {
|
||
chartInstance.dispose()
|
||
}
|
||
|
||
const theme = props.isDark ? 'dark' : undefined
|
||
chartInstance = echarts.init(chartRef.value, theme)
|
||
|
||
try {
|
||
const option = JSON.parse(props.node.content)
|
||
chartInstance.setOption(option, true)
|
||
}
|
||
catch (error) {
|
||
console.error('[ECharts] 配置无效:', error)
|
||
}
|
||
}
|
||
|
||
watch(() => props.isDark, initChart)
|
||
watch(() => props.node.content, () => {
|
||
if (chartInstance) {
|
||
try {
|
||
const option = JSON.parse(props.node.content)
|
||
chartInstance.setOption(option, true)
|
||
}
|
||
catch (error) {
|
||
console.error('[ECharts] 配置无效:', error)
|
||
}
|
||
}
|
||
})
|
||
|
||
onMounted(initChart)
|
||
onBeforeUnmount(() => {
|
||
chartInstance?.dispose()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="chartRef" class="echarts-chart" :style="{ width, height }" />
|
||
</template>
|
||
```
|
||
|
||
### 2.4 在 Markdown 中使用
|
||
|
||
```markdown
|
||
<echarts width="100%" height="500px">
|
||
{
|
||
"series": [{
|
||
"type": "pie",
|
||
"data": [
|
||
{ "value": 335, "name": "直接访问" },
|
||
{ "value": 310, "name": "邮件营销" },
|
||
{ "value": 234, "name": "联盟广告" }
|
||
]
|
||
}]
|
||
}
|
||
</echarts>
|
||
```
|
||
|
||
### 优缺点
|
||
|
||
**优点:**
|
||
- 类型分离清晰
|
||
- 支持自定义属性(宽度、高度)
|
||
- 不干扰代码块
|
||
|
||
**缺点:**
|
||
- 需要 parser 配置
|
||
- 语法不够熟悉
|
||
- 不是标准 Markdown
|
||
|
||
---
|
||
|
||
## 方案三:VmrContainer 集成
|
||
|
||
在自定义容器中使用 ECharts(类似 admonitions),适合带图表说明的文档。
|
||
|
||
### 3.1 注册容器组件
|
||
|
||
```ts
|
||
import { setCustomComponents } from 'markstream-vue'
|
||
import EChartsContainerNode from './components/EChartsContainerNode.vue'
|
||
|
||
setCustomComponents({
|
||
vmr_container: EChartsContainerNode
|
||
})
|
||
```
|
||
|
||
### 3.2 创建容器组件
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import * as echarts from 'echarts'
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
|
||
interface Props {
|
||
node: {
|
||
type: 'vmr_container'
|
||
name: string
|
||
children?: Array<{ type: string, raw: string }>
|
||
}
|
||
isDark?: boolean
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
// 只处理 echarts 容器
|
||
const isEChartsContainer = computed(() => props.node.name === 'echarts')
|
||
|
||
const chartRef = ref<HTMLElement>()
|
||
let chartInstance: echarts.ECharts | null = null
|
||
|
||
// 从子节点提取 JSON
|
||
const chartOption = computed(() => {
|
||
if (!props.node.children || props.node.children.length === 0) {
|
||
return null
|
||
}
|
||
const code = props.node.children[0].raw
|
||
try {
|
||
return JSON.parse(code)
|
||
}
|
||
catch {
|
||
return null
|
||
}
|
||
})
|
||
|
||
async function initChart() {
|
||
if (!isEChartsContainer.value || !chartRef.value || !chartOption.value)
|
||
return
|
||
|
||
if (chartInstance) {
|
||
chartInstance.dispose()
|
||
}
|
||
|
||
const theme = props.isDark ? 'dark' : undefined
|
||
chartInstance = echarts.init(chartRef.value, theme)
|
||
chartInstance.setOption(chartOption.value, true)
|
||
}
|
||
|
||
watch(() => props.isDark, initChart)
|
||
watch(chartOption, (option) => {
|
||
if (chartInstance && option) {
|
||
chartInstance.setOption(option, true)
|
||
}
|
||
})
|
||
|
||
onMounted(initChart)
|
||
onBeforeUnmount(() => {
|
||
chartInstance?.dispose()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="isEChartsContainer" class="vmr-container vmr-container-echarts">
|
||
<div ref="chartRef" style="width: 100%; height: 400px" />
|
||
<slot v-if="!chartOption" />
|
||
</div>
|
||
<div v-else class="vmr-container" :class="`vmr-container-${node.name}`">
|
||
<slot />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.vmr-container-echarts {
|
||
padding: 1rem;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 0.5rem;
|
||
margin: 1rem 0;
|
||
}
|
||
|
||
.dark .vmr-container-echarts {
|
||
border-color: #374151;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
### 3.3 在 Markdown 中使用
|
||
|
||
```markdown
|
||
:::echarts
|
||
{
|
||
"title": { "text": "流量来源" },
|
||
"series": [{
|
||
"type": "pie",
|
||
"radius": "50%",
|
||
"data": [
|
||
{ "value": 1048, "name": "搜索引擎" },
|
||
{ "value": 735, "name": "直接访问" },
|
||
{ "value": 580, "name": "邮件营销" }
|
||
]
|
||
}]
|
||
}
|
||
:::
|
||
```
|
||
|
||
### 优缺点
|
||
|
||
**优点:**
|
||
- 与现有容器系统一致
|
||
- 支持图表周围添加额外内容
|
||
- 适合文档场景
|
||
|
||
**缺点:**
|
||
- 配置较复杂
|
||
- 需要解析子节点
|
||
|
||
---
|
||
|
||
## SSR 注意事项
|
||
|
||
ECharts 需要 DOM 环境,不支持服务端渲染。请使用以下策略之一:
|
||
|
||
### Nuxt 3 中使用
|
||
|
||
```vue
|
||
<template>
|
||
<ClientOnly>
|
||
<MarkdownRender :content="markdown" />
|
||
</ClientOnly>
|
||
</template>
|
||
```
|
||
|
||
### SSR 带降级
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import MarkdownRender from 'markstream-vue'
|
||
import { onMounted, ref } from 'vue'
|
||
|
||
const isClient = ref(false)
|
||
const markdown = '# ECharts 示例'
|
||
|
||
onMounted(() => {
|
||
isClient.value = true
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<MarkdownRender v-if="isClient" :content="markdown" />
|
||
<div v-else class="echarts-placeholder">
|
||
加载图表中...
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
### 组件内检查 window
|
||
|
||
```ts
|
||
import { computed } from 'vue'
|
||
|
||
const canRender = computed(() => typeof window !== 'undefined' && isECharts.value)
|
||
```
|
||
|
||
---
|
||
|
||
## 高级功能
|
||
|
||
### 使用 ResizeObserver 实现响应式图表
|
||
|
||
```ts
|
||
import { useResizeObserver } from '@vueuse/core'
|
||
|
||
useResizeObserver(chartRef, (entries) => {
|
||
if (chartInstance && entries[0]) {
|
||
const { width, height } = entries[0].contentRect
|
||
if (width > 0 && height > 0) {
|
||
chartInstance.resize()
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### 导出为图片
|
||
|
||
```ts
|
||
function exportChart() {
|
||
if (!chartInstance)
|
||
return
|
||
const url = chartInstance.getDataURL({
|
||
type: 'png',
|
||
pixelRatio: 2,
|
||
backgroundColor: props.isDark ? '#1a1a1a' : '#fff'
|
||
})
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = 'chart.png'
|
||
link.click()
|
||
}
|
||
```
|
||
|
||
### 使用 ECharts 主题切换
|
||
|
||
```ts
|
||
import dark from 'echarts/theme/dark'
|
||
import { computed } from 'vue'
|
||
|
||
const theme = computed(() => props.isDark ? 'dark' : undefined)
|
||
chartInstance = echarts.init(chartRef.value, theme.value)
|
||
```
|
||
|
||
---
|
||
|
||
## 应该选择哪种方案?
|
||
|
||
| 方案 | 适用场景 | 复杂度 |
|
||
|------|----------|--------|
|
||
| **CodeBlock** | 大多数用户,简单集成 | ⭐ |
|
||
| **自定义标签** | 高级用户,多种图表类型 | ⭐⭐ |
|
||
| **VmrContainer** | 文档场景,带样式容器 | ⭐⭐⭐ |
|
||
|
||
**推荐:** 从 **方案一(CodeBlock)** 开始。这是最直接的方式,也符合用户与 Mermaid 图表交互的方式。
|
||
|
||
---
|
||
|
||
## 完整示例
|
||
|
||
使用方案一的完整最小设置:
|
||
|
||
```ts
|
||
import MarkdownRender, { setCustomComponents } from 'markstream-vue'
|
||
// main.ts
|
||
import { createApp } from 'vue'
|
||
import EChartsBlockNode from './EChartsBlockNode.vue'
|
||
import 'markstream-vue/index.css'
|
||
import 'echarts'
|
||
|
||
// 注册自定义组件
|
||
setCustomComponents({
|
||
code_block: EChartsBlockNode
|
||
})
|
||
|
||
const app = createApp({
|
||
components: { MarkdownRender },
|
||
template: `
|
||
<MarkdownRender :content="markdown" />
|
||
`,
|
||
data() {
|
||
return {
|
||
markdown: `<!-- eslint-disable -->
|
||
\`\`\`echarts
|
||
{
|
||
"xAxis": { "type": "category", "data": ["A", "B", "C"] },
|
||
"yAxis": { "type": "value" },
|
||
"series": [{ "type": "bar", "data": [10, 20, 30] }]
|
||
}
|
||
\`\`\``
|
||
}
|
||
}
|
||
})
|
||
|
||
app.mount('#app')
|
||
```
|
||
|
||
---
|
||
|
||
## 故障排除
|
||
|
||
1. **图表未渲染** — 检查浏览器控制台的 JSON 解析错误。确保代码块内容是有效的 JSON。
|
||
|
||
2. **容器空白** — 验证 ECharts 已安装且 DOM 元素有尺寸。设置明确的宽高样式。
|
||
|
||
3. **主题未切换** — 切换主题时需要重新创建 ECharts 实例。使用 `watch(() => props.isDark)` 来销毁并重新初始化。
|
||
|
||
4. **SSR 错误** — 在 Nuxt 中使用 `<ClientOnly>` 包裹组件,或在初始化前检查 `typeof window !== 'undefined'`。
|
||
|
||
5. **内存泄漏** — 始终在 `onBeforeUnmount` 中调用 `chartInstance.dispose()`。
|
||
|
||
仍有问题?请查阅 [ECharts 官方文档](https://echarts.apache.org/zh/index.html)或在 GitHub 上提 issue。
|