markdown
> 本文适合刚接触前端框架的同学。跟着文章一步步敲代码,你将从零拼出一个完整可运行的 AI 聊天界面,同时深入理解 Vue 3 Composition API、响应式系统、SSE 流式数据解析等核心技术。
前置条件:请先完成《从零搭建一个 AI 猜字游戏后端》并在 Swagger UI 中确认所有后端接口测试通过,再开始本文。前端将直接对接后端已验证的 API 接口。
先看最终效果
浏览器打开后你会看到:
- 左侧是会话列表,可以新建、切换、删除对话
- 中间是聊天区,输入消息后 AI 逐字"吐"出回复
- 右侧是游戏规则说明
- AI 正在回复时显示"对方正在输入..."跳动动画

前端技术栈:
| 技术 | 说明 |
|---|---|
| Vue 3 (ESM) | 渐进式前端框架,通过 importmap 直接在浏览器使用 ESM |
| fetchEventSource | 微软开源 SSE 客户端库,自动解析 SSE 流 |
| 原生 fetch | 浏览器原生网络请求,非流式接口使用 |
| CSS 变量 + Flex | 主题管理 + 三栏自适应布局 |
🧭 准备关:搭建页面骨架
第 1 步:创建文件
bash
hanzi/api/static/
├── index.html # 页面结构
├── index.css # 页面样式
└── index.js # Vue3 交互逻辑
第 2 步:HTML 最小骨架
新建 index.html,先写一个最小的 HTML 骨架:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>猜字游戏</title>
<link rel="stylesheet" href="/static/index.css">
</head>
<body>
<div id="app">
<h1>猜字游戏</h1>
<p>页面骨架已就绪</p>
</div>
<!-- 浏览器模块映射:用"裸导入"语法引用 CDN 上的 ESM 包 -->
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js",
"@microsoft/fetch-event-source": "https://esm.sh/@microsoft/fetch-event-source@2.0.1"
}
}
</script>
<script type="module" src="/static/index.js"></script>
</body>
</html>
🔍 代码大白话拆解 :<div id="app"> 是 Vue 的挂载点。<script type="importmap"> 声明模块名到 CDN 地址的映射,JS 中就能用 import ... from 'vue' 这样的标准 ESM 语法。<script type="module"> 以 ES 模块方式加载 JS 文件。
📚 核心技术点科普:
- importmap :浏览器原生功能,让我们在 JS 中写
import { ref } from 'vue'时,浏览器知道去哪个 CDN 地址加载。无需 npm 和 webpack,浏览器自己搞定模块解析。 - ESM(ES Modules) :JavaScript 官方模块标准。
import/export是语言内置语法,<script type="module">让浏览器以模块方式执行 JS,支持import语句。 vue.esm-browser.js:Vue 3 专门为浏览器 ESM 场景提供的构建版本,适合直接在浏览器中import使用。@microsoft/fetch-event-source:微软开源的 SSE 客户端库,比手写ReadableStream解析更可靠,支持自动重连、错误处理等。后文讲解流式对话时会用到。
第 3 步:CSS 最小样式
新建 index.css:
css
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; background: #f0f2f5; }
第 4 步:JS 最小验证
新建 index.js:
javascript
// ESM 导入:通过 importmap 映射,浏览器可直接使用裸模块名
import { createApp, ref } from 'vue'
createApp({
setup() {
const message = ref('Vue 已挂载成功!')
return { message }
}
}).mount('#app')
🔍 代码大白话拆解 :用 ESM import 从 Vue 导入 createApp 和 ref,创建应用、创建响应式变量、挂载到 DOM。
📚 核心技术点科普:
import { createApp, ref } from 'vue':ESM 标准导入语法。from 'vue'中的'vue'是裸模块名,浏览器通过 importmap 找到对应的 CDN 地址。这是现代前端项目的标准写法。ref():Vue 3 的响应式 API。ref包装一个值,当值变化时 Vue 自动重新渲染绑定的 DOM。在 JS 中通过.value访问/修改,在模板中自动解包。setup()函数:Vue 3 Composition API 的入口。return 出去的变量和函数才能在模板中使用。mount('#app'):把 Vue 应用绑定到id="app"的 DOM 元素上,Vue 接管其内部渲染。
⚙️ 见证成就感时刻
确保后端服务正在运行(python main.py),浏览器访问 http://127.0.0.1:8000/,你应该看到 "Vue 已挂载成功!"------Vue 应用成功运行了。
🧭 第一关:搭建完整页面布局
现在把 HTML 和 CSS 升级为完整的三栏布局。
1.1 HTML 完整模板
替换 index.html 中 <div id="app"> 内部的内容:
html
<div id="app">
<!-- 页面头部 -->
<header class="app-header">
<h1 class="app-title">猜字游戏</h1>
</header>
<!-- 主体三栏布局 -->
<main class="app-main">
<!-- 左侧:会话管理 -->
<aside class="sidebar sidebar-left">
<button class="btn-new-session"
@click="createSession" :disabled="isCreating">
<span class="icon-plus">+</span>
<span>新建会话</span>
</button>
<nav class="session-list">
<ul>
<li v-for="item in sessionList" :key="item"
class="session-item"
:class="{ active: item === currentSessionId }"
@click="selectSession(item)">
<span class="session-name">{{ formatSessionName(item) }}</span>
<button class="btn-delete"
@click.stop="deleteSession(item)">×</button>
</li>
</ul>
<p v-if="sessionList.length === 0" class="empty-tip">暂无会话</p>
</nav>
</aside>
<!-- 中间:聊天区域 -->
<section class="chat-area">
<div class="chat-header">
<span v-if="currentSessionId">{{ currentSessionId }}</span>
<span v-else>请选择或新建一个会话</span>
</div>
<div class="chat-messages" ref="messagesContainer" @scroll="handleScroll">
<div v-for="(msg, index) in currentMessages" :key="index"
class="message-row" :class="msg.role">
<div class="avatar" :class="msg.role">
{{ msg.role === 'user' ? '我' : 'AI' }}
</div>
<div class="bubble" :class="msg.role">
<span v-if="msg.role === 'assistant' && msg.content === ''"
class="typing-indicator">
对方正在输入<span class="dot">.</span>
<span class="dot">.</span><span class="dot">.</span>
</span>
<span v-else>{{ msg.content }}</span>
</div>
</div>
</div>
<footer class="chat-input-area">
<input type="text" class="chat-input"
v-model="inputMessage"
@keyup.enter="sendMessage"
placeholder="输入汉字或指令..."
:disabled="!currentSessionId || isAiTyping" />
<button class="btn-send"
@click="sendMessage"
:disabled="!currentSessionId || !inputMessage.trim() || isAiTyping">
发送
</button>
</footer>
</section>
<!-- 右侧:游戏简介 -->
<aside class="sidebar sidebar-right">
<h2 class="intro-title">游戏简介</h2>
<div class="intro-content">
<h3>玩法说明</h3>
<p>AI 出一道汉字谜题,你来猜谜底!
每轮有 <strong>3 次</strong>猜测机会。</p>
<h3>谜题类型</h3>
<ul>
<li>字谜诗</li>
<li>象形描述</li>
<li>会意解释</li>
<li>趣味拆字</li>
</ul>
<h3>游戏规则</h3>
<ol>
<li>发送"开始游戏"启动新一轮</li>
<li>每轮仅出一题,谜底为一个常用汉字</li>
<li>猜对:获得夸奖与解析</li>
<li>猜错:获得提示,继续尝试</li>
<li>3 次用尽:揭晓谜底,可选择再来一轮</li>
</ol>
</div>
</aside>
</main>
</div>
🔍 代码大白话拆解:三栏布局------左侧会话列表、中间聊天区、右侧游戏规则。HTML 里绑定了各种 Vue 指令和事件,但对应 JS 函数还没写,目前按钮点了没反应。
📚 核心技术点科普:
@click="createSession":Vue 事件绑定。@click是v-on:click的缩写,点击按钮时调用createSession方法。@click.stop="deleteSession(item)":.stop是事件修饰符,等价于event.stopPropagation()。防止点击删除时冒泡触发外层<li>的"选中会话"事件。v-for="item in sessionList":遍历数组渲染列表。:key="item"给每个元素唯一标识,帮 Vue 高效复用 DOM。v-model="inputMessage":双向数据绑定。输入框值自动同步到变量,修改变量也更新输入框。:disabled="!currentSessionId || isAiTyping":动态控制禁用状态。没有选中会话或 AI 正在回复时,输入框不可用。v-if="msg.role === 'assistant' && msg.content === ''":条件渲染。只有 AI 消息且内容为空时,才显示"正在输入"跳动动画。ref="messagesContainer":模板引用。JS 中通过messagesContainer.value可以直接拿到这个 DOM 元素。@scroll="handleScroll":监听滚动事件。用户每次滚动聊天区时触发handleScroll函数,检测是否主动上翻查看历史消息。配合scrollToBottom中的userScrolledUp判断,实现"用户上翻时暂停自动滚动,滚回底部后恢复"的智能滚动行为。
1.2 完整 CSS 样式
替换 index.css 的全部内容:
css
/* ==================== 基础重置 ==================== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
color: #333;
background-color: #f0f2f5;
line-height: 1.6;
}
ul, ol { list-style: none; }
button { cursor: pointer; border: none; outline: none; font-family: inherit; }
input { outline: none; font-family: inherit; }
/* ==================== CSS 变量(统一管理主题色和尺寸) ==================== */
:root {
--color-primary: #4a6cf7;
--color-primary-hover: #3a5ce5;
--color-primary-light: #eef1ff;
--color-danger: #ff4d4f;
--color-bg: #f0f2f5;
--color-bg-white: #ffffff;
--color-text: #1f2937;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
--color-border: #e5e7eb;
--color-shadow: rgba(0, 0, 0, 0.06);
--sidebar-width: 220px;
--header-height: 56px;
--radius: 8px;
--radius-sm: 6px;
--radius-lg: 12px;
}
/* ==================== 页面头部 ==================== */
.app-header {
height: var(--header-height);
background: linear-gradient(135deg, var(--color-primary), #6c5ce7);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(74, 108, 247, 0.3);
position: relative;
z-index: 10;
}
.app-title {
font-size: 20px;
font-weight: 600;
color: #fff;
letter-spacing: 2px;
}
/* ==================== 主体三栏布局 ==================== */
.app-main {
display: flex;
height: calc(100vh - var(--header-height));
overflow: hidden;
}
/* ==================== 侧边栏通用 ==================== */
.sidebar {
background: var(--color-bg-white);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ==================== 左侧工具栏 ==================== */
.sidebar-left {
width: var(--sidebar-width);
padding: 16px 12px;
gap: 12px;
}
.btn-new-session {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 40px;
background: var(--color-primary);
color: #fff;
font-size: 14px;
font-weight: 500;
border-radius: var(--radius);
transition: background 0.2s, transform 0.1s;
}
.btn-new-session:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-new-session:active:not(:disabled) { transform: scale(0.97); }
.btn-new-session:disabled { opacity: 0.6; cursor: not-allowed; }
.icon-plus { font-size: 18px; font-weight: 700; line-height: 1; }
/* 会话列表 */
.session-list { flex: 1; overflow-y: auto; margin: 0 -12px; padding: 0 12px; }
.session-list::-webkit-scrollbar { width: 4px; }
.session-list::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 2px; }
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
margin-bottom: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s;
}
.session-item:hover { background: var(--color-primary-light); }
.session-item.active {
background: var(--color-primary-light);
border-left: 3px solid var(--color-primary);
}
.session-name {
flex: 1;
font-size: 13px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-item.active .session-name {
font-weight: 600;
color: var(--color-primary);
}
.btn-delete {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
background: transparent;
color: var(--color-text-muted);
font-size: 16px;
border-radius: 4px;
flex-shrink: 0;
margin-left: 8px;
transition: background 0.2s, color 0.2s;
opacity: 0;
}
.session-item:hover .btn-delete { opacity: 1; }
.btn-delete:hover { background: #fee2e2; color: var(--color-danger); }
.empty-tip {
text-align: center;
color: var(--color-text-muted);
font-size: 13px;
padding: 20px 0;
}
/* ==================== 中间聊天区域 ==================== */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-bg);
overflow: hidden;
min-width: 0;
}
.chat-header {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-white);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.chat-session-id { font-size: 13px; color: var(--color-text-secondary); font-weight: 500; }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-messages::-webkit-scrollbar { width: 6px; }
.chat-messages::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
.message-row {
display: flex;
align-items: flex-start;
gap: 10px;
max-width: 80%;
}
.message-row.user { align-self: flex-end; flex-direction: row-reverse; }
.message-row.assistant { align-self: flex-start; }
.avatar {
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
flex-shrink: 0;
color: #fff;
}
.avatar.user { background: linear-gradient(135deg, #667eea, #764ba2); }
.avatar.assistant { background: linear-gradient(135deg, var(--color-primary), #6c5ce7); }
.bubble {
padding: 10px 16px;
border-radius: var(--radius-lg);
font-size: 14px;
line-height: 1.6;
word-break: break-word;
position: relative;
}
.bubble.user {
background: var(--color-primary);
color: #fff;
border-top-right-radius: 4px;
}
.bubble.assistant {
background: var(--color-bg-white);
color: var(--color-text);
border-top-left-radius: 4px;
box-shadow: 0 1px 4px var(--color-shadow);
}
/* 正在输入动画 */
.typing-indicator {
display: inline-flex;
align-items: center;
color: var(--color-text-muted);
font-style: italic;
}
.typing-indicator .dot {
animation: typingDot 1.4s infinite;
font-style: normal;
}
.typing-indicator .dot:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingDot {
0%, 60%, 100% { opacity: 0.2; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-2px); }
}
/* 底部输入区域 */
.chat-input-area {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
background: var(--color-bg-white);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.chat-input {
flex: 1; height: 42px;
padding: 0 16px;
font-size: 14px;
border: 1px solid var(--color-border);
border-radius: 21px;
background: var(--color-bg);
color: var(--color-text);
transition: border-color 0.2s, box-shadow 0.2s;
}
.chat-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.12);
}
.chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
.chat-input::placeholder { color: var(--color-text-muted); }
.btn-send {
height: 42px;
padding: 0 24px;
background: var(--color-primary);
color: #fff;
font-size: 14px;
font-weight: 500;
border-radius: 21px;
transition: background 0.2s, transform 0.1s;
white-space: nowrap;
}
.btn-send:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-send:active:not(:disabled) { transform: scale(0.97); }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
/* ==================== 右侧简介栏 ==================== */
.sidebar-right {
width: var(--sidebar-width);
padding: 20px 16px;
overflow-y: auto;
border-right: none;
border-left: 1px solid var(--color-border);
}
.sidebar-right::-webkit-scrollbar { width: 4px; }
.sidebar-right::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 2px; }
.intro-title {
font-size: 16px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 2px solid var(--color-primary);
}
.intro-content h3 {
font-size: 13px;
font-weight: 600;
color: var(--color-primary);
margin-top: 16px;
margin-bottom: 6px;
}
.intro-content h3:first-child { margin-top: 0; }
.intro-content p { font-size: 13px; color: var(--color-text-secondary); line-height: 1.7; margin-bottom: 4px; }
.intro-content strong { color: var(--color-primary); }
.intro-content ul, .intro-content ol { padding-left: 18px; margin-bottom: 4px; }
.intro-content ul { list-style: disc; }
.intro-content ol { list-style: decimal; }
.intro-content li { font-size: 13px; color: var(--color-text-secondary); line-height: 1.8; }
/* ==================== 响应式适配 ==================== */
@media (max-width: 900px) { .sidebar-right { display: none; } }
@media (max-width: 640px) {
.sidebar-left { width: 60px; padding: 12px 6px; }
.btn-new-session span:last-child { display: none; }
.session-name { display: none; }
.btn-delete { opacity: 1; }
}
🔍 代码大白话拆解 :CSS 变量统一管颜色和尺寸,flex 布局实现三栏自适应,@keyframes typingDot 实现三个点交替跳动。
📚 核心技术点科普:
- CSS 变量 :
--color-primary: #4a6cf7定义,var(--color-primary)引用。改主题色只需改一处。 calc():calc(100vh - var(--header-height))动态计算内容区高度。@keyframes动画 :0%/60%/100%三个时间点状态,30%是弹起峰值。animation-delay错开启动时间,形成跳动效果。.btn-delete { opacity: 0 }+.session-item:hover .btn-delete { opacity: 1 }:删除按钮默认隐藏,悬停时才显示,界面更简洁。
⚙️ 见证成就感时刻
刷新浏览器,你应该看到完整的页面布局了------标题栏、左侧新建会话按钮、中间聊天输入框、右侧游戏规则。虽然按钮点了没反应(JS 功能函数还没写),但骨架和样式都已就绪!
🧭 第二关:会话列表管理
现在一步步给 JS 添加功能。先从会话管理开始。
2.1 定义状态变量 + API 请求封装
替换 index.js 的全部内容:
javascript
// ESM 导入:通过 importmap 映射,浏览器可直接使用裸模块名
import { createApp, ref, nextTick, onMounted } from 'vue'
// API 基础路径:为空表示前后端同源(同一域名和端口)
// 如果后端部署在其他地址,改为如 'http://localhost:8000'
const API_BASE = ''
// API 请求封装:集中处理请求头、错误判断和 JSON 解析
const api = {
async request(url, options = {}) {
// 发起 fetch 请求,默认带 JSON 请求头
const res = await fetch(`${API_BASE}${url}`, {
headers: { 'Content-Type': 'application/json' },
...options // 允许调用方覆盖 headers、method、body 等
})
// HTTP 状态码非 2xx 时抛出异常
if (!res.ok) throw new Error(`HTTP error: ${res.status}`)
// 解析响应体为 JSON 对象
return res.json()
},
// 获取会话列表 GET /api/session
getSessionList() { return api.request('/api/session') },
// 获取会话详情 GET /api/session/{sessionId}
getSessionDetail(sessionId) { return api.request(`/api/session/${sessionId}`) },
// 创建新会话 POST /api/session
createSession() { return api.request('/api/session', { method: 'POST' }) },
// 删除会话 DELETE /api/session/{sessionId}
deleteSession(sessionId) { return api.request(`/api/session/${sessionId}`, { method: 'DELETE' }) }
}
createApp({
setup() {
// --- 状态定义 ---
const sessionList = ref([]) // 会话ID列表
const currentSessionId = ref('') // 当前选中的会话ID
const currentMessages = ref([]) // 当前会话的消息列表
const inputMessage = ref('') // 输入框内容
const isAiTyping = ref(false) // AI是否正在回复
const isCreating = ref(false) // 是否正在创建会话
const messagesContainer = ref(null) // 消息容器DOM引用
const userScrolledUp = ref(false) // 用户是否主动上翻(暂停自动滚动)
// 后续功能函数在这里一个个添加......
return {
sessionList,
currentSessionId,
currentMessages,
inputMessage,
isAiTyping,
isCreating,
messagesContainer,
userScrolledUp
}
}
}).mount('#app')
🔍 代码大白话拆解 :api 对象封装了所有 HTTP 请求的公共逻辑,其他方法只需调 request 并传入不同路径和方法。setup() 里定义了所有响应式状态变量,目前还没写功能函数。userScrolledUp 用于标记用户是否主动上翻查看历史消息,配合 handleScroll 实现智能滚动。
📚 核心技术点科普:
fetchAPI :浏览器原生网络请求接口,基于 Promise,比XMLHttpRequest更简洁。- 封装请求方法 :公共逻辑(请求头拼接、错误处理)集中到
request一处,这是 DRY 原则。 - 接口路径与 HTTP 方法:每个 API 方法旁注释了对应的后端接口路径和 HTTP 方法,与后端路由一一对应。后端已在 Swagger UI 中验证通过,前端直接调用即可。
2.2 加载会话列表 + 格式化显示
在 setup() 中 return 之前添加:
javascript
/** 加载会话列表:调后端接口获取所有 session_id */
async function loadSessionList() {
try {
const res = await api.getSessionList()
// 后端统一返回 { code, msg, data },code=200 表示成功
if (res.code === 200) {
sessionList.value = res.data || []
}
} catch (error) {
alert('加载会话列表失败')
console.error('加载会话列表失败:', error)
}
}
/** 格式化会话名称:2026-07-28_11-34-11 → 07-28 11:34 */
function formatSessionName(sessionId) {
// session_id 格式:日期_时间,用 _ 分割
const parts = sessionId.split('_')
if (parts.length >= 2) {
const dateSegs = parts[0].split('-') // ['2026','07','28']
const timeSegs = parts[1].split('-') // ['11','34','11']
if (dateSegs.length === 3 && timeSegs.length >= 2) {
// 只取月-日 时:分,更简洁
return `${dateSegs[1]}-${dateSegs[2]} ${timeSegs[0]}:${timeSegs[1]}`
}
}
// 格式不匹配时原样返回
return sessionId
}
🔍 代码大白话拆解 :loadSessionList 调后端接口获取 session_id 列表,赋值给 sessionList,Vue 自动更新界面。formatSessionName 把 2026-07-29_14-22-47 转成 07-29 14:22 显示在侧栏。
📚 核心技术点科普:
- Vue 响应式自动更新 :
sessionList.value = res.data修改了 ref 的值,Vue 自动重新渲染v-for="item in sessionList"的列表。 - HTML 配合 :
{{ formatSessionName(item) }}在模板中调用格式化函数;:class="{ active: item === currentSessionId }"动态高亮当前选中项。
2.3 创建新会话
javascript
/** 创建新会话:调后端 POST /api/session 接口 */
async function createSession() {
// 防重复点击:正在创建中则忽略
if (isCreating.value) return
isCreating.value = true
try {
const res = await api.createSession()
if (res.code === 200) {
const newId = res.data
// 新会话插入列表顶部(最新在前)
sessionList.value.unshift(newId)
// 自动选中新创建的会话
await selectSession(newId)
}
} catch (error) {
alert('创建会话失败')
console.error('创建会话失败:', error)
} finally {
// 无论成功失败都重置创建状态
isCreating.value = false
}
}
🔍 代码大白话拆解 :调后端创建会话,成功后把新 ID 插入列表顶部(unshift),自动选中新会话。isCreating 防重复点击。
📚 核心技术点科普:
- 防重复提交 :
if (isCreating.value) return用布尔标志位防止并发操作,避免连点创建一堆空会话。 - HTML 配合 :
@click="createSession"绑定点击,:disabled="isCreating"创建期间禁用按钮,双重防护。 array.unshift:数组头部插入,保证新会话显示在最上方。
2.4 切换会话
javascript
/** 选择会话:切换到指定会话并加载历史消息 */
async function selectSession(sessionId) {
// AI 正在回复时不允许切换,避免丢失流式数据
if (isAiTyping.value) return
currentSessionId.value = sessionId
// 先清空消息区,避免显示上一个会话的残留内容
currentMessages.value = []
try {
// 调后端 GET /api/session/{sessionId} 获取完整会话数据
const res = await api.getSessionDetail(sessionId)
if (res.code === 200 && res.data) {
currentMessages.value = res.data.messages || []
await scrollToBottom()
}
} catch (error) {
alert('加载会话详情失败')
console.error('加载会话详情失败:', error)
}
}
🔍 代码大白话拆解:点击左侧会话项时触发。先检查 AI 是否在输入(避免中途切换丢数据),清空当前消息,加载选中会话的历史消息。
📚 核心技术点科普:
- 会话隔离:每个会话有独立的 session_id 和消息历史,切换就是"换聊天室"。
- HTML 配合 :
@click="selectSession(item)"点击列表项触发,v-for="(msg, index) in currentMessages"自动重新渲染聊天区。
2.5 删除会话
javascript
/** 删除会话:二次确认后调后端 DELETE /api/session/{sessionId} 接口 */
async function deleteSession(sessionId) {
// 二次确认,防止误删
if (!confirm('确定删除该会话吗?删除后不可恢复。')) return
try {
const res = await api.deleteSession(sessionId)
if (res.code === 200) {
// 从列表中过滤掉被删除的会话ID
sessionList.value = sessionList.value.filter(
id => id !== sessionId
)
// 如果删除的是当前会话,清空消息区
if (currentSessionId.value === sessionId) {
currentSessionId.value = ''
currentMessages.value = []
}
}
} catch (error) {
alert('删除会话失败')
console.error('删除会话失败:', error)
}
}
🔍 代码大白话拆解:删除前先弹出确认框,用户点"确定"才继续,点"取消"则直接返回不做任何操作。确认后调后端删除接口,成功后从列表中过滤掉被删的 ID,如果删的是当前会话则清空消息区。
📚 核心技术点科普:
array.filter:返回不含目标元素的新数组,函数式编程"不可变更新"常用方式。confirm()二次确认 :浏览器原生弹窗,返回true(确定)或false(取消)。在执行不可逆操作前加确认,是防止误操作的标准做法。- HTML 配合 :
@click.stop="deleteSession(item)"中.stop防止冒泡到外层<li>的 selectSession 事件。 - CSS 配合 :
.btn-delete { opacity: 0 }+.session-item:hover .btn-delete { opacity: 1 }删除按钮悬停时才显示。
2.6 页面初始化
javascript
// ==================== 生命周期 ====================
// 页面挂载完成后自动加载会话列表并选中第一个
onMounted(async () => {
await loadSessionList()
// 如果已有会话,自动选中最新的(列表第一个)
if (sessionList.value.length > 0) {
await selectSession(sessionList.value[0])
}
})
🔍 代码大白话拆解:页面加载时自动获取会话列表并选中第一个。
📚 核心技术点科普:
onMounted:Vue 组件挂载到 DOM 后触发的钩子,标准初始化时机。
2.7 滚动到底部工具函数
javascript
/** 滚动消息容器到底部:确保最新消息始终可见 */
async function scrollToBottom() {
// nextTick:等 Vue 完成 DOM 更新后再滚动
await nextTick()
// 用户主动上翻时不强制滚动,等用户滚回底部附近再恢复
if (userScrolledUp.value) return
if (messagesContainer.value) {
// scrollTop 设为 scrollHeight 即滚到最底部
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
/** 检测用户滚动行为:判断是否主动上翻查看历史消息 */
function handleScroll() {
const el = messagesContainer.value
if (!el) return
// 距离底部超过 100px,认为用户主动上翻
userScrolledUp.value = el.scrollHeight - el.scrollTop - el.clientHeight > 100
}
🔍 代码大白话拆解 :scrollToBottom 等 DOM 更新完后,如果用户没有主动上翻,就把聊天区滚到最底部;如果用户正在上翻看历史消息,就跳过滚动,不抢夺用户的阅读位置。handleScroll 是滚动事件处理器------用户每次滚动聊天区时触发,计算当前距离底部有多远,超过 100px 就认为用户主动上翻了,设置 userScrolledUp 为 true;滚回底部附近后自动恢复为 false,后续新消息又会被自动滚到视野中。
📚 核心技术点科普:
nextTick:Vue 的nextTick在 DOM 更新完成后执行回调,确保滚动到最新消息位置。ref="messagesContainer":模板引用。messagesContainer.value就是那个 DOM 元素。userScrolledUp:智能滚动标志位。AI 流式回复时会频繁调用scrollToBottom(),如果用户想上翻查看历史消息,每次都被强制滚回底部体验很差。加了if (userScrolledUp.value) return后,用户上翻时自动暂停滚动,等用户滚回底部附近又自动恢复。handleScroll判断逻辑 :scrollHeight - scrollTop - clientHeight计算的是"当前滚动位置距离底部的像素差",超过 100px 就认为用户主动上翻。阈值 100px 既不会太灵敏(微小抖动误触发),也不会太迟钝(滚到半中间还不算上翻)。
2.8 更新 return 暴露
javascript
return {
sessionList,
currentSessionId,
currentMessages,
inputMessage,
isAiTyping,
isCreating,
messagesContainer,
userScrolledUp,
handleScroll,
createSession,
deleteSession,
selectSession,
formatSessionName
}
🔍 代码大白话拆解 :所有在 HTML 模板中引用的变量和函数,必须 return 出去,否则模板找不到它们。新增 userScrolledUp(智能滚动标志位)和 handleScroll(滚动事件处理器),配合 HTML 中 @scroll="handleScroll" 实现用户上翻时暂停自动滚动。
📚 核心技术点科普:
- setup() return 清单:Vue 3 Composition API 常见踩坑点。每新增一个模板用到的变量或函数,别忘了加上。
handleScroll必须暴露 :它在 HTML 模板中通过@scroll="handleScroll"被引用,所以必须 return 出去,否则 Vue 模板找不到这个函数。
⚙️ 见证成就感时刻
刷新浏览器,点击「新建会话」,左侧应该出现一条新的会话记录!点击可以切换,点 × 可以删除。不过聊天区还不能发消息------那是下一关的内容。
🧭 第三关:实现流式对话
最核心的功能------让 AI 回复像打字一样逐字出现。
3.1 发送消息 + "正在输入"动画
在 setup() 中添加 sendMessage 函数:
javascript
// ==================== 消息发送与SSE接收 ====================
/** 发送消息:用户输入 → 显示消息 → SSE流式接收AI回复 */
async function sendMessage() {
const msg = inputMessage.value.trim()
// 三重守卫:内容为空、未选会话、AI正在回复时都不允许发送
if (!msg || !currentSessionId.value || isAiTyping.value) return
// 清空输入框
inputMessage.value = ''
// 立即在界面上显示用户消息
currentMessages.value.push({ role: 'user', content: msg })
// 添加 AI 占位消息(空内容触发 typing 动画)
currentMessages.value.push({ role: 'assistant', content: '' })
const aiMsgIndex = currentMessages.value.length - 1
isAiTyping.value = true
await scrollToBottom()
🔍 代码大白话拆解 :用户点"发送"后,先清空输入框,立刻显示用户消息。同时追加一条 AI 的"空消息"------因为内容为空,HTML 模板中的 v-if="msg.role === 'assistant' && msg.content === ''" 成立,渲染出"对方正在输入..."跳动动画。记录 AI 消息索引 aiMsgIndex,后续 SSE 数据到了就往这个位置填充。
📚 核心技术点科普:
-
先渲染再请求:不等 AI 回复,先让用户看到消息和"正在输入"提示,消除"点了没反应"的空白等待。
-
占位消息技巧 :给 AI 消息先占个位置,后续只需修改
content属性,Vue 响应式系统自动更新 DOM。 -
HTML 配合 :
html<span v-if="msg.role === 'assistant' && msg.content === ''" class="typing-indicator"> 对方正在输入<span class="dot">.</span> <span class="dot">.</span><span class="dot">.</span> </span> <span v-else>{{ msg.content }}</span>content为空显示跳动动画,有内容后自动切为文字。JS 只改数据,DOM 交给 Vue。
3.2 使用 fetchEventSource 接收 SSE 流
现在要用到 SSE 流式接收了,先在 index.js 顶部添加 fetchEventSource 的导入:
javascript
// 在文件顶部 import { createApp, ... } from 'vue' 之后添加
import { fetchEventSource } from '@microsoft/fetch-event-source'
importmap 中已提前声明了
@microsoft/fetch-event-source的 CDN 映射,这里直接 import 即可。
继续在 sendMessage 函数中添加:
javascript
try {
// 使用 fetchEventSource 发送请求并自动解析 SSE 流式响应
// 后端接口:PUT /api/session/{sessionId}
await fetchEventSource(`${API_BASE}/api/session/${currentSessionId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
// 请求体:与后端 ChatMessage 模型对应
body: JSON.stringify({
session_id: currentSessionId.value,
message: msg
}),
// 连接建立成功后的回调,检查响应状态
async onopen(response) {
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
},
// 每收到一条 SSE 消息时回调
// ev.data 是后端推送的 JSON 字符串(已自动去掉 'data: ' 前缀和换行符)
onmessage(ev) {
const chunk = JSON.parse(ev.data)
if (chunk.content) {
// 追加内容到 AI 消息,Vue 响应式自动更新 DOM
currentMessages.value[aiMsgIndex].content += chunk.content
scrollToBottom()
}
},
// 连接关闭时回调:fetchEventSource 默认会自动重连
// 本项目是单次请求-响应模式,不需要重连,抛出异常阻止重连
onclose() {
throw new Error('Stream closed')
},
// 出错时回调:抛出异常阻止自动重连
onerror(err) {
throw err
}
})
🔍 代码大白话拆解 :用 fetchEventSource 替代手写 fetch + ReadableStream。它自动处理 SSE 协议解析------把 data: {...}\n\n 格式的原始文本自动拆成一条条消息,onmessage 回调的 ev.data 就是去掉 data: 前缀的 JSON 字符串,直接 JSON.parse 即可。不再需要手动管 buffer、split('\n\n')、lines.pop() 等粘包/半包逻辑。
📚 核心技术点科普:
fetchEventSourcevs 手写 fetch :手写fetch + getReader()需要自己处理ReadableStream、TextDecoder、缓冲区、粘包/半包解析,代码量大且容易出错。fetchEventSource把这些脏活全包了,onmessage拿到的就是解析好的数据,更简洁可靠。- 四个回调函数 :
onopen:连接建立后调用,适合检查 HTTP 状态码。非 2xx 就抛异常,整个fetchEventSource调用会 reject。onmessage:每收到一条 SSE 消息调用。ev.data是后端推的 JSON 内容(已自动去掉data:前缀)。onclose:连接关闭时调用。关键 :fetchEventSource默认会自动重连!本项目是单次请求-响应,AI 说完就结束,不需要重连,所以抛异常阻止重连。onerror:出错时调用。同理,抛异常阻止自动重连。
- 阻止重连的技巧 :在
onclose/onerror中throw异常,fetchEventSource就不会重试。这是官方推荐的做法。 - Vue 响应式更新 :
currentMessages.value[aiMsgIndex].content += chunk.content修改对象属性,Vue 3 自动检测变化并更新 DOM,实现 AI "打字"效果。 - 智能滚动 :
onmessage中每次追加内容后调用scrollToBottom(),该函数内部已通过userScrolledUp判断实现智能滚动------用户上翻时不会强制滚回底部,滚回底部附近后自动恢复跟随最新消息。
3.3 错误处理与状态重置
javascript
} catch (error) {
// 区分正常结束和真正的错误
// onclose 抛出的 'Stream closed' 是正常结束信号,不需要弹窗
if (error.message !== 'Stream closed') {
alert('发送消息失败')
console.error('发送消息失败:', error)
// 防御性清理:移除空的 AI 占位消息(如果还是空的话)
const lastMsg = currentMessages.value[currentMessages.value.length - 1]
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.content === '') {
currentMessages.value.pop()
}
// 添加一条错误提示消息,让用户知道发生了什么
currentMessages.value.push({
role: 'assistant',
content: '发送失败,请重试'
})
}
} finally {
// 无论成功失败,都重置 AI 输入状态,解锁输入框
isAiTyping.value = false
await scrollToBottom()
}
}
🔍 代码大白话拆解 :catch 中区分两种情况------onclose 抛出的 'Stream closed' 是 AI 说完的正常结束,不是错误;其他错误(网络断开等)才弹窗提示并清理占位消息。finally 无论成功失败都执行,重置 isAiTyping 解锁输入框。
📚 核心技术点科普:
try/catch/finally:try放可能出错的代码,catch处理异常,finally无论如何执行------资源清理的标准模式。- 区分正常结束与错误 :
fetchEventSource的onclose抛异常来阻止重连,这个异常会被catch捕获。通过检查error.message区分正常结束和真正的网络错误,避免给用户弹无意义的提示。 - 防御性清理:真正出错时检查 AI 占位消息是否还是空的,是就移除,避免残留空气泡。
3.4 更新 return 暴露
javascript
return {
sessionList,
currentSessionId,
currentMessages,
inputMessage,
isAiTyping,
isCreating,
messagesContainer,
userScrolledUp,
handleScroll,
createSession,
deleteSession,
selectSession,
sendMessage,
formatSessionName
}
🔍 代码大白话拆解 :sendMessage 对应 HTML 中的 @click="sendMessage" 和 @keyup.enter="sendMessage"。handleScroll 对应消息容器的 @scroll="handleScroll",userScrolledUp 是智能滚动标志位,两者配合实现用户上翻时暂停自动滚动。
⚙️ 见证成就感时刻
确保后端服务在运行,刷新浏览器:
- 点击「新建会话」
- 在输入框输入"开始游戏",回车发送
- 观察 AI 回复逐字出现,就像真人在打字!
- 继续猜测汉字,体验 3 次机会的游戏流程
🏁 终点站:全线通电
完整测试步骤
- 启动后端:
cd hanzi/api && python main.py - 浏览器访问
http://127.0.0.1:8000/ - 新建会话 → 输入"开始游戏" → 观察 AI 逐字回复
- 切换会话 → 查看历史消息
- 删除会话 → 确认从列表消失
复盘:这个项目教了我们什么?
1. Vue 3 Composition API
- 用
ref()创建响应式数据,数据变 DOM 自动更新 - 用
setup()+ return 组织代码,按功能分组而非按选项类型分组 - 用
onMounted在合适的时机做初始化
2. 模板与交互
v-for+:key高效渲染列表v-if/v-else条件渲染("正在输入"动画 vs 文字内容)v-model双向绑定输入框@click/@click.stop事件绑定与冒泡阻止:disabled动态控制交互状态ref="xxx"模板引用直接操作 DOM
3. SSE 流式数据解析
- 用
fetchEventSource库自动解析 SSE 协议,无需手写 ReadableStream + TextDecoder - 理解 四个回调(onopen/onmessage/onclose/onerror)的职责和执行时机
- 通过 onclose/onerror 抛异常 阻止自动重连,适配单次请求-响应模式
- 用 Vue 响应式赋值 实现逐字渲染效果
🚀 进阶优化方向
项目跑通只是起点,以下是真实产品必须解决的问题,可作为练手挑战:
1. AI 回复支持 Markdown 渲染
现状问题:AI 回复中的换行、加粗、列表等 Markdown 格式被当纯文本显示,排版效果差。
优化方向 :引入轻量 Markdown 渲染库(如 marked),在显示 AI 消息时将 Markdown 转为 HTML:
javascript
import { marked } from 'marked'
// 模板中将 {{ msg.content }} 改为 v-html
// <span v-else v-html="renderMarkdown(msg.content)"></span>
function renderMarkdown(content) {
return marked(content)
}
注意:使用
v-html需要做好 XSS 防护。marked默认会转义 HTML 标签,但建议配合DOMPurify库做二次净化。
2. 消息发送失败后的重试机制
现状问题:消息发送失败后只显示"发送失败,请重试",但没有一键重试按钮,用户必须重新输入内容再发。
优化方向:给失败的消息气泡添加重试按钮,点击后用原始内容重新发送:
javascript
// 错误消息增加 retryData 标记
currentMessages.value.push({
role: 'assistant',
content: '发送失败,请重试',
isError: true,
retryMsg: msg // 保存原始消息内容
})
// 重试方法
async function retryMessage(index) {
const retryMsg = currentMessages.value[index].retryMsg
currentMessages.value.splice(index, 1) // 移除错误提示
inputMessage.value = retryMsg // 回填输入框
await sendMessage() // 重新发送
}
3. 长对话的性能优化:虚拟滚动
现状问题 :当前所有消息都用 v-for 渲染到 DOM 中,如果对话有几百条消息,DOM 节点过多会导致页面卡顿。
优化方向 :使用虚拟滚动(Virtual Scroll)技术,只渲染可视区域内的消息节点。可引入 vue-virtual-scroller 等库,或自行实现简单的分页加载(滚动到顶部时加载更早的消息)。
这只是一个"适合新手深入理解的硬核小 Demo"。如果遇到报错,把错误信息贴到评论区,咱们一起 debug!