组件整体结构
该示例采用“页面编排 + Pinia 状态 + UI 组件”的结构:
pages/index/index.vue ├── YmBubble 消息气泡 │ ├── MarkDown Markdown、思考过程、引用资源 │ ├── YmTypewriter 逐字符输出 │ └── FileCard 消息内附件 ├── YmSender 输入框、发送、取消、联网、附件入口 └── YmAttachments 待发送附件管理 store/modules/chat.ts 消息和附件状态1. 消息数据模型和状态管理
每条消息包含内容、左右位置、附件、Markdown 标记、资源引用和错误状态。
export interface ChatMessage { content: string position: 'left' | 'right' files: any[] isMarkdown: boolean resources: any[] isError: boolean loading?: boolean } export interface ChatState { messages: ChatMessage[] chatUUID: string | null isSending: boolean attachmentFiles: any[] }用户消息和 AI 消息分别通过 Store Action 创建:
addUserMessage(content: string, files: any[] = []) { this.messages.push({ content, position: 'right', files, isMarkdown: false, resources: [], isError: false }) } addAiMessage( content: string = '', isMarkdown: boolean = true, resources: any[] = [] ) { this.messages.push({ content, position: 'left', files: [], isMarkdown, resources, loading: true, isError: false }) } updateLastMessage(content: string) { const last = this.messages[this.messages.length - 1] if (last) last.content += content }AI 流式输出时,只需要不断调用:
chatStore.updateLastMessage(chunk)2. 消息气泡YmBubble
核心职责
- 根据
position区分用户和 AI 消息。 - 根据内容状态显示加载动画、普通文本、Markdown 或打字机效果。
- 在消息中展示文件附件。
- 通过插槽扩展底部操作,例如“复制原文”。
模板核心实现
<view class="ym-bubble" :class="bubbleClasses"> <view class="ym-bubble-content"> <slot name="header"></slot> <view v-if="isTextMessage" class="ym-bubble__text"> <view v-if="files?.length" class="ym-bubble__files"> <FileCard v-for="(file, index) in files" :key="file.id || index" :file="file" :show-remove="false" /> </view> <YmTypewriter v-if="typing" :text="message" :speed="typingSpeed" mode="cursor" /> <MarkDown v-else-if="isMarkdown" :content="message" :resources="resources" /> <view v-else :class="{ 'ym-bubble__error': isError }"> {{ message }} </view> </view> <view v-else-if="isLoadingMessage" class="ym-bubble__loading"> <view v-for="i in 3" :key="i" class="ym-bubble__loading-dot" /> </view> </view> <slot name="footer" v-if="!isLoadingMessage"></slot> </view>核心计算属性
const isLoadingMessage = computed(() => props.message === 'loading' || props.message === '' ) const isTextMessage = computed(() => !isLoadingMessage.value ) const bubbleClasses = computed(() => { const classes = [ 'ym-bubble', `ym-bubble--${props.position}` ] if (isLoadingMessage.value) { classes.push('ym-bubble--loading') } return classes })左右气泡样式
.ym-bubble--right { justify-content: flex-end; .ym-bubble-content { color: #fff; background-color: var(--ym-bubble-user-color); border-radius: 50rpx 50rpx 0 50rpx; } } .ym-bubble--left { justify-content: flex-start; .ym-bubble-content { color: var(--ym-bubble-text-color); background-color: var(--ym-bubble-bot-color); border-radius: 50rpx 50rpx 50rpx 0; } }页面使用时:
<YmBubble v-for="(msg, index) in chatStore.getMessages" :key="index" :message="msg.content" :position="msg.position" :is-markdown="msg.isMarkdown" :resources="msg.resources" :files="msg.files" :is-error="msg.isError" > <template #footer> <view v-if="msg.position === 'left'" @click="handleCopy(msg.content)"> 复制原文 </view> </template> </YmBubble>3. 消息发送组件YmSender
核心职责
- 多行文本输入。
- 输入内容校验。
- 发送和取消发送。
- 附件区域展开和收起。
- 联网搜索开关。
- 加载状态下显示取消按钮。
输入框和按钮
<textarea v-model="message" :placeholder="props.placeholder" :maxlength="maxLength" :auto-height="true" :disabled="props.disabled" @input="onInput" /> <button :disabled="isSendDisabled" :aria-label="props.loading ? '取消发送' : '发送消息'" @click="handleSendOrCancel" > <view v-if="props.loading" class="ym-sender__loading-spinner" /> <text v-else class="ym-icon i-fasong3" /> </button>发送按钮状态
const isSendDisabled = computed(() => { if (props.loading) return false if (props.disabled) return true const hasText = StringUtils.isNotEmpty(message.value.trim()) const hasAttachment = showAttachments.value if (props.requireMessage && !hasText) return true if (props.requireAttachment && !hasAttachment) return true return false })发送事件
const sendMessage = async () => { if (props.loading || props.disabled) return const trimmedMessage = message.value.trim() if (props.requireMessage && !trimmedMessage) { PlatformUtils.showToast('请输入消息内容') return } if (trimmedMessage.length > props.maxLength) { PlatformUtils.showToast(`消息长度不能超过${props.maxLength}个字符`) return } const sendEvent: SendEvent = { message: trimmedMessage, timestamp: Date.now(), isOnline: isOpenOnline.value } emit('send', sendEvent) message.value = '' }页面接收发送事件:
<YmSender :show-online="true" :loading="chatStore.getIsSending" @send="handleSend" />4. 消息发送完整流程
页面中的发送流程位于index.vue:
const handleSend = async (event: SendEvent) => { const message = event.message?.trim() if (!message) { uni.showToast({ title: '请输入消息内容', icon: 'none' }) return } scrollToBottom() if (chatStore.getAttachmentFiles.length > 0) { await handleFileUpload() } // 添加用户消息 chatStore.addUserMessage( message, chatStore.getAttachmentFiles ) // 添加空的 AI 消息,后续逐步填充 chatStore.addAiMessage() // 模拟 AI 输出 simulateAiResponse() // 发送完成后清空待发送附件 chatStore.clearAttachments() }5. 打字机效果
YmTypewriter组件通过setInterval逐字符修改displayedText。
核心实现
const displayedText = ref('') const isTyping = ref(false) let typeTimer: ReturnType<typeof setInterval> | null = null const typeText = () => { if (!props.text) { displayedText.value = '' return } if (typeTimer) { clearInterval(typeTimer) } displayedText.value = '' isTyping.value = true let index = 0 typeTimer = setInterval(() => { if (index < props.text.length) { displayedText.value += props.text.charAt(index) index++ } else { clearInterval(typeTimer!) typeTimer = null isTyping.value = false emit('complete') props.onComplete?.() } }, props.speed) }监听文本变化
watch( () => props.text, (newText, oldText) => { if (newText !== oldText) { typeText() } }, { immediate: true } )光标闪烁
const showCursor = ref(false) const startCursorBlink = () => { if (props.mode !== 'cursor') return showCursor.value = true cursorTimer = setInterval(() => { showCursor.value = !showCursor.value }, 500) }模板:
<view class="typewriter"> {{ displayedText }} <text v-if="showCursor" class="cursor"> {{ cursorSuffix }} </text> </view>组件还暴露了控制方法:
defineExpose({ pause, resume, reset, isTyping: computed(() => isTyping.value), isComplete })6. 示例页面中的模拟 AI 流式回复
当前页面没有直接使用YmTypewriter,而是通过 Pinia 不断追加最后一条 AI 消息:
const simulateAiResponse = () => { chatStore.setSending(true) const response = generateAiResponse() let index = 0 const typeInterval = setInterval(() => { if (index < response.length) { chatStore.updateLastMessage(response[index]) index++ scrollToBottom() } else { clearInterval(typeInterval) chatStore.setSending(false) } }, 100) }这种方式更接近真实的流式接口:
for await (const chunk of responseStream) { chatStore.updateLastMessage(chunk) }7. Markdown 渲染和思考过程
MarkDown.vue使用marked解析 Markdown:
import { marked } from 'marked' marked.setOptions({ breaks: true, gfm: true, pedantic: false }) const parsedContent = computed(() => { if (!chatContent.value) return '' return customStyle(marked.parse(chatContent.value)) })组件支持拆分:
<think>...</think> 思考过程 其余内容 正式回答核心解析逻辑:
const processContent = () => { let content = props.content const thinkMatch = content.match( /<think>([\s\S]*?)<\/think>/ ) if (thinkMatch) { thinkContent.value = thinkMatch[1].trim() isThinkCollapsed.value = true content = content.replace( /<think>[\s\S]*?<\/think>/, '' ) } else if (content.includes('<think>')) { const start = content.indexOf('<think>') thinkContent.value = content.substring(start + 7) content = content.substring(0, start) isThinkCollapsed.value = false } chatContent.value = content.trim() }模板将思考过程和回答内容分开渲染:
<view v-if="thinkContent" class="markdown-think"> <view @click="toggleThinkContent"> 思考过程 </view> <view v-if="!isThinkCollapsed" v-html="parsedThinkContent" /> </view> <view v-if="chatContent" v-html="parsedContent" class="markdown-content" />8. 文件附件管理
附件数据结构
interface AttachmentFile { id?: string name: string size?: number type?: string url?: string status?: string progress?: number selected?: boolean }选择文件
const chooseFiles = () => { if (items.value.length >= props.count) { uni.showToast({ title: `最多只能选择${props.count}个文件`, icon: 'none' }) return } uni.chooseMessageFile({ count: props.count - items.value.length, type: 'all', success: (res: any) => { const newFiles = res.tempFiles.map((file: any) => { const fileName = file.name || file.path let fileType = 'file' if (/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(fileName)) { fileType = 'image' } else if (/\.(mp4|avi|mov|wmv|flv|webm)$/i.test(fileName)) { fileType = 'video' } else if (/\.(mp3|wav|flac|aac|ogg)$/i.test(fileName)) { fileType = 'audio' } return { id: Date.now() + Math.random() + '', name: file.name, size: file.size, type: fileType, url: file.path, status: 'pending' } }) items.value.push(...newFiles) emit('update:items', items.value) emit('file-add', newFiles) } }) }删除和选择
const removeFile = (index: number) => { const removedFile = items.value.splice(index, 1)[0] emit('update:items', items.value) emit('file-remove', removedFile) } const toggleFileSelection = (index: number) => { const file = items.value[index] file.selected = !file.selected emit('update:items', items.value) emit('file-select', file, file.selected) }文件预览卡片
图片直接显示:
<image v-if="isImageFile(file.type || file.name)" :src="file.url" class="file-card__image" mode="aspectFill" />其他类型使用图标:
<text v-else :class="getFileIconClass(file.name)" />例如:
if (/\.pdf$/i.test(name)) { return 'ym-icon i-danjufujian-wenjiantubiao-geshi3' } if (/\.(doc|docx)$/i.test(name)) { return 'ym-icon i-danjufujian-wenjiantubiao-geshi5' }页面中通过双向更新同步到 Pinia:
<YmAttachments :items="chatStore.getAttachmentFiles" @update:items="onItemsUpdate" />const onItemsUpdate = (items: any[]) => { chatStore.setAttachmentFiles(items) }9. 文件上传模拟
当前项目没有真实后端上传,只模拟 500ms 延迟:
const handleFileUpload = async () => { const files = chatStore.getAttachmentFiles if (!files.length) return [] uni.showLoading({ title: '正在上传文件...' }) await new Promise(resolve => setTimeout(resolve, 500)) uni.hideLoading() uni.showToast({ title: `成功上传${files.length}个文件`, icon: 'success' }) return files }接入真实服务时,可以替换成:
const uploadFile = (file: AttachmentFile) => { return new Promise((resolve, reject) => { uni.uploadFile({ url: '/api/upload', filePath: file.url!, name: 'file', success: resolve, fail: reject }) }) }10. 复制消息
AI 消息底部插槽调用页面方法:
const handleCopy = (content: string) => { uni.setClipboardData({ data: content, success: () => { uni.showToast({ title: '复制成功', icon: 'success' }) } }) }11. 自动滚动到底部
页面通过scroll-into-view定位到固定锚点:
<scroll-view scroll-y :scroll-into-view="scrollIntoView" :scroll-with-animation="true" > ... <view id="last-msg" /> </scroll-view>const scrollToBottom = async () => { scrollIntoView.value = '' await nextTick() scrollIntoView.value = 'last-msg' }每次新增消息或追加 AI 字符后调用即可。
12. 当前实现需要注意的地方
YmBubble的loading属性虽然在页面中传入,但组件内部没有声明和使用它,实际加载状态由message === ''或message === 'loading'判断。页面使用的是
simulateAiResponse()逐字符更新 Pinia,而不是YmTypewriter。YmTypewriter属于可复用的独立展示组件。YmBubble传递的是:
:cursor-char="cursorChar"但YmTypewriter定义的属性名称是cursorSuffix,因此自定义光标字符目前不会生效。
Markdown 使用
v-html渲染,真实项目接收后端内容时应增加 HTML 清理,避免插入不安全标签。handleFileUpload()当前只是假上传,真实项目需要处理上传地址、进度、失败重试和文件状态更新。ChatMessage和AttachmentFile中仍有部分any,可以进一步收敛为严格 TypeScript 类型。