使用vue-element-plus-x完成AI问答对话,markdown展示Echarts展示

使用vue-element-plus-x

中文文档地址vue-element-plus-x (element-plus-x.com/)

ts 复制代码
// 下载
npm install vue-element-plus-x --save
// 中文文档地址

对话组件

这里使用的都是模拟的静态数据,调用SSE接口也可查看element-puls-x文档中的工具useXStream,这里主要是试用,目前所有功能基本ok,后期优化后会更下面的组件

js 复制代码
<template>
	<div class="element_plus_x">
		<div class="empty message_main_container" v-if="messageList.length == 0">
			<Welcome
				icon="https://camo.githubusercontent.com/4ea7fdaabf101c16965c0bd3ead816c9d7726a59b06f0800eb7c9a30212d5a6a/68747470733a2f2f63646e2e656c656d656e742d706c75732d782e636f6d2f656c656d656e742d706c75732d782e706e67"
				title="欢迎使用 Element Plus X 💖"
				description="这是描述信息 ~"
			/>
		</div>
		<div class="message_main_container" ref="reasoningRef" v-else>
			<!-- <div class="message_main_container" ref="reasoningRef"> -->
			<BubbleList :list="messageList" max-height="100%">
				<!-- 自定义气泡内容 -->
				<template #content="{ item }">
					<XMarkdown :markdown="item.content" :code-x-render="selfCodeXRender" />
				</template>
			</BubbleList>
		</div>
		<Sender
			ref="senderRef"
			v-model="senderValue"
			:loading="senderLoading"
			:auto-size="{ minRows: 3, maxRows: 5 }"
			clearable
			@submit="handleSubmit"
			@cancel="handleCancel"
			class="ai_dialog_sender"
			:inputStyle="inputStyle"
		/>
	</div>
</template>

<script setup lang="ts">
import { toRefs, reactive, ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Top, Search, Paperclip, Refresh } from '@element-plus/icons-vue';
import { BubbleList, Sender, EditorSender, XMarkdown, Typewriter, Bubble, Welcome, FilesCard } from 'vue-element-plus-x';
// npm install vue-element-plus-x --save
import type { BubbleListItemProps, BubbleListProps } from 'vue-element-plus-x/types/BubbleList';
import AiItem from './iaItem.vue';
interface IProps {
	aiDialogVisible: boolean;
}
const searchFormRef = ref();
const emits = defineEmits(['updateModelValue']);
const props = withDefaults(defineProps<IProps>(), {
	aiDialogVisible: false,
});
interface IMessage {
	role: 'user' | 'assistant';
	content: string;
	content1?: string;
	isStreaming?: boolean;
	isError?: boolean;
}
const state = reactive({
	messages: [] as IMessage[],
	inputText: '',
	checked1: false,
	checked2: false,
	isStreaming: false,
	isFullscreen: false, // 是否全屏
});
const { messages, inputText, checked1, checked2, isStreaming, isFullscreen } = toRefs(state);
const closeHandle = () => {
	emits('updateModelValue', false);
};

// 全屏切换
const fullscreenHandle = () => {
	isFullscreen.value = !isFullscreen.value;
};

type listType = BubbleListItemProps & {
	key: number;
	role: 'user' | 'ai';
};
// 示例调用
const messageList: BubbleListProps<listType>['list'] = ref([]);

function generateFakeItems(count: number): listType[] {
	const messages: listType[] = [];
	for (let i = 0; i < count; i++) {
		const role = i % 2 === 0 ? 'ai' : 'user';
		const placement = role === 'ai' ? 'start' : 'end';
		const key = i + 1;
		const content = role === 'ai' ? '💖 感谢使用 Element Plus X ! 你的支持,是我们开源的最强动力 ~'.repeat(5) : `哈哈哈,让我试试`;
		const loading = false;
		const shape = 'corner';
		const variant = role === 'ai' ? 'filled' : 'outlined';
		// const variant = role === 'ai' ? 'filled' : 'filled';
		const isMarkdown = false;
		const typing = role === 'ai' ? i === count - 1 : false;
		const avatar =
			role === 'ai' ? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png' : 'https://avatars.githubusercontent.com/u/76239030?v=4';
		messages.push({
			key, // 唯一标识
			role, // user | ai 自行更据模型定义
			placement, // start | end 气泡位置
			content, // 消息内容 流式接受的时候,只需要改这个值即可
			loading, // 当前气泡的加载状态
			shape, // 气泡的形状
			variant, // 气泡的样式
			isMarkdown, // 是否渲染为 markdown
			typing, // 是否开启打字器效果 该属性不会和流式接受冲突
			isFog: role === 'ai', // 是否开启打字雾化效果,该效果 v1.1.6 新增,且在 typing 为 true 时生效,该效果会覆盖 typing 的 suffix 属性
			avatar,
			avatarSize: '24px', // 头像占位大小
			avatarGap: '12px', // 头像与气泡之间的距离
		});
	}
	messages.push({
		key: messages.length + 1,
		role: 'user',
		placement: 'start',
		content: markdown2,
		loading: false,
		shape: 'corner',
		variant: 'filled',
		isMarkdown: false,
		typing: false,
		isFog: false,
		avatar: 'https://avatars.githubusercontent.com/u/76239030?v=4',
		avatarSize: '24px',
		avatarGap: '12px',
	});
	messages.push({
		key: messages.length + 1,
		role: 'user',
		placement: 'start',
		content: markdown,
		loading: false,
		shape: 'corner',
		variant: 'filled',
		isMarkdown: false,
		typing: false,
		isFog: false,
		avatar: 'https://avatars.githubusercontent.com/u/76239030?v=4',
		avatarSize: '24px',
		avatarGap: '12px',
	});
	return messages;
}

const senderRef = ref();
const timeValue = ref<NodeJS.Timeout | null>(null);
const senderValue = ref('');
const senderLoading = ref(false);
const isSelect = ref(false);
const submitBtnDisabled = ref(true);
function handleSubmit(value: string) {
	ElMessage.info(`发送中`);
	senderLoading.value = true;
	messageList.value = generateFakeItems(10);
	timeValue.value = setTimeout(() => {
		// 可以在控制台 查看打印结果
		console.log('submit-> value:', value);
		console.log('submit-> senderValue', senderValue.value);
		senderLoading.value = false;
		ElMessage.success(`发送成功`);
	}, 3500);
}

function handleCancel() {
	senderLoading.value = false;
	if (timeValue.value) clearTimeout(timeValue.value);
	timeValue.value = null;
	ElMessage.info(`取消发送`);
}
import { h } from 'vue';
import Echarts from './echarts.vue';
const markdown2 = `
# Hello\n## Hi\n> hello world
\`\`\`javascript
const a = 1;
import Echarts from './echarts.vue';
\`\`\`
\`\`\`echarts
option = {
  xAxis: {
    type: 'category',
    data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
  },
  yAxis: {
    type: 'value'
  },
  series: [
    {
      data: [150, 230, 224, 218, 135, 147, 260],
      type: 'line'
    }
  ]
};
\`\`\`
`;
const markdown = `
\`\`\`html
<div class="product-card">
  <div class="badge">新品</div>
  <img src="https://picsum.photos/300/200?product" alt="产品图片">

  <div class="content">
    <h3>无线蓝牙耳机 Pro</h3>
    <p class="description">主动降噪技术,30小时续航,IPX5防水等级</p>

    <div class="rating">
      <span>★★★★☆</span>
      <span class="reviews">(124条评价)</span>
    </div>

    <div class="price-container">
      <span class="price">¥499</span>
      <span class="original-price">¥699</span>
      <span class="discount">7折</span>
    </div>

    <div class="actions">
      <button class="cart-btn" onclick="addToCart(this)">加入购物车</button>
      <button class="fav-btn">❤️</button>
    </div>

    <div class="meta">
      <span>✓ 次日达</span>
      <span>✓ 7天无理由</span>
    </div>
  </div>
</div>

\<script\>
  function addToCart(button) {
    // 防止重复点击
    if (button.disabled) return;
    
    // 禁用按钮
    button.disabled = true;
    button.innerHTML = '加入中...';
    
    // 模拟添加到购物车的API请求
    setTimeout(() => {
      // 显示成功消息
      button.innerHTML = '✓ 已加入';
      button.classList.add('success');
      
      // 3秒后恢复按钮状态
      setTimeout(() => {
        button.innerHTML = '加入购物车';
        button.disabled = false;
        button.classList.remove('success');
      }, 3000);
    }, 800);
  };
\<\/script\>

<style>
  .product-card {
  width: 280px;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  position: relative;
  background: white;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.badge {
  position: absolute;
  top: 12px;
  left: 12px;
  background: #ff6b6b;
  color: white;
  padding: 4px 10px;
  border-radius: 4px;
  font-weight: bold;
  font-size: 12px;
  z-index: 2;
}

img {
  width: 100%;
  height: 180px;
  object-fit: cover;
  display: block;
}

.content {
  padding: 16px;
}

h3 {
  margin: 8px 0;
  font-size: 18px;
  color: #333;
}

.description {
  color: #666;
  font-size: 14px;
  margin: 8px 0 12px;
  line-height: 1.4;
}

.rating {
  display: flex;
  align-items: center;
  margin: 10px 0;
  color: #ffb300;
}

.reviews {
  font-size: 13px;
  color: #888;
  margin-left: 8px;
}

.price-container {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 12px 0;
}

.price {
  font-size: 22px;
  font-weight: bold;
  color: #ff4757;
}

.original-price {
  font-size: 14px;
  color: #999;
  text-decoration: line-through;
}

.discount {
  background: #fff200;
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 12px;
}

.actions {
  display: flex;
  gap: 8px;
  margin: 16px 0 12px;
}

.cart-btn {
  flex: 1;
  background: #5352ed;
  color: white;
  border: none;
  padding: 10px;
  border-radius: 6px;
  font-weight: bold;
  cursor: pointer;
  transition: background 0.2s;
}

.cart-btn:hover {
  background: #3742fa;
}

.cart-btn.disabled {
  background: #a4b0be;
  cursor: not-allowed;
}

.cart-btn.success {
  background: #2ed573;
}

.fav-btn {
  width: 42px;
  background: white;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 18px;
  cursor: pointer;
  transition: all 0.2s;
}

.fav-btn:hover {
  border-color: #ff6b6b;
  color: #ff6b6b;
}

.meta {
  display: flex;
  gap: 15px;
  font-size: 13px;
  color: #2ed573;
  margin-top: 8px;
}
</style>
\`\`\`
`;
const selfCodeXRender = {
	// 渲染自定义代码块标识符 javascript, 返回一个组件
	javascript: (props: { raw: any }) => {
		return h('pre', { class: 'language-javascript' }, h('code', { class: 'language-javascript' }, props.raw.content));
	},
	// 渲染自定义代码块标识符 echarts, Echarts 是自己封装的Vue组件
	echarts: (props: { raw: any }) => h(Echarts, { code: props.raw.content, width: '500px' }),
};

const inputStyle = {
	color: '#fff !important',
};
</script>
<style lang="scss" scoped>
.element_plus_x {
	width: 100%;
	height: 100%;
	display: flex;
	flex-direction: column;
	.message_main_container {
		width: 100%;
		flex: 1;
		height: 0;
		margin: 20px auto;
		// padding: 0 5%;
		padding: 0 20px;
	}
	.ai_dialog_sender {
		margin-bottom: 10px;
	}
}
</style>
  • Echarts组件
js 复制代码
<!-- echarts.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
import * as echarts from 'echarts';

// 保留原有props.code逻辑,同时添加可选配置
const props = defineProps<{
	code: string; // 原始JSON字符串配置
	width?: string; // 可选:图表宽度
	height?: string; // 可选:图表高度
	theme?: string; // 可选:图表主题
}>();

const refEle = ref<HTMLElement>();
let myChart: echarts.ECharts | null = null; // 图表实例引用

function parseEChartsOption(str: string): any {
	try {
		let cleanedStr = str.replace(/^option\s*=\s*/, '').replace(/;\s*$/, '');
		cleanedStr = cleanedStr.replace(/'/g, '"');
		cleanedStr = cleanedStr.replace(/(\w+)\s*:/g, '"$1":');
		return JSON.parse(cleanedStr);
	} catch (error) {
		console.error('Failed to parse ECharts option:', error);
		return null;
	}
}

// 核心渲染逻辑(保留原始解析流程)
function renderChart() {
	if (!refEle.value) return;

	try {
		// 解析JSON配置(保留原有逻辑)
		const cleanedStr = parseEChartsOption(props.code);

		// 初始化/更新图表
		if (!myChart) {
			myChart = echarts.init(refEle.value, props.theme);
		}
		myChart.setOption(cleanedStr);
	} catch (error) {
		console.error('图表配置解析失败:', error);
	}
}

// 窗口resize处理
function handleResize() {
	myChart?.resize();
}

// 销毁逻辑
function destroyChart() {
	if (myChart) {
		myChart.dispose(); // 释放ECharts实例
		myChart = null;
	}
	window.removeEventListener('resize', handleResize);
}

// 初始化渲染
onMounted(() => {
	renderChart();
	window.addEventListener('resize', handleResize); // 添加resize监听
});

// 监听code变化自动更新(关键优化)
watch(
	() => props.code,
	() => {
		renderChart(); // 配置变化时重新渲染
	}
);

// 卸载时清理资源
onUnmounted(() => {
	destroyChart();
});
</script>

<template>
	<div class="echarts-wrap">
		<div
			ref="refEle"
			:style="{
				height: height || '400px', // 可选高度,默认400px
				width: width || '100%', // 可选宽度,默认100%
			}"
		/>
	</div>
</template>

<style scoped lang="scss">
.echarts-wrap {
	position: relative;
	color: #fff;
	.echarts-titlt {
		position: absolute;
		width: fit-content;
		margin-left: 20px;
		color: #fff;
		font-size: 20px;
		font-weight: bold;
	}
}
</style>
  • 引入使用
js 复制代码
<template>
    <ElementPlusX />
</template>
import ElementPlusX from './elementPlusX.vue';
相关推荐
鹏多多3 小时前
关于React父组件调用子组件方法forwardRef的详解和案例
前端·javascript·react.js
吃饺子不吃馅3 小时前
AntV X6 核心插件帮你飞速创建画布
前端·css·svg
Ares-Wang3 小时前
Vue2 》》Vue3》》 Render函数 h
javascript
葡萄城技术团队3 小时前
SpreadJS 纯前端表格控件:破解中国式复杂报表技术文档
前端
Humbunklung3 小时前
C# 压缩解压文件的常用方法
前端·c#·压缩解压
通往曙光的路上3 小时前
时隔一天第二阶段他来了 html!!!!!!!!!!!
前端·html
爱吃甜品的糯米团子4 小时前
CSS图片背景属性
前端·css
雮尘4 小时前
一文读懂Android Fragment栈管理
android·前端
Aoda4 小时前
浏览器字体设置引发的Bug:从一次调查到前端字体策略的深度思考
前端·css