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
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