Skip to content

创建问诊项目与请求工具(二) ​

本页对应 创建问诊项目与请求工具,共 7 段视频,约 1 小时 11 分。

学习目标 ​

  • 能够参考接口返回定义 User 类型,使用 defineStore 创建包含 user 状态、setUser、deleteUser 的用户仓库,并配置持久化插件,通过刷新页面验证用户信息仍保留。
  • 能够将 Pinia 初始化与插件注册迁移到 stores/index.ts,使用 export * from './modules/user' 统一导出仓库函数,并验证组件通过短路径导入后登录、退出功能正常。
  • 能够创建 axios 实例配置 baseURL、timeout,在请求拦截器中安全携带 token,并封装 request<T>(url, method, submitData?),根据请求方式将数据放入 params 或 data,通过浏览器验证请求头、查询参数和请求体正确。
  • 能够实现响应拦截器:当 code !== 10000 时使用 Toast 提示并返回失败 Promise,成功时返回 res.data 摘取核心响应数据;能够处理 401,清空用户仓库并跳转登录页且携带 returnUrl 当前完整路由。
  • 能够为 request 函数增加泛型并定义 Data<T> 类型别名,将泛型传入 axios.request 第二个类型参数,使登录接口响应获得 code、message、data: User 的准确类型提示,并能说明使用第二个泛型参数的原因。

知识点 ​

先定用户类型:对照接口文档,而不是凭感觉写 ​

为什么需要:仓库里存的是用户信息,如果不先定义类型,ref 里放什么、setUser 收什么、页面读什么都会变成 any,后面接接口时很容易字段对不上。
怎么想:类型必须以后台返回为准。转写稿里先看“密码登录”接口的成功响应,再决定字段;不要自己发明字段。
怎么做:新建 src/types/user.d.ts,导出 User 类型。按转写稿模拟用到的字段,至少包含 id、avatar、token、mobile、account:

ts
export interface User {
  id: number
  avatar: string
  token: string
  mobile: string
  account: string
}

易错点:接口文档能“生成代码”,但生成类型有时和项目实际需要不完全一致;转写稿建议直接采用笔记里整理好的类型。字段名、可选性要以接口为准,不能把 token 写成 taken。

用 defineStore 建用户仓库:状态、设置、删除三件事 ​

为什么需要:登录成功要保存用户状态,退出要清空用户状态;如果散落在组件里,后续很多页面都要重复写。
怎么想:用户仓库只做三件事:定义 user 状态、提供 setUser、提供 delUser,最后把使用仓库的函数导出。
怎么做:在 src/stores/user.ts 中写:

ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { User } from '@/types/user'

export const useUserStore = defineStore('cp-user', () => {
  const user = ref<User>()

  const setUser = (u: User) => {
    user.value = u
  }

  const delUser = () => {
    user.value = undefined
  }

  return { user, setUser, delUser }
})

易错点:ref<User>() 初始值可以是 undefined,删除时用 user.value = undefined;不要写成 onDef、ondef,正确是 undefined。仓库 id 要唯一,转写稿用的是 cp-user 这类标识。

在 App.vue 做最小验证:先证明仓库能存能清 ​

为什么需要:业务页面还没正式接,先用两个按钮验证仓库,能把问题挡在早期。
怎么想:一个按钮模拟登录,调用 setUser 塞一条模拟数据;一个按钮模拟退出,调用 delUser;页面直接打印 store.user。
怎么做:

vue
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()

const login = () => {
  userStore.setUser({
    id: 1,
    avatar: '1',
    token: '1',
    mobile: '1',
    account: '1'
  })
}

const logout = () => {
  userStore.delUser()
}
</script>

<template>
  <button @click="login">登录</button>
  <button @click="logout">退出</button>
  <p>{{ userStore.user }}</p>
</template>

易错点:转写稿调试时先看“点击登录后页面是否打印出用户信息”,再点退出看是否清空。若一开始打印为空是正常的,因为初始值是 undefined。

持久化:安装 pinia-plugin-persistedstate 并开启 persist ​

为什么需要:用户信息通常要留在本地,刷新页面不能丢;手写 localStorage 读写很麻烦,插件可以自动完成。
怎么想:先安装插件,再在 main.ts 里让 Pinia 实例 use 插件,最后在需要持久化的仓库第三个参数里开启 persist: true。
怎么做:安装:

bash
pnpm install pinia-plugin-persistedstate

main.ts:

ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)

仓库里加第三个参数:

ts
export const useUserStore = defineStore(
  'cp-user',
  () => {
    const user = ref<User>()
    const setUser = (u: User) => { user.value = u }
    const delUser = () => { user.value = undefined }
    return { user, setUser, delUser }
  },
  {
    persist: true
  }
)

易错点:插件要在 createPinia() 之后 pinia.use(...);persist 写在 defineStore 第三个参数,不是写在 ref 上。验证时点登录后刷新页面,数据还在;打开应用本地存储能看到 cp-user 对应数据。

调试顺序与提交 ​

转写稿的调试过程很明确:先点“登录”,确认页面打印出用户信息;再点“退出”,确认数据清空;开启持久化后,再点登录并刷新,确认数据仍在。最后提交代码,提交信息可用 feature: 用户仓库和持久化。这样这一节的目标就完成了:类型有出处、仓库能读写、页面能验证、刷新不丢数据。

统一管理要解决什么问题 ​

到这一节,用户仓库和持久化已经能跑通,但代码组织上还有两个明显痛点。

第一,main.ts 里混着 Pinia 的初始化代码:创建 Pinia 实例、导入持久化插件、调用 pinia.use(persist)。这些都属于状态管理基础设施,却和应用的启动装配混在一起,导致 main.ts 职责不单一。第二,仓库的导出方式是“每个仓库模块各自导出 useXxxStore”,使用时要写很长的路径,例如从 stores/modules/user 导入。现在只有一个用户仓库还不明显,等后续咨询、订单等仓库多起来,路径会越来越分散,不利于维护。

所以“统一管理”要做的不是改业务逻辑,而是做两件事:一是把 Pinia 相关代码独立维护到 stores/index.ts;二是让仓库的使用函数统一由 stores/index.ts 导出。这样 main.ts 只负责装配,业务组件统一从 @/store 拿仓库函数。

易错点:stores/index.ts 只做基础设施和统一导出,不要把用户状态、登录逻辑塞进去,否则只是把混乱换了个位置。

把 Pinia 初始化迁移到 stores/index.ts ​

先在 stores 下新建 index.ts,把原来 main.ts 中创建 Pinia、使用持久化插件的代码挪过来:

ts
// stores/index.ts
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(persist)

export default pinia

然后修改 main.ts,只保留“导入 Pinia 实例并挂载”:

ts
// main.ts
import pinia from './stores'

app.use(pinia)

这里的推导是:Pinia 实例是全局唯一的,创建实例和安装插件应该集中在一个模块里;main.ts 只消费这个实例。转写稿中调试时回到浏览器,先点退出,再点登录,功能仍然正常,说明迁移没有破坏原有逻辑。

易错点:不要在 main.ts 里再次 createPinia(),否则可能出现两个实例;插件要在创建实例后、挂载应用前 use;导入路径要确认是 ./stores 而不是旧文件。

用 modules 目录收纳仓库 ​

接下来把 user.ts 放到 stores/modules/user.ts。这样每个仓库就是一个模块,后续新增仓库也放进 modules,目录语义更清晰。

ts
// stores/modules/user.ts
export const useUserStore = defineStore('cp-user', () => {
  // 用户状态、setUser、delUser 等逻辑仍在这里
})

移动文件后,原来 App.vue 里的导入路径会变长。转写稿中先观察到路径变成 modules/user,再用统一导出解决。易错点:移动文件后要检查相对路径和别名路径;不要只改一处导入,仓库内部若有互相引用也要同步检查。

在 stores/index.ts 统一导出仓库 ​

在 stores/index.ts 中把用户仓库导出。可以先写成显式导入导出:

ts
import { useUserStore } from './modules/user'
export { useUserStore }

更简洁的写法是:

ts
// stores/index.ts
export * from './modules/user'

这样业务组件统一从 store 入口导入:

ts
// App.vue
import { useUserStore } from '@/store'

为什么可行:export * 会把 modules/user.ts 中的命名导出再次导出,使用方不再关心仓库放在哪个子目录。易错点:export * 要求被导出模块使用命名导出;如果多个模块有同名导出会冲突,需要显式重命名;路径不要写成 ./module/user。

统一管理后的验证与边界 ​

改完后回到浏览器刷新,点击退出看用户信息是否清空,点击登录看用户信息是否写入,同时确认本地持久化仍然存在。转写稿中的验证结果就是退出、登录都正常,说明 Pinia 独立维护和统一导出都生效。

最后明确边界:stores/index.ts 负责 Pinia 实例、插件安装和仓库汇总导出;main.ts 负责应用装配;modules/user.ts 负责用户仓库业务。这样后续仓库增多时,使用方始终从统一入口导入,维护成本更低。

先明确请求工具要解决什么问题 ​

这一节要做的不是“随便装一个 axios”,而是把项目里所有接口请求的公共逻辑收口到 request.ts:统一基准地址、统一超时时间、统一带 token、统一处理业务失败、统一摘取核心响应数据、统一处理 401,并在此之上封装一个更简单的 request 函数。这样做的原因是:如果每个页面都直接写 axios.get、axios.post,token 拼接、错误提示、401 跳转、返回数据解包都会重复,后期改一处要改很多地方。怎么想:先配置 axios 实例,再写拦截器,最后封装函数和类型。怎么做:安装 axios,在 src/utils/request.ts 中维护实例与拦截器。易错点:不要一开始就把所有功能混在一起,转写稿的调试顺序是“配一点、测一点”,先验证基准地址、超时和 token,再验证业务失败与 401。

创建 axios 实例:baseURL 与 timeout ​

为什么需要实例:项目接口通常有统一域名,如果每个请求都写完整地址,既长又容易写错。怎么想:从接口文档里找“域名 + 接口路径”的结构,前面公共部分就是基准地址;超时时间按项目约定设置。怎么做:安装依赖后创建实例,核心代码形态如下,具体基准地址从接口文档复制,不要手写猜:

ts
import axios from 'axios'

const instance = axios.create({
  baseURL: '从接口文档复制的基准地址',
  timeout: 10000
})

export default instance

易错点:baseURL 后面不要再重复拼接口文档里的公共域名;timeout 单位是毫秒,转写稿中设置的是 10 秒。调试时先发一个请求,看请求地址是否自动带上了基准地址。

请求拦截器:携带 token ​

为什么需要:登录后后台通过 token 识别用户,后续接口要在请求头里带 Authorization。怎么想:token 存在用户仓库里,请求发出前从仓库取;有 token 才拼 Bearer 并写入请求头。怎么做:在请求拦截器里取 userStore 的 token,并判断 config.headers 是否存在:

ts
instance.interceptors.request.use((config) => {
  const userStore = useUserStore()
  const token = userStore.user?.token
  if (token && config.headers) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

易错点:config.headers 在类型上可能是 undefined,不能只用可选链去“赋值”,否则 TS 会提示对象可能未定义;要先判断再设置。转写稿的调试方法是点“获取用户信息”,在浏览器 Network 的请求标头里看有没有 Authorization: Bearer 1,有就说明携带成功。

响应成功:处理业务失败并摘取核心数据 ​

为什么需要:HTTP 状态码 200 只代表请求到达后台,不代表业务成功。转写稿约定后台 code === 10000 为正常,其他 code 是业务失败,例如登录密码错误。怎么想:响应成功回调里先判断业务 code;失败时轻提示后台 message,并返回失败的 Promise,让调用处走 catch;成功时直接返回 res.data,把 axios 外层对象摘掉。怎么做:

ts
instance.interceptors.response.use(
  (res) => {
    if (res.data.code !== 10000) {
      showToast(res.data.message || '业务失败')
      return Promise.reject(res.data)
    }
    return res.data
  },
  (error) => {
    // 401 处理见下一段
    return Promise.reject(error)
  }
)

易错点:业务失败要 Promise.reject(res.data),这样 catch 里拿到的是后台数据对象,可以继续取 code 做不同处理;成功时返回 res.data 后,调用处拿到的就不再是 axios 的完整响应对象。转写稿中先用错误密码测出轻提示和 catch,再把密码改对,验证成功时打印的是后台核心数据。

响应失败:401 清信息并带 returnURL 跳登录 ​

为什么需要:token 失效时后台返回 401,如果还停留在原页面,用户会不断看到失败。怎么想:401 时清空用户仓库和本地持久化信息,再跳登录页;为了登录后能回到原页面,跳转时携带当前完整路由地址。怎么做:给错误参数加 AxiosError 类型,判断 error.response?.status === 401,调用 store.deleteUser(),再用 router.push:

ts
if (error.response?.status === 401) {
  const store = useUserStore()
  store.deleteUser()
  router.push({
    path: '/login',
    query: {
      returnURL: router.currentRoute.value.fullPath
    }
  })
}

易错点:router.currentRoute 是 ref,要取 .value.fullPath;fullPath 才包含 query 和 hash,path 不够。转写稿中模拟访问带参数的档案页后触发 401,验证本地信息被清空、跳登录页且 returnURL 带上了原地址和参数。

封装 request 函数:简化传参 ​

为什么需要:直接调 axios 要传配置对象,get 要写 params,post 要写 data,调用处不统一。怎么想:封装成 request(url, method, submitData) 三参,默认 GET;函数内部根据 method 决定放 params 还是 data。怎么做:

ts
import type { Method } from 'axios'

export const request = <T = any>(
  url: string,
  method: Method = 'GET',
  submitData?: object
) => {
  return instance.request<any, Data<T>>({
    url,
    method,
    [method.toUpperCase() === 'GET' ? 'params' : 'data']: submitData
  })
}

易错点:method 可能大小写不一致,判断前统一 toUpperCase();GET 用 params,POST/PUT/PATCH 用 data;submitData 可选,因为有的接口不传提交数据。调试时分别用登录接口和获取用户信息接口验证 data 与 params 是否放对。

用泛型约束响应数据类型 ​

为什么需要:不同接口返回的 data 结构不同,不设类型时 res.data 提示不准确。怎么想:让 request 支持泛型,把调用处传入的业务数据类型传给 axios 的第二个泛型参数。转写稿中定义类型别名:

ts
type Data<T> = {
  code: number
  message: string
  data: T
}

调用时写 request<User>('接口路径', 'POST', { mobile, password }),这样返回值里的 data 就是 User。易错点:axios 第一个泛型不是这里要设置的自定义响应数据;因为响应拦截器已经返回了 res.data,所以要用 instance.request<any, Data<T>> 的第二个泛型。类型设置好后,鼠标悬停能看到 code、message、data,再点 data 能看到 User 的字段,说明类型链路打通。

代码示例 ​

一、用户仓库 + 持久化 + 统一导出(Pinia) ​

ts
// src/types/user.ts
// 参考接口文档「密码登录」返回的用户信息定义
export type User = {
  id: string
  account: string
  avatar: string
  token: string
  mobile: string
}
ts
// src/stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { User } from '@/types/user'

// 参数1:仓库唯一标识  参数2:setup 函数  参数3:配置对象(开启持久化)
export const useUserStore = defineStore(
  'cp-user',
  () => {
    // 1. 用户信息状态
    const user = ref<User>()

    // 2. 设置用户信息(登录成功后调用)
    const setUser = (u: User) => {
      user.value = u
    }

    // 3. 删除用户信息(退出登录 / token 失效时调用)
    const delUser = () => {
      user.value = undefined
    }

    return { user, setUser, delUser }
  },
  {
    persist: true // 开启本地持久化,自动写入 localStorage
  }
)
ts
// src/stores/index.ts
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'

const pinia = createPinia()
// 注意:必须在 createPinia 之后 use 插件
pinia.use(persist)

export default pinia

// 统一导出:将来所有仓库都从 '@/stores' 导入
export * from './modules/user'
ts
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from './stores'

createApp(App).use(pinia).use(router).mount('#app')

关键行与易错点

  • defineStore 的第三个参数 { persist: true } 才是持久化开关,写在前两个参数里无效。
  • pinia.use(persist) 必须在 createPinia() 之后执行,且要在 app.use(pinia) 之前完成,否则插件不生效。
  • 仓库文件要放在 stores/modules/ 下,index.ts 用 export * from './modules/user' 统一导出,业务里统一写 import { useUserStore } from '@/stores',避免 '@/stores/modules/user' 这类长路径。
  • ref<User>() 不传初值时类型是 User | undefined,delUser 里赋 undefined 才不报错;如果写成 ref<User>({} as User) 就清不干净。
  • 验证方式:模拟登录后刷新页面,用户信息仍在;本地存储里能看到键名 cp-user(即 store id)。

二、axios 配置 + 响应拦截 + request 函数封装(src/utils/request.ts) ​

ts
// src/utils/request.ts
import axios, { type Method } from 'axios'
import type { AxiosError } from 'axios'
import { showToast } from 'vant'
import router from '@/router'
import { useUserStore } from '@/stores'

// 1. 创建实例:基准地址 + 超时时间(基准地址以接口文档为准)
const instance = axios.create({
  baseURL: 'https://consult-api.itheima.net/',
  timeout: 10000
})

// 2. 请求拦截器:携带 token
instance.interceptors.request.use(
  (config) => {
    const store = useUserStore()
    // 类型守卫:headers 可能为 undefined,不能直接赋值
    if (store.user?.token && config.headers) {
      config.headers.Authorization = `Bearer ${store.user.token}`
    }
    return config
  },
  (err) => Promise.reject(err)
)

// 3. 响应拦截器
instance.interceptors.response.use(
  (res) => {
    // 业务失败:HTTP 200 但 code !== 10000
    if (res.data.code !== 10000) {
      showToast(res.data.message || '业务失败')
      // 返回失败的 promise,后续走 catch,并带上 code 供外部区分处理
      return Promise.reject(res.data)
    }
    // 摘取核心响应数据
    return res.data
  },
  (err: AxiosError) => {
    // 401:token 失效
    if (err.response?.status === 401) {
      const store = useUserStore()
      store.delUser() // 清仓库 = 同时清本地存储
      router.push({
        path: '/login',
        // 携带当前页面完整地址(含参数),登录成功后可回跳
        query: { returnUrl: router.currentRoute.value.fullPath }
      })
    }
    return Promise.reject(err)
  }
)

// 4. 响应数据统一结构
type Data<T> = {
  code: number
  message: string
  data: T
}

// 5. 请求函数封装:url / method / 提交数据
export const request = <T>(url: string, method: Method = 'GET', submitData?: object) => {
  return instance.request<any, Data<T>>({
    url,
    method,
    // 动态属性名:GET 走 params,其余走 data
    [method.toUpperCase() === 'GET' ? 'params' : 'data']: submitData
  })
}

关键行与易错点

  • config.headers.Authorization = ... 前必须判断 config.headers 存在,TS 里 headers 是可选类型,直接用可选链赋值会报错(可选链只能读不能写)。
  • 业务失败 return Promise.reject(res.data) 才能让调用方走 catch,并能在 catch 里拿到 code 做差异化处理;直接 throw 或只弹提示不 reject,外部会误以为成功。
  • 成功分支 return res.data 是「摘取核心数据」的关键:拦截器返回什么,await request() 拿到的就是什么。
  • 401 判断要用 err.response?.status(err 需标注 AxiosError 才有提示),不是 err.status。
  • returnUrl 用 router.currentRoute.value.fullPath(含 query/hash),不要用 path,否则参数丢失,登录后回跳会丢条件。
  • request 的泛型必须写在 instance.request 的第二个泛型位(自定义响应数据类型),第一个位写 any;写第一个位拿到的是 axios 包装后的完整响应对象。
  • 动态属性名 [条件 ? 'params' : 'data'] 要配合 method.toUpperCase(),否则传小写 get 会误放进 data。

三、在组件中使用(模拟登录 / 退出 / 调接口) ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { request } from '@/utils/request'
import { useUserStore } from '@/stores'
import type { User } from '@/types/user'

const store = useUserStore()

// 模拟登录:写入仓库(会被持久化)
const onLogin = () => {
  store.setUser({
    id: '1',
    account: '1',
    avatar: '1',
    token: '1',
    mobile: '1'
  })
}

// 模拟退出:清空仓库与本地存储
const onLogout = () => {
  store.delUser()
}

// 真实调用接口:返回类型即 User
const getProfile = async () => {
  const res = await request<User>('/patient/myUser', 'GET', { test: 1 })
  console.log(res.code, res.message, res.data.token)
}

// 真实登录(密码故意写错,验证业务失败处理)
const onRealLogin = async () => {
  try {
    const res = await request<User>('/patient/login', 'POST', {
      mobile: '13212345678',
      password: 'abc12345'
    })
    console.log('登录成功', res.data)
  } catch (err) {
    console.log('登录失败', err) // 能拿到 { code, message }
  }
}
</script>

<template>
  <div>当前用户:{{ store.user }}</div>
  <van-button type="primary" @click="onLogin">模拟登录</van-button>
  <van-button type="danger" @click="onLogout">模拟退出</van-button>
  <van-button @click="getProfile">获取用户信息</van-button>
  <van-button @click="onRealLogin">接口登录</van-button>
</template>

关键行与易错点

  • request<User>(...) 传进去的 User 会经 request 的泛型 T → Data<T> → instance.request 第二泛型,最终 res.data 就是 User 类型,点 .token、.mobile 都有提示。
  • 调用 GET 时第三个参数是查询参数(进 params),POST 时是请求体(进 data),不用再手写 { url, method, params } 配置对象。
  • 业务失败时不会进 try 的成功分支,而是在 catch 里拿到 { code, message },可据此做不同错误提示。
  • 验证 token 是否带上:打开浏览器 Network 面板,看请求头是否有 Authorization: Bearer xxx;验证持久化:登录后刷新页面,store.user 仍在。

常见错误 ​

  1. 刷新页面后用户信息丢失

    • 现象:登录后状态正常,F5 刷新后 user 变回 undefined。
    • 原因:只在 store 里定义了状态,没有开启持久化;或插件装了但没在 pinia 实例上 use。
    • 修法:在创建 pinia 实例后 pinia.use(piniaPluginPersistedstate),并在 defineStore 第三个参数写 { persist: true };两处缺一不可。
  2. 请求拦截器给 config.headers.Authorization 赋值时报“对象可能为 undefined”

    • 现象:TS 红线,提示 headers 可能为 undefined;用 config.headers?.Authorization = ... 后报“左侧不能是可选属性”。
    • 原因:headers 类型允许 undefined,可选链只能读不能写。
    • 修法:先做类型守卫 if (config.headers) { config.headers.Authorization = ... },再赋值。
  3. 业务失败判断写了但页面还是走 then

    • 现象:code !== 10000 时 toast 弹了,但调用处仍进入 then。
    • 原因:只弹提示没有 return Promise.reject(...),或 reject 写在 return res.data 之后成了死代码。
    • 修法:业务失败分支内先 toast,再 return Promise.reject(res.data),成功分支最后才 return res.data。
  4. catch 里拿不到后端返回的 code

    • 现象:catch(err) 打印出来是字符串或 undefined,无法按错误码分支处理。
    • 原因:Promise.reject 传的是 res.data.message 而不是整个数据对象。
    • 修法:reject(res.data),调用处用 err.code 判断;同时约定好 reject 的数据形状。
  5. 401 跳登录后无法回到原页面

    • 现象:登录成功一律回首页,用户之前所在页面和查询参数丢失。
    • 原因:跳转时只写了 router.push('/login'),没带 returnURL;或用了 route.path 而非 fullPath,query 丢失。
    • 修法:router.push({ path: '/login', query: { returnURL: router.currentRoute.value.fullPath } }),登录成功后按该参数回跳。
  6. 封装的 request 发 GET 时后端收不到参数

    • 现象:GET 请求参数为空,POST 正常。
    • 原因:统一把提交数据放进了 data,GET 应放 params。
    • 修法:用动态键 [method.toUpperCase() === 'GET' ? 'params' : 'data']: submitData,并给 method 默认值 'GET'。
  7. 泛型标了但 res.data 没有类型提示

    • 现象:调用 request<User> 后 res 仍是 AxiosResponse 结构,点不出业务字段。
    • 原因:泛型传给了 axios.request 的第一个类型参数(响应整体类型),而拦截器已返回 res.data。
    • 修法:第一个参数写 any,业务类型放第二个:instance.request<any, Data<T>>(config),其中 Data<T> 统一收敛 code/message/data。
  8. 仓库多了以后 import 路径到处改

    • 现象:模块挪进 modules 目录后,各页面引用路径全部报错。
    • 原因:每个页面直接引具体 store 文件,没有统一出口。
    • 修法:stores/index.ts 里 export * from './modules/user',页面统一从 @/stores 引入。

练习 ​

  1. 在 user store 中补充一个 token 的 getter,并验证:登录 → 刷新页面 → token 仍在;退出登录 → 刷新 → 本地存储中 cp-user 已被清除。

  2. 改造请求拦截器:当仓库中 token 为空时,不添加 Authorization 头。用浏览器 Network 面板分别验证“有 token”和“无 token”两种请求的请求头差异。

  3. 故意用错误密码调用登录接口,验证:① 页面出现 toast 提示后端 message;② 调用处进入 catch;③ catch 中能读到 err.code 并据其打印不同文案。

  4. 手动把本地 token 改成无效值,在带 query 参数的页面触发 401,验证:本地用户信息被清空、跳转到 /login 且 returnURL 为含参数的完整地址;再模拟登录成功后按 returnURL 回跳。

  5. 给“获取用户信息”接口调用处加上泛型 User,验证 res.data 能点出 id / mobile / token / account 等字段;再把 request 中的响应结构抽成类型别名,确认调用处无需重复声明 code/message。

课件图示 ​

课件第44页:基础架构-请求工具-axios配置 axios配置 创建axios实例 基准地址

课件第48页:基础架构-请求工具-请求函数 axios配置 创建axios实例 基准地址,超时

课件第52页:基础架构-请求工具-函数封装-设置类型 axios配置 创建axios实例 基准

课件第29页:基础架构-用户仓库和持久化 优医问诊H5 构建界面 数据交互 状态管理 引入Va

课件第20页:基础架构-引入Vant和移动端适配 优医问诊H5 构建界面 数据交互 状态管理

课件第24页:基础架构-主题定制 优医问诊H5 构建界面 数据交互 状态管理 引入Vant 移

原视频对照 ​

#视频时长
1基础架构-用户仓库和持久化13:17
2基础架构-统一管理09:16
3请求工具-axios配置09:35
4请求工具-业务失败处理和摘取核心响应数据10:21
5请求工具-401处理09:28
6请求工具-工具函数08:44
7请求工具-类型设置11:14

小结 ​

  1. 用户仓库的三件套是“状态 + 设置函数 + 删除函数”,类型定义必须以接口文档返回结构为准,不要凭感觉写。
  2. 持久化是“插件注册 + store 配置”两步,任何一步漏掉都表现为“刷新即丢”,排查时先看这两处。
  3. 基础架构的收口原则:pinia 初始化归 stores/index.ts,store 归 modules,对外只暴露统一出口,页面不感知内部目录。
  4. 请求拦截器负责“带凭证”,响应拦截器负责“判业务、摘数据、处理 401”,职责不要混在页面里。
  5. 业务失败必须 reject 且携带结构化数据,否则调用方无法按 code 做差异化处理;HTTP 200 不等于业务成功。
  6. 401 的关键不是“跳登录”,而是“带着 fullPath 跳登录”,这是登录后可回跳的前提。
  7. 请求函数封装的核心价值是“三参调用 + 按 method 自动分配 params/data”,让调用处不再拼配置对象。
  8. 泛型要落在 axios.request 的第二个类型参数上,并把 code/message/data 抽成公共类型别名,业务侧只传 data 的类型。