编程闭关教程 · 系列 08–20 · 编制日期 2026-10-14

Vue3 前端专题:把工作台前端从"能跑"做到"工程化"

你用过 Vue2 选项式、也碰过 ThinkPHP + Vue3 的混合写法,但一直缺两样东西:TypeScript 的类型纪律,和前端工程化的固定套路(统一请求封装、路由守卫、状态持久化、构建产物)。本模块不教你"什么是 Vue",只带你把工作台(Go 后端 + Gin)的前端重写成一套可复用、类型安全、能直接 build 上线的 vue3-template 架子。API 端点与数据模型严格对齐工作台契约(见 11 模块),不新增任何端点。
本模块承诺闭关 2–3 周(每天 4h)后:你有一套自己的 vue3 架子(pnpm create vite 起手),工作台前端登录页 + 任务列表页(表格 / 增删改查 / 完成勾选 / loading / 错误提示)全部跑通,pnpm build 类型检查零报错,产物可直接交给 Go 静态托管或 Nginx。这就是 08 总纲 M3 验收中"前端完整接入"那一格。
Vue 3.5.x
组合式 API + script setup
Vite 8.3
ESM 秒级冷启动
TS 7
原生编译器新主版本
pnpm 12
严格依赖 + 锁文件
1 套模板
vue3-template 架子沉淀
前置:Node v24.21.0 LTS 与 pnpm 12.11.2 已按 09 模块装好;工作台 Go 后端已按 11 模块在 localhost:8080 跑通(/healthz 返回 ok)。本模块所有版本号均来自全系列唯一版本事实来源(09 / versions.md)。
08 总纲 09 环境·版本 10 Go 内核 11 工作台 12 Vue3 13 Flutter 14 Python 15 C/C++ 16 MySQL 17 PostgreSQL 18 Redis 19 SQLite3 20 部署
本页目录
  1. 2026 技术栈定位:与旧 Vue2 的差异速览
  2. 从零建项目:pnpm create vite 全序列
  3. 组合式 API 核心:够用就好
  4. TypeScript 纪律(本模块的核心)
  5. 路由与守卫:登录拦截 + RBAC 预留
  6. Pinia:user store 与持久化
  7. axios 封装:拦截器 + 统一解包
  8. 工作台前端完整实现(登录 + 任务列表)
  9. 构建与部署:产物怎么上线
  10. 常见报错表(10 条真实坑)
  11. 验收:M3 前端工程化达标
  12. 版本速览与来源
01

2026 技术栈定位:与旧 Vue2 的差异速览

先花 20 分钟把"旧肌肉记忆"和"新纪律"的差清理掉。你不是从零学 Vue,你是要把脑子里 Vue2 的组织方式换成 Vue3 的。

1.1 本模块锁定的技术栈(2026-10-14 核验)

组件锁定为什么是它
Vue3.5.x(安装写 ^3.5,取最新补丁 3.5.44)当前稳定线;组合式 API 与 <script setup> 已是一等公民;3.5 修复了响应式系统多轮边界问题。
TypeScript7.0.2(安装写 ^7)注意:TS 7 是新主版本,换成了原生编译器(不再是 tsc over JS),速度大幅提升。用法与 5.x 的源码写法一致,装 typescript@^7 即可,不要停在 5.x。
Vite8.3.4(安装写 ^8.3)开发服务器用原生 ESM,冷启动毫秒级;构建用 Rolldown 系。新项目不再用 vue-cli/webpack。
pnpm12.11.2全系列包管理器统一 pnpm;严格 node_modules 结构能逼出"幽灵依赖"问题(见 §10)。
Node.jsv24.21.0(Active LTS)跑 Vite 8 的地基;不要用 Current 线。安装见 09 模块。
配套库vue-router 4 / Pinia / axios直接 pnpm add,版本号不手填,由 pnpm install 解析后写进 pnpm-lock.yaml——锁文件才是唯一事实来源(08 §7 规则 3)。

1.2 旧 Vue2(选项式)→ 新 Vue3(组合式)差异速览

维度你记忆中的 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。
thisthis.$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"。模板那层(插值、指令、事件)你几乎不用重学。

02

从零建项目:pnpm create vite 全序列

工作台的前端放在 workbench/web/(与 server/ 平级,见 11 模块目录契约)。下面整条命令序列照抄即可,Windows PowerShell 里执行。

2.1 照做步骤(编号执行)

  1. 确认地基(应与 09 模块一致;不一致先回 09,不要在本模块纠结装环境):
node -v      # 期望 v24.21.0(或 24.x 线)
pnpm -v      # 期望 12.11.2
  1. 在 workbench/ 目录下用官方脚手架起 vue-ts 模板:
cd D:\你的代码目录\workbench
pnpm create vite web --template vue-ts
# 上面命令会创建 web/ 目录,内含 vue + ts + vite 骨架
cd web
  1. 锁死核心版本(关键纪律:不手填 patch,只锁主版本区间,补丁交给 lock):
pnpm add vue@^3.5
pnpm add vue-router pinia axios
pnpm add -D typescript@^7 vite@^8.3 @vitejs/plugin-vue
pnpm install
  1. 把默认 HelloWorld 演示文件删掉,只留骨架(main.ts / App.vue),然后按 §2.3 建目录。
  2. 启动开发服务器:
pnpm dev      # 默认 http://localhost:5173

2.2 目录结构(与工作台契约一致)

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   # 锁文件,入库

2.3 vite.config.ts(含 dev proxy,整文件照抄)

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 条)。

2.4 tsconfig.json 要点(整文件照抄)

{
  "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"]
}
03

组合式 API 核心:够用就好

只讲工作台会用到的最小集。不要去背全部 API——用不到的背了也会忘。

3.1 ref / reactive / computed / watch

<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>

3.2 生命周期(选项式钩子名加 on 前缀)

选项式(Vue2)组合式(<script setup>)工作台里干嘛用
beforeCreate / createdsetup 顶层代码本身组件创建即执行,直接写在 <script setup> 顶层。
mountedonMounted页面加载数据的固定位置(§8 任务列表在此 fetch)。
beforeUnmount / unmountedonUnmounted清定时器、清全局监听。

3.3 provide / inject(跨层传值)

// 祖先组件:提供
import { provide } from 'vue'
provide('appTitle', '我的工作台')

// 深层后代组件:注入(不知道中间传了几层)
import { inject } from 'vue'
const appTitle = inject('appTitle', '默认标题')   // 第二参数是默认值

3.4 组件通信全方案(速查)

场景方案说明
父 → 子propsdefineProps<{...}>()(泛型写法见 §4)。
子 → 父emitdefineEmits<{...}>(),父组件 @事件名 监听。
父 v-model 双向绑子defineModel / modelValue+update表单组件常用;工作台初期可不写。
跨多层级provide / inject主题、全局配置。
跨页面全局状态Pinia store登录态、用户信息(§6)。绝大多数"跨组件共享"走这里。
父调子方法defineExpose + ref少用;能用 props/emit 就别用。
任意组件互发事件事件总线(mitt)不推荐,工作台禁用——状态难追踪。

纪律:工作台里组件通信只用三种——父子 props/emit、跨层 provide/inject、全局 Pinia。不引事件总线,不用 $root,状态来源只有 store 一条。

04

TypeScript 纪律(本模块的核心)

这是你这次闭关真正要补的东西。原则只有一条:与后端契约对齐的类型,集中定义在一处,全项目复用;组件 props/emits 一律泛型化,禁止裸 any。

4.1 先建契约类型:src/types/api.ts(整文件照抄)

这是前端与 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
}
为什么字段名用 created_at 而不是 createdAt:后端 GORM 模型的 json tag 是 created_at(蛇形)。前端类型必须跟后端 json tag 一字不差,不要自作主张转驼峰——除非后端真的改了 tag,那是后端的决定。

4.2 类型化 props / emits(泛型写法)

<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']——传错字段名、漏传必填项,旧写法运行时才炸;泛型写法编译期就拦下来。这就是纪律的价值。

4.3 as const 与类型安全要点

const STATUS = ['pending', 'done'] as const
type Status = typeof STATUS[number]   // = 'pending' | 'done'

// 错误:不加 as const,STATUS 被推断成 string[],Status 变成 string
05

路由与守卫:登录拦截 + RBAC 预留

两个页面:/login(公开)和 /tasks(需登录)。守卫判断 token,未登录重定向回登录页,并把目标地址塞进 query,登录后回跳。

5.1 src/router/index.ts(整文件照抄)

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
06

Pinia:user store 与持久化

全局只放"登录态"这种真正跨页面的东西:token + userinfo,刷新页面不能丢,所以手写 localStorage 持久化(不引持久化插件,少一个依赖少一个坑)。

6.1 src/stores/user.ts(组合式 store 写法,整文件照抄)

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。

07

axios 封装:拦截器 + 统一解包

工程化的标志:全项目只建一个 axios 实例,请求自动带 token,响应自动拆 code/data/msg,401 自动清登录态跳登录。业务代码里不再写任何 headers、不再判 code。

7.1 src/api/request.ts(整文件照抄)

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

7.2 按业务拆分:src/api/auth.ts 与 src/api/task.ts

// 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' })
}
端点纪律:上面四个函数对应契约里 GET/POST/PUT/DELETE /tasks,登录对应 /auth/login。一个端点都不许多,一个字段都不许改——后端是 11 模块的 Gin handler,前端只是它的客户端。
08

工作台前端完整实现(登录页 + 任务列表页)

这是对 11 模块"最小版前端"的工程化升级:同样两个页面,但有 TS 类型、有统一请求、有路由守卫、有 loading 与错误提示。先写入口,再写两个视图。

8.1 入口:src/main.ts 与 src/App.vue

// 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>

8.2 登录页:src/views/LoginView.vue(整文件照抄)

<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>

8.3 任务列表页:src/views/WorkbenchView.vue(表格 + 增删改 + 完成勾选)

<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 文件。

09

构建与部署:产物怎么上线

本地开发跑通后,一条命令产出静态文件。生产怎么接,细节归 20 模块,这里只讲"前端这边"的动作。

9.1 构建

cd workbench/web
pnpm build        # 先做 TS 类型检查,再打包;类型不过会失败
# 产物输出到 web/dist/ :index.html + assets/
pnpm preview      # 本地预览构建产物,模拟生产

9.2 产物接入(两种,二选一,细节见 20 模块)

方式 A · Go 静态托管(推荐工作台用)

把 web/dist/ 拷进 server 能访问的目录,用 Gin 的静态文件中间件直接把前端和 /api 挂在同一个 8080 端口。同源 = 天生没有 CORS,部署只有一个二进制 + 一个 dist 目录。Go 侧具体 embed/静态路由写法见 11 与 20 模块。

方式 B · Nginx 反代

Nginx 托管 dist/,把 /api 反向代理到 Go 后端端口。必须配 history fallback(所有路径 try_files 到 index.html),否则刷新 /tasks 会 404(见 §10 空白页条)。nginx.conf 模板见 20 模块 deploy/。

生产环境没有 proxy:Vite 的 server.proxy 只在 pnpm dev 时生效。生产是浏览器直接访问同源 /api,由 Go 或 Nginx 接。所以代码里写 /api/v1 相对路径是对的——dev 被代理,prod 被同源服务接住,一套代码两头通用。

10

常见报错表(10 条真实坑)

闭关期间卡壳,先查这张表。每条都是这个技术栈真会遇到的报错。

报错原文/现象原因解决
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。
11

验收:M3 前端工程化达标

对照 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"前端完整接入"两格同时打勾。你的工作台前端从此是工程,不再是"能跑的页面"。

12

版本速览与来源

完整版与全部核验说明见 09 环境搭建与版本清单 与全系列 versions.md。

组件锁定(2026-10-14 核验)来源
Vue3.5.x(^3.5,最新 3.5.44)https://github.com/vuejs/core/releases
TypeScript7.0.2(^7,原生编译器新主版本)https://registry.npmjs.org/typescript
Vite8.3.4(^8.3)https://github.com/vitejs/vite/releases
pnpm12.11.2https://github.com/pnpm/pnpm/releases
Node.jsv24.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 端点与模型为契约原文,无外部来源。)