用 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.login 或 userStore.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 分页的返回结构)。
跑起来¶
同时后端跑着(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: 上一章:前后端联调总览 | 下一章:联调常见坑