项目实训——Werewolf-Agent 多智能体狼人杀前端开发日志Vue3
前端开发日志——工程骨架 + 会话启动 + 基础联调(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 流式输出,如果解析策略不对,用户看到的就是“卡住”“没反应”。这周把工程骨架与最小闭环打通后,后面写游戏交互与体验优化才有意义。
更多推荐
所有评论(0)