前面八章你学会了画界面、管状态、规划路由——但页面上的作品还是假的。这一章把前端和课程二的 Flask 后端真正接上:用 axios 封装一个只写一次的请求层,自动带 token、自动拆 {code, msg, data}、自动处理 401;再讲清楚「跨域」到底是什么,用 Vite 代理 在开发期解决它。学完你能独立完成前后端联调,这是整个课程三最关键的一章。
// 取「古典文学网」作品列表第一页
async function loadArticles() {
const res = await fetch('/api/article/list?page=1&page_size=10')
if (!res.ok) { // ① 先判断 HTTP 状态码
throw new Error(`HTTP ${res.status}`)
}
const json = await res.json() // ② 再手动解析 JSON(还要再 await 一次)
if (json.code !== 200) { // ③ 再判断业务状态码
throw new Error(json.msg)
}
return json.data // ④ 最后才拿到真正的数据
}
// request 是第 3~5 节封装出来的实例,全项目共用
async function loadArticles() {
const data = await request.get('/article/list', { params: { page: 1, page_size: 10 } })
return data // 拿到的直接就是 data,四步全被拦在了拦截器里
}
npm install axios
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios' // 先直接用 axios,还没封装
const list = ref([])
onMounted(() => {
axios.get('/api/article/list', { params: { page: 1, page_size: 10 } })
.then((res) => {
// 注意两层:res.data 是后端返回的 { code, msg, data }
if (res.data.code === 200) {
list.value = res.data.data.list
}
})
.catch((err) => {
console.log('请求失败:', err.message)
})
})
</script>
import axios from 'axios'
// 创建属于本项目的 axios 实例:以后全项目都用它,不再直接用 axios
const request = axios.create({
// 接口文档约定:本地开发 http://127.0.0.1:5000/api,线上 https://你的域名/api
// 这里只写 /api,让「谁来补上主机名」交给 Vite(开发)或 Nginx(生产)决定
baseURL: '/api',
timeout: 10000 // 10 秒没响应就当失败(后端挂了、断网都会走到错误分支)
})
export default request
import { useUserStore } from '@/stores/user'
// 请求拦截器:每个请求「发出之前」都会先跑这里
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
// 有 token 就按接口文档的约定带上鉴权头:Authorization: Bearer <token>
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 必须返回 config,否则这个请求就断在半路了
return config
},
(error) => Promise.reject(error)
)
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
/* ---------------- 请求拦截器:自动带 token ---------------- */
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error)
)
/* ---------------- 响应拦截器:统一拆 {code, msg, data} ---------------- */
request.interceptors.response.use(
(response) => {
const res = response.data
// ① 业务成功:直接把 data 交给页面,业务里不用再写 res.data.data
if (res.code === 200) {
return res.data
}
// ② 未登录 / token 失效:清干净本地状态,送去登录页
if (res.code === 401) {
const userStore = useUserStore()
userStore.logout()
ElMessage.error(res.msg || '登录已过期,请重新登录')
router.push({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath }
})
return Promise.reject(new Error(res.msg || '未登录'))
}
// ③ 其它业务错误(400 / 403 / 404 / 500):统一弹 msg
ElMessage.error(res.msg || '请求失败')
return Promise.reject(new Error(res.msg || '请求失败'))
},
(error) => {
// ④ 走到这里说明请求压根没拿到响应:超时、断网、后端没启动
const msg = error.response
? `请求失败(HTTP ${error.response.status})`
: '网络异常,请检查后端服务是否已启动'
ElMessage.error(msg)
return Promise.reject(error)
}
)
export default request
// 把请求直接打到后端的完整地址上:5173 → 5000,端口不同 = 跨域
import axios from 'axios'
axios.get('http://127.0.0.1:5000/api/article/list', { params: { page: 1 } })
.then((res) => console.log(res.data))
.catch((err) => console.log('请求失败:', err.message))
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
// 让 import xxx from '@/utils/request' 能指向 src/utils/request
'@': path.resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
// 只要请求路径以 /api 开头,开发服务器就替你转发到 Flask
'/api': {
target: 'http://127.0.0.1:5000', // 课程二的后端地址
changeOrigin: true // 把请求头里的 Host 改成目标地址
// 不需要 rewrite:后端路由本来就是 /api/xxx,原样转发即可
}
}
}
})
server {
listen 80;
server_name 你的域名;
# 前端打包产物(npm run build 生成的 dist 目录)
root /var/www/hivue/dist;
index index.html;
# Vue Router 是 History 模式:找不到的路径统统交给 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 接口反向代理:前端与接口同源,不存在跨域
location /api/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
import request from '@/utils/request'
// 作品列表:分页 / 分类筛选 / 关键词搜索 / 排序,参数名与接口文档一致
// params 形如 { page: 1, page_size: 10, category_id: 1, keyword: '唐', order_by: 'hot' }
export function getArticleList(params) {
return request.get('/article/list', { params })
}
// 作品详情:GET /api/article/detail/<id>,id 直接拼在路径上
export function getArticleDetail(id) {
return request.get(`/article/detail/${id}`)
}
// 首页推荐位:GET /api/article/recommend?limit=6
export function getRecommend(limit = 6) {
return request.get('/article/recommend', { params: { limit } })
}
<script setup>
import { ref, onMounted } from 'vue'
import { getArticleList } from '@/api/article'
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
// 拦截器已经把 { code, msg, data } 拆开,这里拿到的直接就是 data
async function loadList() {
loading.value = true
try {
const data = await getArticleList({
page: page.value,
page_size: pageSize.value,
order_by: 'hot'
})
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
onMounted(loadList)
</script>
<template>
<div v-if="loading">加载中…</div>
<ul v-else class="article-list">
<li v-for="item in list" :key="item.id">
<h3>{{ item.title }}</h3>
<p>{{ item.author }} · {{ item.dynasty }} · 浏览量 {{ item.views }}</p>
</li>
</ul>
<p>共 {{ total }} 篇</p>
</template>
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:自动带 token
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器:统一拆 {code, msg, data}
request.interceptors.response.use(
(response) => {
const res = response.data
if (res.code === 200) return res.data
if (res.code === 401) {
const userStore = useUserStore()
userStore.logout()
ElMessage.error(res.msg || '登录已过期,请重新登录')
if (router.currentRoute.value.name !== 'login') {
router.push({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
return Promise.reject(new Error(res.msg || '未登录'))
}
ElMessage.error(res.msg || '请求失败')
return Promise.reject(new Error(res.msg || '请求失败'))
},
(error) => {
const msg = error.response
? `请求失败(HTTP ${error.response.status})`
: '网络异常,请检查后端服务是否已启动'
ElMessage.error(msg)
return Promise.reject(error)
}
)
export default request
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') }
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:5000',
changeOrigin: true
}
}
}
})
// src/api/movie.js —— 与 api/article.js 完全同构
import request from '@/utils/request'
// 电影列表:分页 + 类型筛选
export function getMovieList(params) {
// params 形如 { page: 1, page_size: 10, genre: '科幻' }
return request.get('/movie/list', { params })
}
// 电影详情:GET /api/movie/detail/<id>
export function getMovieDetail(id) {
return request.get(`/movie/detail/${id}`)
}
第 3 题的关键:换的只是「接口地址和业务参数」,不变的是「request 实例 + 拦截器 + api 模块」这套骨架。能迁移过去,说明你掌握了前后端对接的通用套路,剩下的都是体力活。