healthapp
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

12 KiB

06-AI对话页面(原型)

目标

实现 Web 端 AI 健康问诊对话功能,使用模拟数据模拟多轮对话效果。


前置要求

  • 路由配置完成
  • 模拟数据服务已创建

实施步骤

步骤 1:创建对话状态 Store

创建 src/stores/chat.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Conversation, Message } from '@/types'

export const useChatStore = defineStore('chat', () => {
  const conversations = ref<Conversation[]>([])

  function addConversation(conv: Conversation) {
    conversations.value.unshift(conv)
    saveToStorage()
  }

  function deleteConversation(id: string) {
    conversations.value = conversations.value.filter(c => c.id !== id)
    saveToStorage()
  }

  function addMessage(convId: string, message: Message) {
    const conv = conversations.value.find(c => c.id === convId)
    if (conv) {
      conv.messages.push(message)
      conv.updatedAt = new Date().toISOString()
      // 更新标题为第一条用户消息
      if (message.role === 'user' && conv.messages.filter(m => m.role === 'user').length === 1) {
        conv.title = message.content.slice(0, 20) + (message.content.length > 20 ? '...' : '')
      }
      saveToStorage()
    }
  }

  function saveToStorage() {
    localStorage.setItem('conversations', JSON.stringify(conversations.value))
  }

  function init() {
    const saved = localStorage.getItem('conversations')
    if (saved) {
      conversations.value = JSON.parse(saved)
    }
  }

  init()

  return { conversations, addConversation, deleteConversation, addMessage }
})

步骤 2:对话列表页面

创建 src/views/chat/ChatListView.vue

<template>
  <div class="chat-list-page">
    <div class="page-header">
      <h2>AI问答</h2>
      <el-button type="primary" @click="createConversation">
        <el-icon><Plus /></el-icon>
        新建对话
      </el-button>
    </div>

    <div v-if="chatStore.conversations.length === 0" class="empty-state">
      <el-empty description="暂无对话记录">
        <el-button type="primary" @click="createConversation">开始第一次对话</el-button>
      </el-empty>
    </div>

    <div v-else class="conversation-list">
      <el-card
        v-for="conv in chatStore.conversations"
        :key="conv.id"
        class="conversation-item"
        shadow="hover"
        @click="router.push(`/chat/${conv.id}`)"
      >
        <div class="conv-content">
          <div class="conv-icon">
            <el-icon size="24" color="#10B981"><ChatDotRound /></el-icon>
          </div>
          <div class="conv-info">
            <h4>{{ conv.title }}</h4>
            <p>{{ formatTime(conv.updatedAt) }}</p>
          </div>
          <el-button
            type="danger"
            :icon="Delete"
            circle
            size="small"
            @click.stop="handleDelete(conv.id)"
          />
        </div>
      </el-card>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { Plus, ChatDotRound, Delete } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import { useChatStore } from '@/stores/chat'

const router = useRouter()
const chatStore = useChatStore()

const formatTime = (time: string) => dayjs(time).format('MM-DD HH:mm')

const createConversation = () => {
  const newConv = {
    id: Date.now().toString(),
    title: '新对话',
    messages: [],
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  }
  chatStore.addConversation(newConv)
  router.push(`/chat/${newConv.id}`)
}

const handleDelete = (id: string) => {
  ElMessageBox.confirm('确定要删除这个对话吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    chatStore.deleteConversation(id)
  })
}
</script>

<style scoped lang="scss">
.chat-list-page {
  max-width: 800px;
  margin: 0 auto;
}

.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  
  h2 {
    font-size: 20px;
  }
}

.empty-state {
  padding: 60px 0;
}

.conversation-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.conversation-item {
  cursor: pointer;
  
  .conv-content {
    display: flex;
    align-items: center;
    gap: 16px;
  }
  
  .conv-icon {
    width: 48px;
    height: 48px;
    background: #ECFDF5;
    border-radius: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  
  .conv-info {
    flex: 1;
    
    h4 {
      font-size: 15px;
      margin-bottom: 4px;
    }
    
    p {
      font-size: 12px;
      color: #9CA3AF;
    }
  }
}
</style>

步骤 3:对话详情页面

创建 src/views/chat/ChatDetailView.vue

<template>
  <div class="chat-detail-page">
    <!-- 消息列表 -->
    <div class="message-list" ref="messageListRef">
      <!-- 欢迎消息 -->
      <div v-if="messages.length === 0" class="welcome-message">
        <div class="welcome-avatar">
          <el-icon size="32" color="#3B82F6"><Service /></el-icon>
        </div>
        <div class="welcome-content">
          <h3>AI健康助手</h3>
          <p>您好!我是您的AI健康助手。我可以为您提供健康咨询、疾病预防建议、用药指导等服务。请问有什么可以帮助您的吗?</p>
        </div>
      </div>

      <!-- 常见问题 -->
      <div v-if="messages.length === 0" class="quick-questions">
        <p class="label">常见问题</p>
        <div class="question-list">
          <el-button
            v-for="q in quickQuestions"
            :key="q"
            round
            @click="sendQuickQuestion(q)"
          >
            {{ q }}
          </el-button>
        </div>
      </div>

      <!-- 消息列表 -->
      <div
        v-for="msg in messages"
        :key="msg.id"
        class="message-item"
        :class="msg.role"
      >
        <div class="message-avatar">
          <el-icon v-if="msg.role === 'assistant'" size="20" color="#3B82F6">
            <Service />
          </el-icon>
          <span v-else>{{ authStore.user?.nickname?.charAt(0) || 'U' }}</span>
        </div>
        <div class="message-bubble">
          <div class="message-content" v-html="formatContent(msg.content)"></div>
          <div class="message-time">{{ formatTime(msg.createdAt) }}</div>
        </div>
      </div>

      <!-- 输入中提示 -->
      <div v-if="sending" class="typing-indicator">
        <el-icon class="is-loading"><Loading /></el-icon>
        <span>AI 正在思考...</span>
      </div>
    </div>

    <!-- 输入区域 -->
    <div class="input-area">
      <el-input
        v-model="inputText"
        type="textarea"
        :rows="2"
        placeholder="请输入您的健康问题..."
        :disabled="sending"
        @keydown.enter.exact.prevent="handleSend"
      />
      <el-button
        type="primary"
        :icon="Promotion"
        circle
        size="large"
        :disabled="!inputText.trim() || sending"
        @click="handleSend"
      />
    </div>

    <!-- 免责声明 -->
    <div class="disclaimer">
      AI 建议仅供参考,不构成医疗诊断,如有需要请就医
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Service, Promotion, Loading } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { mockAIReply } from '@/mock/chat'
import type { Message } from '@/types'

const route = useRoute()
const chatStore = useChatStore()
const authStore = useAuthStore()
const messageListRef = ref<HTMLElement>()

const conversationId = route.params.id as string
const inputText = ref('')
const sending = ref(false)

const messages = computed(() => {
  const conv = chatStore.conversations.find(c => c.id === conversationId)
  return conv?.messages || []
})

const quickQuestions = [
  '我最近总是感觉疲劳怎么办?',
  '如何改善睡眠质量?',
  '有什么养生建议吗?',
  '感冒了应该注意什么?'
]

const formatTime = (time: string) => dayjs(time).format('HH:mm')

const formatContent = (content: string) => {
  return content.replace(/\n/g, '<br>')
}

const scrollToBottom = () => {
  nextTick(() => {
    if (messageListRef.value) {
      messageListRef.value.scrollTop = messageListRef.value.scrollHeight
    }
  })
}

const sendQuickQuestion = (question: string) => {
  inputText.value = question
  handleSend()
}

const handleSend = async () => {
  const content = inputText.value.trim()
  if (!content || sending.value) return

  // 添加用户消息
  const userMessage: Message = {
    id: Date.now().toString(),
    role: 'user',
    content,
    createdAt: new Date().toISOString()
  }
  chatStore.addMessage(conversationId, userMessage)
  inputText.value = ''
  scrollToBottom()

  // 模拟 AI 回复
  sending.value = true
  try {
    const reply = await mockAIReply(content)
    const assistantMessage: Message = {
      id: (Date.now() + 1).toString(),
      role: 'assistant',
      content: reply,
      createdAt: new Date().toISOString()
    }
    chatStore.addMessage(conversationId, assistantMessage)
    scrollToBottom()
  } finally {
    sending.value = false
  }
}

onMounted(() => {
  scrollToBottom()
})
</script>

<style scoped lang="scss">
.chat-detail-page {
  display: flex;
  flex-direction: column;
  height: calc(100vh - 120px);
  max-width: 800px;
  margin: 0 auto;
}

.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background: #fff;
  border-radius: 12px;
  margin-bottom: 16px;
}

.welcome-message {
  display: flex;
  gap: 12px;
  margin-bottom: 24px;
  
  .welcome-avatar {
    width: 48px;
    height: 48px;
    background: #DBEAFE;
    border-radius: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  
  .welcome-content {
    flex: 1;
    background: #F3F4F6;
    padding: 16px;
    border-radius: 12px;
    
    h3 {
      margin-bottom: 8px;
    }
    
    p {
      color: #4B5563;
      line-height: 1.6;
    }
  }
}

.quick-questions {
  margin-bottom: 24px;
  
  .label {
    text-align: center;
    color: #9CA3AF;
    margin-bottom: 12px;
  }
  
  .question-list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    justify-content: center;
  }
}

.message-item {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
  
  &.user {
    flex-direction: row-reverse;
    
    .message-avatar {
      background: #10B981;
      color: #fff;
    }
    
    .message-bubble {
      background: #10B981;
      color: #fff;
      border-bottom-right-radius: 4px;
      
      .message-time {
        color: rgba(255, 255, 255, 0.8);
      }
    }
  }
  
  &.assistant {
    .message-avatar {
      background: #DBEAFE;
    }
    
    .message-bubble {
      background: #F3F4F6;
      border-bottom-left-radius: 4px;
    }
  }
}

.message-avatar {
  width: 40px;
  height: 40px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.message-bubble {
  max-width: 70%;
  padding: 12px 16px;
  border-radius: 16px;
  
  .message-content {
    line-height: 1.6;
  }
  
  .message-time {
    font-size: 11px;
    color: #9CA3AF;
    margin-top: 8px;
  }
}

.typing-indicator {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #9CA3AF;
  padding: 8px;
}

.input-area {
  display: flex;
  gap: 12px;
  align-items: flex-end;
  
  .el-textarea {
    flex: 1;
  }
}

.disclaimer {
  text-align: center;
  font-size: 12px;
  color: #EF4444;
  background: #FEF2F2;
  padding: 8px;
  border-radius: 8px;
  margin-top: 12px;
}
</style>

验收标准

  • 对话列表正常显示
  • 新建对话正常
  • 删除对话正常
  • 消息发送和模拟回复正常
  • 快捷问题点击正常
  • 免责声明显示

预计耗时

40-50 分钟


下一步

完成后进入 03-Web原型开发/07-个人中心页面.md