一、引言

在健康管理系统中,AI小助手是核心交互模块之一。它不仅需要提供流畅的聊天体验,还需要支持头像自定义、角色形态切换、历史记录管理等复杂功能。本文将从架构设计角度,深入分析AI助手组件的实现细节。

二、组件架构概览

2.1 整体架构设计

AI助手组件采用 状态机模式 管理复杂的交互状态,主要包含以下几个状态:

状态 描述 触发条件
isChatOpen 聊天窗口打开状态 点击头像
isSettingsOpen 设置弹窗打开状态 点击设置按钮
isHistoryOpen 历史记录面板打开状态 点击历史按钮
isEditMode 设置编辑状态 点击编辑按钮
isCropModalOpen 头像裁剪弹窗状态 上传头像
isTyping AI正在回复状态 发送消息后

2.2 Vue模板结构

核心HTML结构 :

<template>
<div class="ai-assistant">
    <!-- 小助手头像 -->
    <div class="ai-assistant__avatar-container" @click.stop="toggleChat">
    <div class="ai-assistant__avatar">
        <div class="ai-assistant__avatar-image">
        <div v-if="avatarType === 'icon'" class="ai-assistant__avatar-icon">...</div>
        <img v-else :src="avatarUrl" />
        <div class="ai-assistant__status" :class="{ 'ai-assistant__status--online': isOnline }"></div>
        </div>
        <div class="ai-assistant__name">{{ assistantName }}</div>
    </div>
    </div>

    <!-- 聊天窗口 -->
    <div class="ai-assistant__chat" :class="{ 'ai-assistant__chat--open': isChatOpen }">
    <!-- 头部 -->
    <div class="ai-assistant__chat-header">...</div>
    <!-- 内容区 -->
    <div class="ai-assistant__chat-content">...</div>
    <!-- 输入区 -->
    <div class="ai-assistant__chat-input">...</div>
    </div>

    <!-- 设置弹窗 -->
    <div class="ai-assistant__settings" v-if="isSettingsOpen">...</div>

    <!-- 头像裁剪组件 -->
    <ImageCropper ... />
</div>
</template>

设计要点 :

  • 使用条件渲染控制不同状态的UI展示
  • 通过动态class绑定实现状态切换动画
  • 组件采用嵌套结构,职责清晰

三、消息渲染与打字效果

3.1 Markdown消息渲染

JavaScript实现 :

import { marked } from 'marked'

const formatMessage = (content) => {
if (!content) return ''
return marked.parse(content)
}

CSS样式:

.ai-assistant__message-content {
padding: 10px 14px;
border-radius: 18px;
background: rgba(var(--theme-color-rgb), 0.1);
font-size: 14px;
line-height: 1.4;
}

/* Markdown样式支持 */
.ai-assistant__message-content h1, .ai-assistant__message-content h2 {
margin: 8px 0 6px;
font-weight: 600;
}

.ai-assistant__message-content blockquote {
margin: 8px 0;
padding: 8px 12px;
border-left: 3px solid var(--theme-color);
background: rgba(var(--theme-color-rgb), 0.08);
border-radius: 0 8px 8px 0;
}

.ai-assistant__message-content code {
padding: 2px 6px;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
font-family: monospace;
}

3.2 打字指示器动画

Vue模板 :

<div v-if="isTyping" class="ai-assistant__message">
<div class="ai-assistant__message-avatar">...</div>
<div class="ai-assistant__message-content-wrapper">
    <div class="ai-assistant__message-content ai-assistant__message-content--typing">
    <div class="ai-assistant__typing-indicator">
        <span></span>
        <span></span>
        <span></span>
    </div>
    </div>
</div>
</div>

CSS动画:

.ai-assistant__typing-indicator {
display: flex;
gap: 4px;
}

.ai-assistant__typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #999;
animation: typing 1.4s infinite ease-in-out both;
}

.ai-assistant__typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.ai-assistant__typing-indicator span:nth-child(2) { animation-delay: -0.16s; }

@keyframes typing {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}

四、多形态角色系统

4.1 角色配置

JavaScript实现 :

const personalities = ref([
{ id: '温柔', name: '温柔', description: '温柔体贴,耐心解答' },
{ id: '毒舌', name: '毒舌', description: '犀利幽默,直言不讳' },
{ id: '萝莉', name: '萝莉', description: '可爱活泼,充满活力' },
{ id: '御姐', name: '御姐', description: '成熟优雅,气场强大' }
])

const getWelcomeMessage = (name, personalityId) => {
const welcomeMessages = {
    温柔: `你好呀!我是${name},你的专属健康小助手~`,
    毒舌: `哟,终于想起我来了?我是${name},你的健康顾问。`,
    萝莉: `嗨嗨~!我是${name}呀!✨ 今天也要元气满满地保持健康哦!`,
    御姐: `你好,我是${name}。你的健康状况,我会帮你打理得井井有条。`,
    default: `你好!我是${name},你的AI健康小助手。`
}
return welcomeMessages[personalityId] || welcomeMessages.default
}

4.2 形态选择UI

Vue模板 :

<div class="ai-assistant__settings-personality">
<label>小助手形态</label>
<div class="ai-assistant__personality-options">
    <div
    v-for="personality in personalities"
    :key="personality.id"
    class="ai-assistant__personality-option"
    :class="{ 'ai-assistant__personality-option--active': selectedPersonality === personality.id }"
    @click.stop="handlePersonalityClick(personality.id)"
    >
    <div class="ai-assistant__personality-info">
        <div class="ai-assistant__personality-name">{{ personality.name }}</div>
        <div class="ai-assistant__personality-desc">{{ personality.description }}</div>
    </div>
    <div v-if="selectedPersonality === personality.id" class="ai-assistant__personality-check">
        <svg>...</svg>
    </div>
    </div>
</div>
</div>

CSS样式:

.ai-assistant__personality-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}

.ai-assistant__personality-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 12px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-radius: 16px;
background: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.3s ease;
}

.ai-assistant__personality-option--active {
background: linear-gradient(135deg, rgba(var(--theme-color-rgb), 0.15), rgba(100, 149, 237, 0.15));
border-color: rgba(var(--theme-color-rgb), 0.5);
box-shadow: 0 4px 15px rgba(var(--theme-color-rgb), 0.2);
}

五、头像上传与裁剪集成

5.1 头像上传流程

JavaScript实现 :

const avatarInput = ref(null)

const triggerAvatarUpload = () => {
if (avatarInput.value) {
    avatarInput.value.click()
}
}

const handleAvatarUpload = (event) => {
const file = event.target.files[0]
if (file) {
    const reader = new FileReader()
    reader.onload = (e) => {
    cropImageUrl.value = e.target.result
    isCropModalOpen.value = true
    }
    reader.readAsDataURL(file)
}
}

const handleCropResult = async (croppedFile) => {
// 本地预览
const reader = new FileReader()
reader.onload = (e) => {
    avatarUrl.value = e.target.result
    avatarType.value = 'custom'
}
reader.readAsDataURL(croppedFile)

// 上传到服务器
try {
    const username = localStorage.getItem('username')
    if (username) {
    const response = await healthService.aiAssistantSettings.uploadAvatar(username, croppedFile)
    if (response && response.success && response.avatarData) {
        const contentType = response.avatarContentType || 'image/jpeg'
        avatarUrl.value = `data:${contentType};base64,${response.avatarData}`
        localStorage.setItem('aiAssistantAvatarUrl', avatarUrl.value)
    }
    }
} catch (error) {
// 上传失败但仍保留本地预览
}
}

5.2 头像区域UI

Vue模板 :

<div class="ai-assistant__settings-avatar">
<div class="ai-assistant__settings-avatar-image" @click.stop="handleAvatarClick">
    <div v-if="avatarType === 'icon'" class="ai-assistant__settings-avatar-icon">...</div>
    <img v-else :src="avatarUrl" class="ai-assistant__settings-avatar-custom" />
    <div class="ai-assistant__settings-avatar-edit">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
        <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
        <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
    </svg>
    </div>
</div>
<input type="file" ref="avatarInput" @change="handleAvatarUpload" accept="image/*" class="ai-assistant__settings-avatar-input" />
</div>

CSS样式:

.ai-assistant__settings-avatar-image {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, var(--theme-color), var(--theme-dark));
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(var(--theme-color-rgb), 0.3);
}

.ai-assistant__settings-avatar-image:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(var(--theme-color-rgb), 0.5);
}

.ai-assistant__settings-avatar-edit {
position: absolute;
bottom: 0;
right: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--theme-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
border: 2px solid rgba(255, 255, 255, 0.8);
transition: all 0.3s ease;
}

.ai-assistant__settings-avatar-edit:hover {
background: var(--theme-light);
transform: scale(1.1);
}

.ai-assistant__settings-avatar-input {
display: none;
}

六、历史记录管理

6.1 历史记录数据同步

JavaScript实现 :

// 保存历史记录到数据库
const saveChatHistoryToDB = async (session) => {
try {
    const username = localStorage.getItem('username')
    if (!username) return
    
    await healthService.aiAssistant.saveHistory(
    username,
    session.id,
    session.title,
    JSON.stringify(session.messages)
    )
} catch (error) {
    // 保存失败静默处理
}
}

// 从数据库加载历史记录
const loadChatHistoryFromDB = async () => {
try {
    const username = localStorage.getItem('username')
    if (!username) {
    chatHistory.value = []
    return
    }

    const response = await healthService.aiAssistant.getHistoryList(username)
    if (response && Array.isArray(response)) {
    chatHistory.value = response.map(item => ({
        id: item.sessionId,
        title: item.title,
        timestamp: item.createdAt,
        messages: []
    }))
    }
} catch (error) {
    chatHistory.value = []
}
}

// 定期同步(每5分钟)
let syncInterval = null
onMounted(() => {
syncInterval = setInterval(() => {
    const currentUsername = localStorage.getItem('username')
    if (currentUsername) {
    syncSettingsFromDB()
    }
}, 5 * 60 * 1000)
})

onUnmounted(() => {
if (syncInterval) {
    clearInterval(syncInterval)
}
})

6.2 历史记录列表UI

Vue模板 :

<div v-if="isHistoryOpen" class="ai-assistant__history-panel">
<div class="ai-assistant__history-panel-inner">
    <div class="ai-assistant__history-header">
    <span>历史记录</span>
    <button @click.stop="closeHistoryPanel" class="ai-assistant__history-close">×</button>
    </div>
    
    <div v-if="chatHistory.length === 0" class="ai-assistant__history-empty">
    <svg>...</svg>
    <p>暂无历史记录</p>
    </div>
    
    <div v-else class="ai-assistant__history-list">
    <div
        v-for="(session, index) in chatHistory"
        :key="session.id"
        class="ai-assistant__history-item"
        @click="loadChatSession(session)"
    >
        <div class="ai-assistant__history-item-icon">
        <svg>...</svg>
        </div>
        <div class="ai-assistant__history-item-content">
        <div class="ai-assistant__history-item-title">{{ session.title }}</div>
        <div class="ai-assistant__history-item-time">{{ formatHistoryTime(session.timestamp) }}</div>
        </div>
        <button class="ai-assistant__history-item-delete" @click.stop="deleteChatSession(index)">
        <svg>...</svg>
        </button>
    </div>
    </div>
</div>
</div>

CSS样式:

.ai-assistant__history-panel {
position: absolute;
top: 0;
left: 0;
width: 320px;
height: 400px;
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
border-radius: 16px;
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}

@keyframes slideIn {
from {
    opacity: 0;
    transform: translateX(-20px);
}
to {
    opacity: 1;
    transform: translateX(0);
}
}

.ai-assistant__history-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 44px 14px 14px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 250, 252, 0.9) 100%);
border-radius: 14px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}

.ai-assistant__history-item:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(var(--theme-color-rgb), 0.1) 100%);
box-shadow: 0 6px 20px rgba(var(--theme-color-rgb), 0.12);
transform: translateX(4px);
}

.ai-assistant__history-item-delete {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
border: none;
background: rgba(239, 68, 68, 0.08);
border-radius: 50%;
cursor: pointer;
color: #ef4444;
opacity: 0;
transition: all 0.25s ease;
}

.ai-assistant__history-item:hover .ai-assistant__history-item-delete {
opacity: 1;
}

七、状态管理与生命周期

7.1 用户隔离机制

JavaScript实现 :

const loadCurrentChat = () => {
const username = localStorage.getItem('username')
const savedChat = localStorage.getItem('aiAssistantCurrentChat')
const savedUser = localStorage.getItem('aiAssistantChatUser')

// 检查是否是同一用户
if (!username || username !== savedUser) {
    clearChatStorage()
    initNewChat()
    return
}

if (savedChat) {
    try {
    messages.value = JSON.parse(savedChat)
    currentChatId.value = savedChatId || Date.now()
    } catch (e) {
    initNewChat()
    }
} else {
    initNewChat()
}
}

const clearChatStorage = () => {
localStorage.removeItem('aiAssistantCurrentChat')
localStorage.removeItem('aiAssistantCurrentChatId')
localStorage.removeItem('aiAssistantChatUser')
}

7.2 组件生命周期

JavaScript实现 :

onMounted(async () => {
const username = localStorage.getItem('username')
const savedUser = localStorage.getItem('aiAssistantChatUser')

// 优先从localStorage加载设置
loadSettingsFromLocal()

// 如果没有加载到设置,使用默认值
if (!assistantName.value) {
    resetToDefaultSettings()
}

// 检查用户是否未登录或切换了用户
if (!username || username !== savedUser) {
    clearChatStorage()
    chatHistory.value = []
    initNewChat()
    if (username) {
    localStorage.setItem('aiAssistantChatUser', username)
    }
} else {
    // 同一用户已登录,异步从数据库同步最新设置
    syncSettingsFromDB()
    loadCurrentChat()
    await loadChatHistoryFromDB()
}
checkAiStatus()

// 设置定期同步
syncInterval = setInterval(() => {
    const currentUsername = localStorage.getItem('username')
    if (currentUsername) {
    syncSettingsFromDB()
    }
}, 5 * 60 * 1000)
})

onUnmounted(() => {
if (syncInterval) {
    clearInterval(syncInterval)
    syncInterval = null
}
})

八、总结

AI助手组件是一个复杂的交互系统,其架构设计体现了以下几个关键原则:

  1. 状态机模式 :通过多个布尔状态管理复杂的UI交互流程
  2. 数据持久化 :本地存储与数据库同步相结合,确保数据安全
  3. 模块化设计 :将头像裁剪、图片查看等功能分离为独立组件
  4. 用户隔离 :通过localStorage用户标记实现多用户数据隔离
  5. 定期同步 :后台静默同步机制保持数据最新
    这些设计使得AI助手组件既具备良好的用户体验,又保证了数据的安全性和可靠性。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐