前端开发日志——工程骨架 + 会话启动 + 基础联调(Vue3)

0. 本周定位(我的负责范围)

这次实训课设是“AI 狼人杀”。后端用 FastAPI + LangGraph 驱动对局状态机,并通过 SSE(text/event-stream)把大模型输出“边生成边推送”给前端。我的主要工作是把前端页面、状态管理、接口联调先跑通,让浏览器端能完成最小闭环:

  • 访问大厅页能看到服务是否在线
  • 能创建会话(拿到 thread_id)
  • 能启动游戏(拿到初始 state/next)
  • 能进入游戏页看到阶段、身份、消息列表

这一周我把目标定为“先能跑起来、能连上后端、结构别太乱”,为后两周做功能与体验打基础。


1. 技术栈与依赖(真实项目)

前端位于 frontend/,使用 Vite + Vue3 + TS,并引入 Pinia、Vue Router、Element Plus、Axios。frontend/package.json 里实际依赖如下(摘取关键部分):

{
  "dependencies": {
    "axios": "^1.7.5",
    "element-plus": "^2.8.6",
    "pinia": "^2.1.7",
    "vue": "^3.4.27",
    "vue-router": "^4.3.0"
  }
}

2. 工程入口与插件接入(Vue3/Pinia/Router/Element Plus)

先把工程“主干”接好:创建 app → 挂 Pinia → 挂路由 → 挂 Element Plus。这样后续写页面时不用重复做全局注册。

文件:frontend/src/main.ts

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

3. 路由规划:大厅 / 游戏两页先跑通

为了减少第一周联调的复杂度,我把页面先压到两页:

  • / → Lobby.vue(大厅,创建会话/进入游戏)
  • /game → Game.vue(游戏页,展示状态与推进)

文件:frontend/src/router.ts

import { createRouter, createWebHistory } from 'vue-router'
import Lobby from './views/Lobby.vue'
import Game from './views/Game.vue'

const routes = [
  { path: '/', component: Lobby },
  { path: '/game', component: Game }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

4. 会话与对局状态:Pinia store 设计(thread_id + state + next)

后端用 thread_id 区分一局会话,所以前端 store 的核心字段就是 threadId。另外后端状态机每步会返回:

  • state:当前可展示的对局状态(messages/phase/roles 等)
  • next:下一步节点(例如 wolf_turn / witch_turn)

我把这些放在 frontend/src/stores/session.ts,并做了 localStorage 持久化(刷新页面后还能找回 thread_id,演示时很实用)。

import { defineStore } from 'pinia'

export interface GameState {
  messages: { type: string, content: string }[]
  ai_thoughts: string[]
  phase: string
  alive_players: string[]
  roles: Record<string, string>
  night_actions: Record<string, any>
  seer_knowledge: Record<string, string>
  votes: Record<string, string>
  winner?: string | null
}

export const useSessionStore = defineStore('session', {
  state: () => ({
    threadId: '' as string,
    state: null as GameState | null,
    next: [] as string[],
    loading: false as boolean
  }),
  actions: {
    setThread(id: string) {
      this.threadId = id
      localStorage.setItem('thread_id', id)
    },
    loadThread() {
      const id = localStorage.getItem('thread_id')
      if (id) this.threadId = id
    },
    setState(s: GameState, next: string[]) {
      this.state = s
      this.next = next || []
    },
    setLoading(v: boolean) {
      this.loading = v
    }
  }
})

本周体会:Pinia 的好处是页面不需要层层传 props,像 threadId/loading/state 这种“全局共享”的东西集中起来后,后面拆组件会更顺。


5. API 设计与联调:先把能通的接口跑起来

后端(server.py)真实提供的接口(与前端对接的):

  • GET /health:服务健康检查
  • POST /session:创建会话,返回 { thread_id }
  • POST /start:启动/获取初始状态(第一次会返回 SSE,后续可能直接 JSON)
  • GET /state?thread_id=...:获取当前状态
  • POST /advance:推进状态机(SSE 流式输出)
  • POST /action/wolf:狼人行动(SSE)
  • POST /action/witch:女巫行动(SSE)

前端封装在 frontend/src/api.ts。我第一周主要做了两类请求:

1)普通 JSON:用 axios(如 /health、/session、/state)
2)SSE 流:用 fetch + ReadableStream 手动解析(因为 axios 不太适合直接处理浏览器端 stream)

例如创建会话与取状态:

import axios from 'axios'

const API = axios.create({
  baseURL: 'http://localhost:8000'
})

export async function createSession(): Promise<string> {
  const { data } = await API.post('/session')
  return data.thread_id
}

export async function getState(threadId: string) {
  const { data } = await API.get('/state', { params: { thread_id: threadId } })
  return data
}

6. 大厅页实现:健康检查 + 创建会话 + 启动并跳转

大厅页(frontend/src/views/Lobby.vue)做了三件事:

  • onMounted:请求 /health,给用户显示“在线/离线”
  • “创建新会话”按钮:调用 createSession(),把 thread_id 写入 Pinia + localStorage
  • “进入游戏”按钮:如果没有 thread_id 先创建;再调用 startGame(threadId) 拿到初始 state/next,最后跳转 /game

对应逻辑(关键片段):

onMounted(async () => {
  try {
    const res = await fetch('http://localhost:8000/health')
    healthState.value = res.ok ? 'ok' : 'error'
  } catch {
    healthState.value = 'error'
  }
})

const newSession = async () => {
  const tid = await createSession()
  store.setThread(tid)
}

const start = async () => {
  if (!store.threadId) await newSession()
  store.setLoading(true)
  try {
    const data = await startGame(store.threadId!)
    store.setState(data.state, data.next)
    router.push('/game')
  } finally {
    store.setLoading(false)
  }
}

7. 本周遇到的问题与解决

7.1 接口返回既可能是流,也可能直接是 JSON

后端 /start 的逻辑是:如果没有 state,就走流式;如果已有 state,可能直接返回 JSON。为此我在 fetchStream 里加了“兜底解析”,当流结束后 buffer 里如果还能解析出 JSON,就 resolve。

7.2 CORS/端口问题的处理策略

本项目后端开放 allow_origins=["*"],所以开发环境跨域压力不大。但我也意识到:前端 baseURL 和 fetch URL 目前写死 http://localhost:8000,后两周需要抽成可配置项(否则换电脑/换端口会很痛苦)。


8. 小结(第 1 周体会)

第一周让我感受最深的是:前端联调的关键不是“页面写得多漂亮”,而是把后端的真实协议与数据结构搞明白。尤其 SSE 流式输出,如果解析策略不对,用户看到的就是“卡住”“没反应”。这周把工程骨架与最小闭环打通后,后面写游戏交互与体验优化才有意义。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐