跳转至

用 Vue 3 实现前端

你已经会写 Vue,这一章就是把任务管理系统的前端搭出来——登录页 + 任务列表,对接你自己写的后端。

状态管理:Pinia store

token 和用户信息存 Pinia(+ localStorage 持久化):

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login as loginApi, register as registerApi } from '../api/auth'

interface UserInfo {
  id: number
  username: string
}

// localStorage 可能被篡改成非法 JSON,安全解析:失败当未登录,避免 store 初始化抛错导致白屏。
function loadUserInfo(): UserInfo | null {
  try {
    const raw = localStorage.getItem('userInfo')
    return raw ? (JSON.parse(raw) as UserInfo) : null
  } catch {
    return null
  }
}

// 第四篇 ch33:用户状态(token + 用户信息)。Pinia store。
export const useUserStore = defineStore('user', () => {
  const token = ref(localStorage.getItem('token') || '')
  const userInfo = ref<UserInfo | null>(loadUserInfo())

  async function login(username: string, password: string) {
    const data = await loginApi(username, password)
    token.value = data.token
    userInfo.value = data.user
    localStorage.setItem('token', data.token)
    localStorage.setItem('userInfo', JSON.stringify(data.user))
  }

  async function register(username: string, password: string) {
    // 注册成功后直接登录,省去用户再填一次
    await registerApi(username, password)
    await login(username, password)
  }

  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
    localStorage.removeItem('userInfo')
  }

  return { token, userInfo, login, register, logout }
})

login 调接口拿 token 存起来,logout 清掉。对标后端:前端存 token,后端签发/校验 token。

路由 + 守卫

import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'

// 第四篇 ch33:路由 + 全局守卫(对标后端的 JwtInterceptor,前端版本)
const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', component: () => import('../views/Login.vue') },
    { path: '/', component: () => import('../views/Tasks.vue'), meta: { requiresAuth: true } },
  ],
})

// 没登录访问受保护页面 → 跳登录
router.beforeEach((to) => {
  const userStore = useUserStore()
  if (to.meta.requiresAuth && !userStore.token) {
    return '/login'
  }
})

export default router

meta.requiresAuth 标记需要登录的页面,全局守卫 beforeEach 检查 token。这就是前端的"鉴权拦截器",和后端 JwtInterceptor 遥相呼应。

登录页

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'

// 第四篇 ch33:登录页(登录/注册切换)
const username = ref('')
const password = ref('')
const loading = ref(false)
const isRegister = ref(false)
const router = useRouter()
const userStore = useUserStore()

async function submit() {
  if (!username.value.trim() || !password.value.trim()) {
    alert('用户名和密码不能为空')
    return
  }
  loading.value = true
  try {
    if (isRegister.value) {
      await userStore.register(username.value, password.value)
    } else {
      await userStore.login(username.value, password.value)
    }
    router.push('/')
  } catch {
    // 失败提示已由 request 拦截器统一 alert,这里只负责恢复按钮状态
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="login">
    <h2>{{ isRegister ? '注册' : '登录' }}</h2>
    <input v-model="username" placeholder="用户名" :disabled="loading" />
    <input v-model="password" type="password" placeholder="密码" :disabled="loading" />
    <button @click="submit" :disabled="loading">
      {{ loading ? '处理中...' : (isRegister ? '注册' : '登录') }}
    </button>
    <a href="javascript:void(0)" class="toggle" @click="isRegister = !isRegister">
      {{ isRegister ? '已有账号?去登录' : '没有账号?去注册' }}
    </a>
  </div>
</template>

<style scoped>
.login {
  max-width: 320px;
  margin: 60px auto;
}
input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin-bottom: 8px;
  padding: 8px;
}
button {
  width: 100%;
  padding: 8px;
}
.toggle {
  display: inline-block;
  margin-top: 12px;
  font-size: 14px;
}
</style>

支持登录 / 注册切换:调 userStore.loginuserStore.register,成功后跳首页;注册成功会自动登录。空字段校验在前端拦一道,失败提示由 request 拦截器统一 alert,这里只管按钮 loading 状态。

任务列表页

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getTasks, createTask, deleteTask, updateTask, type Task } from '../api/task'

// 第四篇 ch33:任务列表页(对接后端 CRUD)
const tasks = ref<Task[]>([])
const newTitle = ref('')
const loading = ref(false)

// 任务状态文案:0 待办 / 1 进行中 / 2 已完成(和后端 t_task.status 一致)
const statusText: Record<number, string> = { 0: '待办', 1: '进行中', 2: '已完成' }

// 状态切换:0 待办 → 1 进行中 → 2 已完成 → 0 待办
const nextStatus = (s: number): number => (s + 1) % 3

async function load() {
  loading.value = true
  try {
    const page = await getTasks({ current: 1, size: 20 })
    tasks.value = page.records
  } catch {
    // 失败提示已由 request 拦截器统一 alert
  } finally {
    loading.value = false
  }
}

async function add() {
  if (!newTitle.value.trim()) return
  try {
    await createTask({ title: newTitle.value })
    newTitle.value = ''
    await load()
  } catch {
    // 同上
  }
}

async function toggle(t: Task) {
  try {
    await updateTask(t.id, { title: t.title, status: nextStatus(t.status) })
    await load()
  } catch {
    // 同上
  }
}

async function remove(id: number) {
  if (!confirm('确定删除这个任务吗?')) return
  try {
    await deleteTask(id)
    await load()
  } catch {
    // 同上
  }
}

onMounted(load)
</script>

<template>
  <div class="tasks">
    <h2>我的任务</h2>
    <div class="add">
      <input v-model="newTitle" placeholder="新任务标题" :disabled="loading" @keyup.enter="add" />
      <button @click="add" :disabled="loading || !newTitle.trim()">添加</button>
    </div>
    <p v-if="loading && !tasks.length" class="hint">加载中...</p>
    <p v-else-if="!tasks.length" class="hint">暂无任务,添加第一个吧</p>
    <ul v-else>
      <li v-for="t in tasks" :key="t.id">
        <span class="title">{{ t.title }}</span>
        <span class="status">{{ statusText[t.status] ?? '未知' }}</span>
        <button @click="toggle(t)" :disabled="loading">切换状态</button>
        <button @click="remove(t.id)">删除</button>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.add {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}
.add input {
  flex: 1;
}
.hint {
  color: #888;
}
li {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
  border-bottom: 1px solid #eee;
}
.title {
  flex: 1;
}
.status {
  font-size: 12px;
  color: #1976d2;
}
button {
  padding: 4px 8px;
}
</style>

onMounted 加载列表、add 新建、toggle 切换状态、remove 删除——全是调第 32 章封装的 task api。注意 getTasks 返回的 page.records 就是当前页任务数组(后端 MyBatis-Plus 分页的返回结构)。

跑起来

cd code/fullstack-frontend
npm install
npm run dev        # 开 http://localhost:5173

同时后端跑着(mvn spring-boot:run,8080)。打开 5173,注册 → 登录 → 加任务,前后端跑通闭环

目录结构

fullstack-frontend/
├── vite.config.ts          # 开发代理
├── src/
│   ├── main.ts             # 入口
│   ├── App.vue
│   ├── api/
│   │   ├── request.ts      # axios 封装(第 32 章)
│   │   ├── auth.ts
│   │   └── task.ts
│   ├── stores/user.ts      # Pinia
│   ├── router/index.ts     # 路由 + 守卫
│   └── views/
│       ├── Login.vue
│       └── Tasks.vue

这就是一个最小但完整的 Vue 3 全栈前端。真实项目再往上叠 UI 库(Element Plus)、更多页面、表单校验等。


:octicons-arrow-left-16: 上一章:前后端联调总览 | 下一章:联调常见坑