Vue3 猜字游戏前端实战:从零搭建聊天交互界面

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 导入 createAppref,创建应用、创建响应式变量、挂载到 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)">&times;</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 事件绑定。@clickv-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 实现智能滚动。

📚 核心技术点科普

  • fetch API :浏览器原生网络请求接口,基于 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 自动更新界面。formatSessionName2026-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 就认为用户主动上翻了,设置 userScrolledUptrue;滚回底部附近后自动恢复为 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 即可。不再需要手动管 buffersplit('\n\n')lines.pop() 等粘包/半包逻辑。

📚 核心技术点科普

  • fetchEventSource vs 手写 fetch :手写 fetch + getReader() 需要自己处理 ReadableStreamTextDecoder、缓冲区、粘包/半包解析,代码量大且容易出错。fetchEventSource 把这些脏活全包了,onmessage 拿到的就是解析好的数据,更简洁可靠。
  • 四个回调函数
    • onopen:连接建立后调用,适合检查 HTTP 状态码。非 2xx 就抛异常,整个 fetchEventSource 调用会 reject。
    • onmessage:每收到一条 SSE 消息调用。ev.data 是后端推的 JSON 内容(已自动去掉 data: 前缀)。
    • onclose:连接关闭时调用。关键fetchEventSource 默认会自动重连!本项目是单次请求-响应,AI 说完就结束,不需要重连,所以抛异常阻止重连。
    • onerror:出错时调用。同理,抛异常阻止自动重连。
  • 阻止重连的技巧 :在 onclose / onerrorthrow 异常,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/finallytry 放可能出错的代码,catch 处理异常,finally 无论如何执行------资源清理的标准模式。
  • 区分正常结束与错误fetchEventSourceonclose 抛异常来阻止重连,这个异常会被 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 是智能滚动标志位,两者配合实现用户上翻时暂停自动滚动。

⚙️ 见证成就感时刻

确保后端服务在运行,刷新浏览器:

  1. 点击「新建会话」
  2. 在输入框输入"开始游戏",回车发送
  3. 观察 AI 回复逐字出现,就像真人在打字!
  4. 继续猜测汉字,体验 3 次机会的游戏流程

🏁 终点站:全线通电

完整测试步骤

  1. 启动后端:cd hanzi/api && python main.py
  2. 浏览器访问 http://127.0.0.1:8000/
  3. 新建会话 → 输入"开始游戏" → 观察 AI 逐字回复
  4. 切换会话 → 查看历史消息
  5. 删除会话 → 确认从列表消失

复盘:这个项目教了我们什么?

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!

相关推荐
春波petal5 小时前
Vue3防抖搜索:从Lodash到customRef全解析
vue.js·vue3·防抖搜索
达子6666 小时前
第22章_HarmonyOs开发图解之 蓝牙
vue.js
无人生还7 小时前
从 Vue3 到 React · 快速上手系列第 9 篇:自定义 Hook(对标 Composables)
前端·vue.js·react.js
别怪我很水7 小时前
Vue element admin 浏览器本地存储 localStorage、useStorage
前端·javascript·vue.js
猫猫不是喵喵.18 小时前
Vue3 Props 属性
前端·javascript·vue.js
小罗水1 天前
第17章 后台管理端与演示支持
javascript·vue.js·ecmascript
猫猫不是喵喵.1 天前
Vue3 中 computed 计算属性与 watch、watchEffect 监听
前端·javascript·vue.js
En^_^Joy1 天前
Vue项目创建与入口配置全攻略
前端·vue.js·arcgis
猫猫不是喵喵.1 天前
Vue2 的 Vuex 状态管理与 Vue3 的 Pinia 状态管理
前端·javascript·vue.js