先花 20 分钟把"旧肌肉记忆"和"新纪律"的差清理掉。你不是从零学 Vue,你是要把脑子里 Vue2 的组织方式换成 Vue3 的。
| 组件 | 锁定 | 为什么是它 |
|---|---|---|
| Vue | 3.5.x(安装写 ^3.5,取最新补丁 3.5.44) | 当前稳定线;组合式 API 与 <script setup> 已是一等公民;3.5 修复了响应式系统多轮边界问题。 |
| TypeScript | 7.0.2(安装写 ^7) | 注意:TS 7 是新主版本,换成了原生编译器(不再是 tsc over JS),速度大幅提升。用法与 5.x 的源码写法一致,装 typescript@^7 即可,不要停在 5.x。 |
| Vite | 8.3.4(安装写 ^8.3) | 开发服务器用原生 ESM,冷启动毫秒级;构建用 Rolldown 系。新项目不再用 vue-cli/webpack。 |
| pnpm | 12.11.2 | 全系列包管理器统一 pnpm;严格 node_modules 结构能逼出"幽灵依赖"问题(见 §10)。 |
| Node.js | v24.21.0(Active LTS) | 跑 Vite 8 的地基;不要用 Current 线。安装见 09 模块。 |
| 配套库 | vue-router 4 / Pinia / axios | 直接 pnpm add,版本号不手填,由 pnpm install 解析后写进 pnpm-lock.yaml——锁文件才是唯一事实来源(08 §7 规则 3)。 |
| 维度 | 你记忆中的 Vue2 / 选项式 | 2026 的 Vue3 / script setup |
|---|---|---|
| 组织方式 | data() / methods / computed / watch 四块分散,一个功能横跨四个选项。 | 按功能点聚合:一个状态 + 它的读写函数写在一起。组合式 API(Composition API)。 |
| 组件写法 | export default { data(){}, methods:{} }。 | <script setup lang="ts"> 顶层变量直接暴露给模板,无 export default。 |
| 响应式取值 | this.count 直接用,赋值即更新。 | ref 包装后 JS 里必须 .value,模板里自动解包省掉 .value。 |
| this | this.$emit / this.$refs / this.router 满天飞。 | 没有 this。用 import 进来的函数(useRouter、store)。 |
| 状态管理 | Vuex(state/mutations/actions)。 | Pinia:概念更少,直接 state + actions,原生 TS 支持。 |
| 路由 | Vue Router 3(new VueRouter)。 | Vue Router 4:createRouter + createWebHistory,路由组件懒加载。 |
| 模板能力 | 单一根节点。 | 多根片段(Fragments)、Teleport、Suspense;模板语法你基本都会。 |
| 构建/开发 | vue-cli + webpack,冷启动几十秒。 | Vite:dev 秒开,改文件浏览器即时热更新。 |
| TS 支持 | 选项式写 TS 很别扭。 | script setup + 泛型 defineProps/defineEmits,TS 是一等公民——这就是你要补的纪律。 |
一句话转换口诀:把"在 Vue2 里 this.xxx"的思路,换成"在 setup 里 import 什么、声明什么 ref"。模板那层(插值、指令、事件)你几乎不用重学。
工作台的前端放在 workbench/web/(与 server/ 平级,见 11 模块目录契约)。下面整条命令序列照抄即可,Windows PowerShell 里执行。
node -v # 期望 v24.21.0(或 24.x 线) pnpm -v # 期望 12.11.2
cd D:\你的代码目录\workbench
pnpm create vite web --template vue-ts
# 上面命令会创建 web/ 目录,内含 vue + ts + vite 骨架
cd web
pnpm add vue@^3.5 pnpm add vue-router pinia axios pnpm add -D typescript@^7 vite@^8.3 @vitejs/plugin-vue pnpm install
pnpm dev # 默认 http://localhost:5173
workbench/web/ ├── src/ │ ├── types/ # 与后端契约对齐的 TS 类型(api.ts) │ ├── api/ # request.ts(axios 实例) + auth.ts / task.ts │ ├── stores/ # Pinia:user.ts │ ├── router/ # index.ts(路由表 + 守卫) │ ├── views/ # LoginView.vue / WorkbenchView.vue │ ├── App.vue │ └── main.ts ├── vite.config.ts # dev proxy /api → localhost:8080 ├── tsconfig.json └── package.json / pnpm-lock.yaml # 锁文件,入库
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
// 前端代码里写 /api/xxx,开发服务器转发到 Go 后端
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/healthz': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
为什么必须走 proxy:开发时前端在 5173、后端在 8080,直接跨域会触发 CORS。让 Vite 把 /api 开头的请求代理到 8080,浏览器眼里就是同源,永远不要在前端代码里写 http://localhost:8080(见 §10 CORS 条)。
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
只讲工作台会用到的最小集。不要去背全部 API——用不到的背了也会忘。
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
// ref:基本类型或需要整体替换的对象。JS 里用 .value,模板里不用。
const count = ref(0)
count.value++ // JS 操作:必须 .value
// reactive:对象的深层响应式。访问属性不需要 .value。
const form = reactive({ username: '', password: '' })
// computed:派生状态,缓存,依赖变才重算。
const isEmpty = computed(() => form.username.trim() === '')
// watch:监听某个 ref / getter,变化时执行副作用。
watch(count, (newVal, oldVal) => {
console.log('count 变了', oldVal, '->', newVal)
})
</script>
<template>
<button @click="count++">点了 {{ count }} 次</button>
<input v-model="form.username" placeholder="用户名">
<p v-if="isEmpty">用户名为空</p>
</template>
| 选项式(Vue2) | 组合式(<script setup>) | 工作台里干嘛用 |
|---|---|---|
| beforeCreate / created | setup 顶层代码本身 | 组件创建即执行,直接写在 <script setup> 顶层。 |
| mounted | onMounted | 页面加载数据的固定位置(§8 任务列表在此 fetch)。 |
| beforeUnmount / unmounted | onUnmounted | 清定时器、清全局监听。 |
// 祖先组件:提供 import { provide } from 'vue' provide('appTitle', '我的工作台') // 深层后代组件:注入(不知道中间传了几层) import { inject } from 'vue' const appTitle = inject('appTitle', '默认标题') // 第二参数是默认值
| 场景 | 方案 | 说明 |
|---|---|---|
| 父 → 子 | props | defineProps<{...}>()(泛型写法见 §4)。 |
| 子 → 父 | emit | defineEmits<{...}>(),父组件 @事件名 监听。 |
| 父 v-model 双向绑子 | defineModel / modelValue+update | 表单组件常用;工作台初期可不写。 |
| 跨多层级 | provide / inject | 主题、全局配置。 |
| 跨页面全局状态 | Pinia store | 登录态、用户信息(§6)。绝大多数"跨组件共享"走这里。 |
| 父调子方法 | defineExpose + ref | 少用;能用 props/emit 就别用。 |
| 任意组件互发事件 | 事件总线(mitt) | 不推荐,工作台禁用——状态难追踪。 |
纪律:工作台里组件通信只用三种——父子 props/emit、跨层 provide/inject、全局 Pinia。不引事件总线,不用 $root,状态来源只有 store 一条。
这是你这次闭关真正要补的东西。原则只有一条:与后端契约对齐的类型,集中定义在一处,全项目复用;组件 props/emits 一律泛型化,禁止裸 any。
这是前端与 workbench-contract.md 的唯一对齐点。后端字段改了,这里先改。禁止擅自增减字段。
// src/types/api.ts —— 与 workbench-contract.md 严格对齐 // 后端统一响应:{ code, data, msg } export interface ApiResponse<T = unknown> { code: number data: T msg: string } // GET /api/v1/users 返回的用户(对应后端 GORM User) export interface User { id: number username: string created_at: string } // POST /api/v1/auth/login 返回 { token, user } export interface LoginResult { token: string user: User } // 任务(对应后端 GORM Task) export interface Task { id: number title: string note: string done: boolean created_at: string updated_at: string } // 新建任务的请求体:POST /api/v1/tasks { title, note, done } export interface TaskPayload { title: string note: string done: boolean }
<script setup lang="ts">
import type { Task } from '../types/api'
// 父组件传进来的:一个 Task 对象 + 序号;disabled 可选
const props = defineProps<{
task: Task
index: number
disabled?: boolean
}>()
// 子组件向外抛的:toggle 传 id、remove 传 id
const emit = defineEmits<{
(e: 'toggle', id: number): void
(e: 'remove', id: number): void
}>()
function onClickToggle() {
emit('toggle', props.task.id) // 传错字段/类型,TS 立刻报错
}
</script>
对比旧写法 props: ['task']——传错字段名、漏传必填项,旧写法运行时才炸;泛型写法编译期就拦下来。这就是纪律的价值。
const STATUS = ['pending', 'done'] as const type Status = typeof STATUS[number] // = 'pending' | 'done' // 错误:不加 as const,STATUS 被推断成 string[],Status 变成 string
unknown 再收窄,不要 any 一把梭。http<Task[]>(...),不标就是 unknown,等于没类型(见 §7)。toRefs(见 §10 "ref 解包丢失"条)。两个页面:/login(公开)和 /tasks(需登录)。守卫判断 token,未登录重定向回登录页,并把目标地址塞进 query,登录后回跳。
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'), // 懒加载
},
{
path: '/tasks',
name: 'tasks',
component: () => import('../views/WorkbenchView.vue'),
meta: { requiresAuth: true, roles: ['admin', 'user'] }, // RBAC 预留位
},
{ path: '/', redirect: '/tasks' },
{ path: '/:pathMatch(.*)*', redirect: '/tasks' },
],
})
// 全局前置守卫
router.beforeEach((to) => {
const userStore = useUserStore()
// 1. 要登录但没登录 → 跳登录,记下目标路径
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
// 2. 已登录还访问登录页 → 直接进任务页
if (to.name === 'login' && userStore.isLoggedIn) {
return { path: '/tasks' }
}
// 3. roles 校验(阶段三 RBAC 再启用;现在不拦)
return true
})
export default router
() => import(...),路由组件单独打包,首屏更快。全局只放"登录态"这种真正跨页面的东西:token + userinfo,刷新页面不能丢,所以手写 localStorage 持久化(不引持久化插件,少一个依赖少一个坑)。
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '../types/api'
const TOKEN_KEY = 'wb_token'
const USER_KEY = 'wb_user'
export const useUserStore = defineStore('user', () => {
// 初始值从 localStorage 读,刷新不丢
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || '')
const user = ref<User | null>(
localStorage.getItem(USER_KEY)
? (JSON.parse(localStorage.getItem(USER_KEY)!) as User)
: null,
)
const isLoggedIn = computed(() => !!token.value)
function setAuth(t: string, u: User) {
token.value = t
user.value = u
localStorage.setItem(TOKEN_KEY, t)
localStorage.setItem(USER_KEY, JSON.stringify(u))
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
return { token, user, isLoggedIn, setAuth, logout }
})
在组件里用:const userStore = useUserStore(),然后 userStore.isLoggedIn / userStore.setAuth(...)。store 里的 ref 在组件中自动解包,不用 .value。
工程化的标志:全项目只建一个 axios 实例,请求自动带 token,响应自动拆 code/data/msg,401 自动清登录态跳登录。业务代码里不再写任何 headers、不再判 code。
import axios from 'axios'
import type { AxiosRequestConfig } from 'axios'
import { useUserStore } from '../stores/user'
import type { ApiResponse } from '../types/api'
const request = axios.create({
baseURL: '/api/v1', // 代码里都写 /api/v1/...,dev 走 vite proxy
timeout: 10000,
})
// 请求拦截器:自动带上 Bearer token
request.interceptors.request.use((config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
// 响应拦截器:统一拆 { code, data, msg }
request.interceptors.response.use(
(resp) => {
const body = resp.data as ApiResponse
if (body.code === 0) {
return body.data // 业务成功:直接把 data 交出去
}
// code 非 0 = 业务错误,msg 是人类可读信息
return Promise.reject(new Error(body.msg || '业务错误'))
},
(error) => {
// HTTP 层错误(401/500/网络断)
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
// 已经在登录页就不要再跳,避免循环(见 §10)
if (!window.location.pathname.startsWith('/login')) {
const redirect = encodeURIComponent(window.location.pathname)
window.location.href = `/login?redirect=${redirect}`
}
}
const msg = error.response?.data?.msg || error.message || '网络错误'
return Promise.reject(new Error(msg))
},
)
// 类型化的请求函数:业务代码一律用 http<T>,不用裸 request
export async function http<T>(config: AxiosRequestConfig): Promise<T> {
return request.request(config) as unknown as Promise<T>
}
export default request
// src/api/auth.ts import { http } from './request' import type { LoginResult, User } from '../types/api' // POST /api/v1/auth/login,body { username, password } export function login(username: string, password: string) { return http<LoginResult>({ url: '/auth/login', method: 'POST', data: { username, password }, }) } // GET /api/v1/users(管理功能演示,工作台预留) export function fetchUsers() { return http<User[]>({ url: '/users', method: 'GET' })
// src/api/task.ts —— 端点严格按契约,不新增 import { http } from './request' import type { Task, TaskPayload } from '../types/api' export function fetchTasks() { // GET /tasks return http<Task[]>({ url: '/tasks', method: 'GET' }) } export function createTask(payload: TaskPayload) { // POST /tasks return http<Task>({ url: '/tasks', method: 'POST', data: payload }) } export function updateTask(id: number, payload: Partial<TaskPayload>) { // PUT /tasks/:id return http<Task>({ url: `/tasks/${id}`, method: 'PUT', data: payload }) } export function removeTask(id: number) { // DELETE /tasks/:id return http<void>({ url: `/tasks/${id}`, method: 'DELETE' }) }
这是对 11 模块"最小版前端"的工程化升级:同样两个页面,但有 TS 类型、有统一请求、有路由守卫、有 loading 与错误提示。先写入口,再写两个视图。
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
createApp(App).use(createPinia()).use(router).mount('#app')
<!-- src/App.vue -->
<script setup lang="ts"></script>
<template>
<router-view />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { login } from '../api/auth'
import { useUserStore } from '../stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const errorMsg = ref('')
async function onSubmit() {
errorMsg.value = ''
if (!username.value || !password.value) {
errorMsg.value = '用户名和密码不能为空'
return
}
loading.value = true
try {
const res = await login(username.value, password.value)
userStore.setAuth(res.token, res.user) // token+user 落 localStorage
const redirect = (route.query.redirect as string) || '/tasks'
router.push(redirect) // 登录后回跳原目标
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div style="max-width:360px;margin:80px auto;padding:0 16px">
<h2 style="text-align:center">工作台登录</h2>
<form @submit.prevent="onSubmit" style="display:block">
<input v-model="username" placeholder="用户名"
style="width:100%;padding:8px;margin:8px 0" />
<input v-model="password" type="password" placeholder="密码"
style="width:100%;padding:8px;margin:8px 0" />
<button type="submit" :disabled="loading"
style="width:100%;padding:9px;margin-top:8px">
{{ loading ? '登录中…' : '登 录' }}
</button>
<p v-if="errorMsg" style="color:#C0392B;margin-top:10px">{{ errorMsg }}</p>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchTasks, createTask, updateTask, removeTask } from '../api/task'
import { useUserStore } from '../stores/user'
import type { Task } from '../types/api'
const userStore = useUserStore()
const tasks = ref<Task[]>([])
const loading = ref(false)
const errorMsg = ref('')
const newTitle = ref('')
const newNote = ref('')
async function load() {
loading.value = true
errorMsg.value = ''
try {
tasks.value = await fetchTasks() // GET /tasks,返回类型 Task[]
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '加载任务失败'
} finally {
loading.value = false
}
}
async function onCreate() {
if (!newTitle.value.trim()) return
try {
await createTask({
title: newTitle.value.trim(),
note: newNote.value.trim(),
done: false,
})
newTitle.value = ''
newNote.value = ''
await load()
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '创建失败'
}
}
async function onToggleDone(t: Task) {
try {
await updateTask(t.id, { done: !t.done }) // PUT /tasks/:id 标记完成
t.done = !t.done
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '更新失败'
}
}
async function onRemove(t: Task) {
try {
await removeTask(t.id) // DELETE /tasks/:id
tasks.value = tasks.value.filter((x) => x.id !== t.id)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '删除失败'
}
}
function onLogout() {
userStore.logout()
window.location.href = '/login'
}
onMounted(load) // 进页面就拉一次列表
</script>
<template>
<div style="max-width:760px;margin:30px auto;padding:0 16px">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>我的工作台</h2>
<span>
{{ userStore.user?.username }}
<button @click="onLogout" style="margin-left:10px">退出登录</button>
</span>
</div>
<div style="margin:14px 0">
<input v-model="newTitle" placeholder="任务标题"
style="padding:7px;width:45%" />
<input v-model="newNote" placeholder="备注(可空)"
style="padding:7px;width:35%;margin-left:6px" />
<button @click="onCreate" style="margin-left:6px">新增</button>
</div>
<p v-if="loading" style="color:#3d5a80">加载中…</p>
<p v-if="errorMsg" style="color:#C0392B">{{ errorMsg }}</p>
<table v-else style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:#0E1B2C;color:#fff;text-align:left">
<th style="padding:8px">完成</th>
<th style="padding:8px">标题</th>
<th style="padding:8px">备注</th>
<th style="padding:8px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id"
:style="t.done ? 'opacity:.5' : ''">
<td style="padding:8px;border:1px solid #e2e7ec">
<input type="checkbox" :checked="t.done"
@change="onToggleDone(t)" />
</td>
<td style="padding:8px;border:1px solid #e2e7ec">
<span :style="t.done ? 'text-decoration:line-through' : ''">
{{ t.title }}
</span>
</td>
<td style="padding:8px;border:1px solid #e2e7ec">{{ t.note }}</td>
<td style="padding:8px;border:1px solid #e2e7ec">
<button @click="onRemove(t)">删除</button>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="4" style="padding:12px;border:1px solid #e2e7ec;color:#5d6d7e">
还没有任务,先新增一条。
</td>
</tr>
</tbody>
</table>
</div>
</template>
这一页包含了全部工程化要点:onMounted 拉数据、loading/errorMsg 两个 ref 管界面状态、每次增删改后 reload、勾选触发 PUT、删除后本地 filter 兜底。表格样式用内联是为了教程自包含;真实项目里把样式抽进 style 块或 CSS 文件。
本地开发跑通后,一条命令产出静态文件。生产怎么接,细节归 20 模块,这里只讲"前端这边"的动作。
cd workbench/web pnpm build # 先做 TS 类型检查,再打包;类型不过会失败 # 产物输出到 web/dist/ :index.html + assets/ pnpm preview # 本地预览构建产物,模拟生产
生产环境没有 proxy:Vite 的 server.proxy 只在 pnpm dev 时生效。生产是浏览器直接访问同源 /api,由 Go 或 Nginx 接。所以代码里写 /api/v1 相对路径是对的——dev 被代理,prod 被同源服务接住,一套代码两头通用。
闭关期间卡壳,先查这张表。每条都是这个技术栈真会遇到的报错。
| 报错原文/现象 | 原因 | 解决 |
|---|---|---|
TS2322: Type 'X' is not assignable to type 'Y' / Property 'xxx' does not exist on type |
props/emits/接口字段类型对不上;或后端返回字段与 src/types/api.ts 不一致。 | 按报错行号改:统一到 §4.1 的契约类型;字段名蛇形还是驼峰与后端 json tag 对齐。 |
pnpm 报 'xxx' should be listed in the dependencies / 运行时 Cannot find module 'xxx'(幽灵依赖) |
代码 import 了某包,但它只是别的依赖的子依赖;pnpm 严格 node_modules 不暴露它。 | 缺啥装啥:pnpm add xxx(或 -D)。不要用 public-hoist 绕开——严格结构就是用来逼出这个问题的。 |
前端请求 /api/... 404 或 net::ERR_CONNECTION_REFUSED(proxy 不生效) |
后端没在 8080 起;target 端口写错;或改了 vite.config.ts 没重启 dev。 | ①先 curl http://localhost:8080/healthz 确认后端活着;②核对 target;③改完 vite.config.ts 必须 Ctrl+C 重启 pnpm dev。 |
浏览器控制台 CORS policy: No 'Access-Control-Allow-Origin' |
前端代码里直接写了 http://localhost:8080 跨域请求,绕过了 proxy。 | 代码里一律写相对路径 /api/v1/...,dev 走 vite proxy,prod 同源。不要在前端配 CORS。 |
| 登录页反复跳、路由守卫死循环 / 401 后浏览器狂刷请求 | 拦截器收到 401 就跳 /login,但跳过去又触发请求/守卫,循环。 | 拦截器里先判断 window.location.pathname 已在 /login 就不再跳(§7.1 已写);logout() 先清 token 再跳转。 |
<script setup> 顶层写 await,组件不渲染 / 报需要 Suspense |
script setup 顶层 await 会让组件变成异步组件,必须用 <Suspense> 包裹 <router-view>。 | 工作台纪律:数据加载写进 onMounted 里的 async 函数,不在顶层 await。需要顶层 await 时再去包 Suspense。 |
ref 改了界面不更新 / value 是 undefined(ref 解包丢失) |
JS 里忘写 .value(如 count++ 写成改了普通变量);或解构 reactive 对象丢了响应式。 |
JS 操作 ref 一律 xxx.value;模板里不写 .value。要解构 reactive 就用 const { a } = toRefs(state)。 |
Cannot find module '@/xxx' 或红色波浪线,但 vite dev 能跑 |
tsconfig.json 的 paths 与 vite.config.ts 的 alias 只配了一处;或 VS Code 的 TS 服务没重启。 | 两处都配 @(§2.3/§2.4 已成对给出);VS Code 里 Ctrl+Shift+P → "Restart TS Server"。 |
| pnpm build 成功,部署后打开是空白页(控制台 404 静态资源) | ①路由用 createWebHistory,服务器没配 history fallback,刷新 /tasks 找不到 index.html;②资源 base 路径不对,部署在子目录。 | Nginx 配 try_files $uri /index.html(20 模块);若部署在子路径,vite.config 里设 base: '/子目录/'。本地先 pnpm preview 复现。 |
登录后任务页空白 / 控制台 user is null |
刷新页面后 localStorage 读 user,但 JSON.parse 拿到旧数据/空串,或 userStore 初始值没取到。 | 确认 §6.1 初始值从 localStorage 读、setAuth 写入成对;模板里用可选链 userStore.user?.username,不要裸取 .username。 |
对照 08 总纲 M3 验收项("前端(Vue3+TS+Pinia+路由守卫)完整接入")逐条打勾。全绿才算本模块结业。
| √ | 验收项 | 怎么验 |
|---|---|---|
| □ | pnpm dev 起前端,pnpm dev 起后端,5173 登录页可打开 | 浏览器开 http://localhost:5173,未登录访问 /tasks 自动跳 /login |
| □ | 登录成功 → 自动回跳 /tasks,显示用户名与退出按钮 | 用后端已存在的账号登录;F12 Application 里看到 wb_token |
| □ | 任务列表:新增 / 勾选完成(划线+变灰)/ 删除全部可用 | 三条操作分别试;Network 里看到 POST/PUT/DELETE /api/v1/tasks |
| □ | 请求自动带 Authorization: Bearer <token> | F12 Network 任一请求 Header 里有 Authorization |
| □ | 错误有提示:密码错显示 msg;网络断显示"网络错误" | 故意输错密码;停掉后端再刷新列表 |
| □ | 401 自动清登录态并跳回登录页(不循环) | 手动改坏 localStorage 里的 token,刷新页面 |
| □ | pnpm build 通过,TS 类型检查零报错 | 在 web/ 跑 pnpm build,dist/ 正常产出 |
| □ | pnpm preview 打开构建产物,刷新 /tasks 不白屏(配合 20 的部署配置) | pnpm preview 后刷新页面 |
| □ | 端点零新增:只用了 /healthz、/auth/login、/tasks、/users | 对照 workbench-contract.md §4 端点表自查 api/ 目录 |
| □ | 架子沉淀:把 workbench/web/ 复制一份到 assets/scaffolds/vue3-template/ | 08 §5 四件套;下一个项目直接 fork |
结业标志:上面 10 项全绿 = 08 总纲 W5–W6"工作台前端第一版"与 W7–W8 M3"前端完整接入"两格同时打勾。你的工作台前端从此是工程,不再是"能跑的页面"。
完整版与全部核验说明见 09 环境搭建与版本清单 与全系列 versions.md。
| 组件 | 锁定(2026-10-14 核验) | 来源 |
|---|---|---|
| Vue | 3.5.x(^3.5,最新 3.5.44) | https://github.com/vuejs/core/releases |
| TypeScript | 7.0.2(^7,原生编译器新主版本) | https://registry.npmjs.org/typescript |
| Vite | 8.3.4(^8.3) | https://github.com/vitejs/vite/releases |
| pnpm | 12.11.2 | https://github.com/pnpm/pnpm/releases |
| Node.js | v24.21.0(Active LTS) | https://nodejs.org/en |
| vue-router / Pinia / axios | 随 pnpm install 解析,版本以 pnpm-lock.yaml 为准 | https://registry.npmjs.org/ 对应包页 |
| 工作台目录结构 / API 端点 / 数据模型(User、Task、统一响应 {code,data,msg}、Bearer JWT)——本模块前端代码严格对齐,未新增端点 | workbench-contract.md(全系列内部契约,2026-10-14) |
| Vue 3.5 最新补丁版本核验 | https://data.jsdelivr.com/v1/packages/npm/vue/resolved?specifier=latest · https://github.com/vuejs/core/releases |
| Vite 8.3.4 发布核验 | https://data.jsdelivr.com/v1/packages/npm/vite/resolved?specifier=latest · https://github.com/vitejs/vite/releases |
| TypeScript 7.0.2(原生编译器新主版本,装 typescript@^7) | https://registry.npmjs.org/typescript |
| pnpm 12.11.2 发布核验 | https://github.com/pnpm/pnpm/releases |
| Node.js Active LTS(v24.21.0) | https://nodejs.org/en |
| 组合式 API / script setup / 响应式基础用法(教程写法依据) | https://cn.vuejs.org/guide/introduction.html(Vue 官方文档,组合式 API 约定) |
| axios 拦截器与 Pinia store 写法(工程模式) | https://axios-http.com/docs/interceptors · https://pinia.vuejs.org/cookbook/composition-api.html |
| M3 验收项与四件套沉淀要求 | 08-闭关总纲.html §4 / §5 / §9(本系列模块) |
(编者整理:本页"最小必要理论 + 照做步骤 + 报错表"的组织方式、组件通信速查、报错条目为基于官方文档与工作台契约的教学化整理;API 端点与模型为契约原文,无外部来源。)