课程列表页面与接口对接实战
学习目标:掌握 Store 创建与使用、TypeScript Interface 定义、JSON to TS 工具使用、页面数据渲染、动态排序实现。
一、课程列表页面对接概述
1.1 前后端对接工作流
code
前后端对接工作流(推荐):
│
├── 第一步:创建 Store
│ ├── 定义 State(状态)
│ ├── 定义 Actions(方法)
│ └── 定义 Types(类型)
│
├── 第二步:定义接口类型
│ ├── 复制后端响应的 JSON 数据
│ ├── 使用 JSON to TS 转换为 TypeScript Interface
│ └── 手动调整类型定义
│
├── 第三步:在 Store 中实现请求逻辑
│ ├── 使用 useFetch 请求接口
│ ├── 处理响应数据
│ └── 更新 State
│
├── 第四步:页面调用 Store
│ ├── 引入 Store
│ ├── 调用 Actions
│ └── 使用 State 数据
│
└── 第五步:渲染页面
├── 使用 v-for 循环
├── 使用 v-if 判断
└── 动态显示内容1.2 核心知识点
code
课程列表页面对接核心要点:
│
├── 1. Store 管理
│ ├── defineStore 创建 Store
│ ├── state 管理状态
│ ├── actions 管理方法
│ └── TypeScript 类型约束
│
├── 2. TypeScript Interface 定义
│ ├── JSON to TS 工具使用
│ ├── 手动调整类型
│ ├── 可选属性(?)
│ └── 类型导入导出
│
├── 3. useFetch 请求
│ ├── 异步请求处理
│ ├── 响应数据判断
│ ├── 错误处理
│ └── 类型断言
│
├── 4. 页面数据渲染
│ ├── v-for 循环渲染
│ ├── v-if 条件判断
│ ├── template 包裹
│ └── 数据绑定
│
└── 5. 动态排序
├── 后端 order 字段控制
├── 管理后台调整
├── 前端自动渲染
└── 无需前端排序二、Store 创建与管理
2.1 Store 文件结构
code
Store 文件结构:
│
├── stores/
│ ├── useStudyStore.ts # 学习相关 Store
│ ├── useHomeStore.ts # 首页相关 Store
│ └── useUserStore.ts # 用户相关 Store
│
├── types/
│ └── index.d.ts # TypeScript 类型定义
│
└── 页面/
└── pages/
└── study/
└── index.vue # 学习页面2.2 创建 useStudyStore
基础结构:
typescript
// stores/useStudyStore.ts
import { defineStore } from 'pinia'
import type { GetCoursesInterface } from '@/types/index.d'
export const useStudyStore = defineStore('study', {
// State:状态
state: () => ({
list: [] as GetCoursesInterface[], // 课程列表
}),
// Actions:方法
actions: {
// 获取课程列表
async getCoursesList() {
// 请求逻辑...
},
},
})完整实现:
typescript
// stores/useStudyStore.ts
import { defineStore } from 'pinia'
import type { GetCoursesInterface } from '@/types/index.d'
export const useStudyStore = defineStore('study', {
// State:状态
state: () => ({
list: [] as GetCoursesInterface[], // 课程列表
loading: false, // 加载状态
error: null as string | null, // 错误信息
}),
// Actions:方法
actions: {
/**
* 获取课程列表
*/
async getCoursesList() {
this.loading = true
this.error = null
try {
// 使用 useFetch 请求接口
const res = await useFetch('/api/courses')
// 判断响应状态
if (res.status.value === 'success') {
// 类型断言并赋值
this.list = res.data.value as GetCoursesInterface[]
// 额外判断:数据是否存在且为数组
if (res.data.value && Array.isArray(res.data.value)) {
this.list = res.data.value as GetCoursesInterface[]
}
} else {
// 错误处理
console.error('获取课程列表接口失败')
this.error = '获取课程列表失败'
}
} catch (error) {
console.error('请求失败:', error)
this.error = '请求失败'
} finally {
this.loading = false
}
},
},
})2.3 Store 组合式 API 写法
typescript
// stores/useStudyStore.ts(组合式 API 写法)
import { defineStore } from 'pinia'
import type { GetCoursesInterface } from '@/types/index.d'
export const useStudyStore = defineStore('study', () => {
// State
const list = ref<GetCoursesInterface[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Actions
const getCoursesList = async () => {
loading.value = true
error.value = null
try {
const res = await useFetch('/api/courses')
if (res.status.value === 'success') {
list.value = res.data.value as GetCoursesInterface[]
} else {
console.error('获取课程列表接口失败')
error.value = '获取课程列表失败'
}
} catch (err) {
console.error('请求失败:', err)
error.value = '请求失败'
} finally {
loading.value = false
}
}
// 返回 State 和 Actions
return {
list,
loading,
error,
getCoursesList,
}
})2.4 Store 最佳实践
code
Store 最佳实践:
│
├── 1. State 类型定义
│ ├── 使用泛型约束类型
│ ├── 示例:list: [] as GetCoursesInterface[]
│ └── 避免使用 any
│
├── 2. Actions 命名规范
│ ├── 使用动词开头:get、fetch、update、delete
│ ├── 示例:getCoursesList、fetchUserDetail
│ └── 语义化命名
│
├── 3. 错误处理
│ ├── 添加 loading 状态
│ ├── 添加 error 状态
│ ├── 使用 try-catch 捕获错误
│ └── 提供友好的错误提示
│
├── 4. 类型断言
│ ├── 使用 as 进行类型断言
│ ├── 示例:res.data.value as GetCoursesInterface[]
│ └── 确保类型安全
│
└── 5. 响应式数据
├── 使用 ref 或 reactive
├── 组合式 API 中使用 ref
└── 选项式 API 中使用 state三、TypeScript Interface 定义
3.1 使用 JSON to TS 工具
什么是 JSON to TS:
code
JSON to TS 是一个 VS Code 扩展工具:
│
├── 功能
│ ├── 将 JSON 数据转换为 TypeScript Interface
│ ├── 自动推断类型
│ ├── 支持嵌套对象
│ └── 支持数组类型
│
├── 安装方式
│ ├── VS Code 扩展商店搜索 "JSON to TS"
│ ├── 点击安装
│ └── 重启 VS Code
│
└── 使用方式
├── 复制 JSON 数据
├── 打开命令面板(Cmd+Shift+P / Ctrl+Shift+P)
├── 输入 "JSON to TS"
├── 选择 "Paste JSON as Types"
└── 输入 Interface 名称使用步骤详解:
code
JSON to TS 使用步骤:
│
├── 第一步:复制后端响应的 JSON 数据
│ └── 示例:
│ [
│ {
│ "id": 11,
│ "name": "精品微课",
│ "courses": [...]
│ }
│ ]
│
├── 第二步:打开命令面板
│ ├── Mac: Cmd + Shift + P
│ └── Windows: Ctrl + Shift + P
│
├── 第三步:搜索并选择 "Paste JSON as Types"
│ └── JSON to TS 扩展提供的命令
│
├── 第四步:输入 Interface 名称
│ └── 例如:GetCoursesInterface
│
└── 第五步:手动调整生成的类型
├── 替换 null 为具体类型
├── 添加可选属性(?)
└── 修改类型为 number、string 等3.2 手动调整 Interface
生成的初始 Interface:
typescript
// types/index.d.ts(自动生成,可能包含 null)
export interface GetCoursesInterface {
id: number | null
name: string | null
courses: CoursesInterface[] | null
}
export interface CoursesInterface {
id: number | null
title: string | null
cover: string | null
price: number | null
status: number | null
}手动调整后的 Interface:
typescript
// types/index.d.ts(手动调整后)
export interface GetCoursesInterface {
id: number // 分类 ID
name: string // 分类名称
courses: CoursesInterface[] // 课程列表
}
export interface CoursesInterface {
id: number // 课程 ID
title: string // 课程标题
cover?: string // 封面图片(可选)
price: number // 价格
status: number // 状态:0-下架,1-上架
description?: string // 描述(可选)
author?: string // 作者(可选)
createdAt?: string // 创建时间(可选)
updatedAt?: string // 更新时间(可选)
}调整要点:
code
Interface 调整要点:
│
├── 1. 替换 null 为具体类型
│ ├── id: number | null
│ ├── id: number
│ └── 确保必填字段不为 null
│
├── 2. 添加可选属性(?)
│ ├── cover: string
│ ├── cover?: string
│ └── 可选字段使用 ? 标记
│
├── 3. 修改类型
│ ├── price: string
│ ├── price: number
│ └── 根据实际情况修改类型
│
├── 4. 添加注释
│ ├── id: number // 课程 ID
│ └── 提高代码可读性
│
└── 5. 统一类型定义
├── 所有接口类型放在 types/index.d.ts
├── 使用 export 导出
└── 便于统一管理3.3 完整 Interface 定义示例
typescript
// types/index.d.ts
/**
* 课程列表接口响应类型
*/
export interface GetCoursesInterface {
id: number // 分类 ID
name: string // 分类名称
order: number // 排序字段
courses: CoursesInterface[] // 课程列表
}
/**
* 课程详情类型
*/
export interface CoursesInterface {
id: number // 课程 ID
title: string // 课程标题
cover?: string // 封面图片
price: number // 价格
originalPrice?: number // 原价
status: number // 状态:0-下架,1-上架
description?: string // 描述
author?: string // 作者
createdAt?: string // 创建时间
updatedAt?: string // 更新时间
tags?: string[] // 标签列表
}
/**
* 分页参数类型
*/
export interface PaginationParams {
page?: number // 页码
size?: number // 每页数量
order?: OrderType // 排序规则
}
/**
* 排序类型
*/
export interface OrderType {
[key: string]: 'asc' | 'desc' // 键值对:字段名 -> 排序方式
}
/**
* 通用响应类型
*/
export interface ApiResponse<T = any> {
code: number // 状态码
message: string // 消息
data: T // 数据
}四、页面调用与数据渲染
4.1 页面调用 Store
基础用法:
Vue SFC
<!-- pages/study/index.vue -->
<script setup lang="ts">
import { useStudyStore } from '@/stores/useStudyStore'
// 创建 Store 实例
const store = useStudyStore()
// 调用 Actions 获取数据
await store.getCoursesList()
// 使用 State(响应式)
const { list, loading, error } = storeToRefs(store)
</script>完整示例:
Vue SFC
<!-- pages/study/index.vue -->
<script setup lang="ts">
import { useStudyStore } from '@/stores/useStudyStore'
// 创建 Store 实例
const store = useStudyStore()
// 页面加载时获取数据
onMounted(async () => {
await store.getCoursesList()
})
// 使用 storeToRefs 解构响应式数据
const { list, loading, error } = storeToRefs(store)
// 计算属性:判断是否有数据
const hasData = computed(() => {
return list.value && list.value.length > 0
})
// 计算属性:分类名称
const categories = computed(() => {
return list.value.map(item => ({
id: item.id,
name: item.name,
count: item.courses?.length || 0,
}))
})
</script>
<template>
<div class="study-page">
<!-- 加载状态 -->
<div v-if="loading" class="loading">
加载中...
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error">
{{ error }}
</div>
<!-- 空数据状态 -->
<div v-else-if="!hasData" class="empty">
暂无数据
</div>
<!-- 数据列表 -->
<div v-else class="course-list">
<div v-for="category in list" :key="category.id" class="category">
<h2>{{ category.name }}</h2>
<div class="courses">
<div
v-for="course in category.courses"
:key="course.id"
class="course-item"
>
<img :src="course.cover" :alt="course.title" />
<h3>{{ course.title }}</h3>
<p>¥{{ course.price }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.loading,
.error,
.empty {
text-align: center;
padding: 40px;
font-size: 18px;
}
.error {
color: #f56c6c;
}
.category {
margin-bottom: 30px;
}
.category h2 {
font-size: 24px;
margin-bottom: 15px;
}
.courses {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.course-item {
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.3s;
}
.course-item:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.course-item img {
width: 100%;
height: 150px;
object-fit: cover;
}
.course-item h3 {
margin: 10px;
font-size: 16px;
}
.course-item p {
margin: 0 10px 10px;
font-size: 18px;
color: #f56c6c;
font-weight: bold;
}
</style>4.2 v-for 与 v-if 结合使用
错误用法:
Vue SFC
<!-- 错误:v-for 和 v-if 在同一元素上 -->
<div v-for="item in list" :key="item.id" v-if="item.courses.length > 0">
<!-- Vue 3 中 v-if 优先级更高,v-for 无法访问 item -->
</div>正确用法一:使用 template:
Vue SFC
<!-- 正确:使用 template 包裹 -->
<template v-for="category in list" :key="category.id">
<div v-if="category.courses && category.courses.length > 0">
<h2>{{ category.name }}</h2>
<div v-for="course in category.courses" :key="course.id">
{{ course.title }}
</div>
</div>
</template>正确用法二:使用计算属性:
Vue SFC
<script setup lang="ts">
import { computed } from 'vue'
// 使用计算属性过滤数据
const filteredList = computed(() => {
return list.value.filter(category =>
category.courses && category.courses.length > 0
)
})
</script>
<template>
<!-- 使用过滤后的数据 -->
<div v-for="category in filteredList" :key="category.id">
<h2>{{ category.name }}</h2>
<div v-for="course in category.courses" :key="course.id">
{{ course.title }}
</div>
</div>
</template>v-for 与 v-if 使用原则:
code
v-for 与 v-if 使用原则:
│
├── 不要在同一元素上使用
│ ├── Vue 3 中 v-if 优先级高于 v-for
│ ├── v-if 无法访问 v-for 的变量
│ └── 会产生错误
│
├── 方案一:使用 template 包裹
│ ├── template 不会生成 DOM 节点
│ ├── v-for 在 template 上
│ └── v-if 在内部元素上
│
├── 方案二:使用计算属性
│ ├── 在 computed 中过滤数据
│ ├── 使用过滤后的数据渲染
│ └── 推荐用于复杂过滤逻辑
│
└── 方案三:使用嵌套元素
├── 外层元素使用 v-for
└── 内层元素使用 v-if4.3 数据渲染最佳实践
code
数据渲染最佳实践:
│
├── 1. 使用 storeToRefs 解构
│ ├── const { list } = storeToRefs(store)
│ ├── const { list } = store(失去响应性)
│ └── 保持响应式特性
│
├── 2. 添加状态判断
│ ├── loading:加载状态
│ ├── error:错误状态
│ ├── empty:空数据状态
│ └── 提供良好的用户体验
│
├── 3. 使用 v-for 的 key
│ ├── 使用唯一标识作为 key
│ ├── 通常是 id
│ └── 避免使用 index
│
├── 4. 条件渲染优化
│ ├── 使用 v-show 频繁切换
│ ├── 使用 v-if 条件较少
│ └── 提高性能
│
└── 5. 数据格式化
├── 使用计算属性格式化数据
├── 使用方法格式化显示
└── 保持模板简洁五、动态排序实现
5.1 排序实现方案对比
code
排序实现方案对比:
│
├── 方案一:后端排序(推荐)
│ ├── 数据库 order 字段控制
│ ├── 管理后台动态调整
│ ├── 前端直接渲染,无需排序
│ ├── 优点:灵活、可配置、实时生效
│ └── 缺点:需要开发管理后台
│
├── 方案二:前端排序
│ ├── 前端根据某个字段排序
│ ├── 使用 sort() 方法
│ ├── 优点:无需后端支持
│ └── 缺点:不够灵活、需要前端处理
│
└── 方案三:前后端结合
├── 后端提供排序字段
├── 前端根据需求排序
├── 优点:灵活可控
└── 缺点:实现复杂5.2 后端排序实现(推荐)
数据库表结构:
sql
-- 课程分类表(course_types)
CREATE TABLE course_types (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '分类名称',
`order` INT DEFAULT 0 COMMENT '排序字段(值越小越靠前)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);后端接口实现:
typescript
// backend/src/modules/course/course.service.ts
async getCoursesByType() {
// 根据 order 字段排序
const result = await this.prisma.courseType.findMany({
orderBy: {
order: 'asc', // 升序排列(order 值小的排前面)
},
include: {
tags: {
include: {
courses: {
include: {
users: true, // 关联作者信息
},
},
},
},
},
})
return result
}管理后台调整 order 字段:
typescript
// backend/src/modules/course/course.controller.ts
@Post('update-type-order')
async updateTypeOrder(@Body() body: UpdateTypeOrderDto) {
return this.courseService.updateTypeOrder(body)
}
// backend/src/modules/course/course.service.ts
async updateTypeOrder(data: UpdateTypeOrderDto) {
const { id, order } = data
// 更新分类的 order 字段
return this.prisma.courseType.update({
where: { id },
data: { order },
})
}前端自动渲染:
Vue SFC
<!-- pages/study/index.vue -->
<script setup lang="ts">
const store = useStudyStore()
await store.getCoursesList()
// 前端无需排序,直接渲染
// 数据已按 order 字段排序返回
const { list } = storeToRefs(store)
</script>
<template>
<div v-for="category in list" :key="category.id">
<!-- 数据顺序由后端 order 字段控制 -->
<h2>{{ category.name }}</h2>
</div>
</template>5.3 前端排序实现
Vue SFC
<script setup lang="ts">
const store = useStudyStore()
await store.getCoursesList()
// 方案一:使用计算属性排序
const sortedList = computed(() => {
return [...list.value].sort((a, b) => {
// 根据 order 字段升序排列
return a.order - b.order
})
})
// 方案二:根据其他字段排序
const sortedByCourseCount = computed(() => {
return [...list.value].sort((a, b) => {
// 根据课程数量降序排列
const countA = a.courses?.length || 0
const countB = b.courses?.length || 0
return countB - countA
})
})
const { list } = storeToRefs(store)
</script>
<template>
<!-- 使用排序后的数据 -->
<div v-for="category in sortedList" :key="category.id">
<h2>{{ category.name }}</h2>
</div>
</template>5.4 动态排序最佳实践
code
动态排序最佳实践:
│
├── 1. 推荐后端排序
│ ├── 数据库 order 字段
│ ├── 管理后台动态调整
│ ├── 前端无需处理
│ └── 实时生效
│
├── 2. order 字段设计
│ ├── 初始值:100、200、300...(预留间隔)
│ ├── 值越小越靠前
│ └── 便于后续调整
│
├── 3. 管理后台实现
│ ├── 提供拖拽排序功能
│ ├── 或提供输入框调整 order 值
│ ├── 调整后自动更新
│ └── 前端实时生效
│
├── 4. 前端排序场景
│ ├── 后端未提供 order 字段
│ ├── 需要用户自定义排序
│ └── 使用计算属性实现
│
└── 5. 性能优化
├── 后端排序性能更好
├── 前端排序适合少量数据
└── 根据实际场景选择六、完整实战示例
6.1 项目结构
code
项目结构:
│
├── stores/
│ └── useStudyStore.ts # 学习相关 Store
│
├── types/
│ └── index.d.ts # TypeScript 类型定义
│
├── pages/
│ └── study/
│ └── index.vue # 学习页面
│
└── server/
└── api/
└── courses/
└── get.ts # 课程接口6.2 完整代码清单
1. TypeScript 类型定义
typescript
// types/index.d.ts
/**
* 课程列表接口响应类型
*/
export interface GetCoursesInterface {
id: number // 分类 ID
name: string // 分类名称
order: number // 排序字段
courses: CoursesInterface[] // 课程列表
}
/**
* 课程详情类型
*/
export interface CoursesInterface {
id: number // 课程 ID
title: string // 课程标题
cover?: string // 封面图片
price: number // 价格
status: number // 状态:0-下架,1-上架
description?: string // 描述
author?: AuthorInterface // 作者信息
}
/**
* 作者信息类型
*/
export interface AuthorInterface {
id: number // 作者 ID
username: string // 作者名称
avatar?: string // 头像
}2. Store 实现
typescript
// stores/useStudyStore.ts
import { defineStore } from 'pinia'
import type { GetCoursesInterface } from '@/types/index.d'
export const useStudyStore = defineStore('study', () => {
// State
const list = ref<GetCoursesInterface[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Actions:获取课程列表
const getCoursesList = async () => {
loading.value = true
error.value = null
try {
const res = await useFetch('/api/courses')
if (res.status.value === 'success') {
// 类型断言
if (res.data.value && Array.isArray(res.data.value)) {
list.value = res.data.value as GetCoursesInterface[]
}
} else {
console.error('获取课程列表接口失败')
error.value = '获取课程列表失败'
}
} catch (err) {
console.error('请求失败:', err)
error.value = '请求失败'
} finally {
loading.value = false
}
}
// 计算属性:分类总数
const totalCategories = computed(() => list.value.length)
// 计算属性:课程总数
const totalCourses = computed(() => {
return list.value.reduce((total, category) => {
return total + (category.courses?.length || 0)
}, 0)
})
return {
list,
loading,
error,
getCoursesList,
totalCategories,
totalCourses,
}
})3. 页面实现
Vue SFC
<!-- pages/study/index.vue -->
<script setup lang="ts">
import { useStudyStore } from '@/stores/useStudyStore'
// 创建 Store 实例
const store = useStudyStore()
// 页面加载时获取数据
await store.getCoursesList()
// 解构响应式数据
const { list, loading, error, totalCategories, totalCourses } = storeToRefs(store)
// 计算属性:是否有数据
const hasData = computed(() => {
return list.value && list.value.length > 0
})
</script>
<template>
<div class="study-page">
<!-- 页面头部 -->
<div class="header">
<h1>学习中心</h1>
<p v-if="hasData">
共 {{ totalCategories }} 个分类,{{ totalCourses }} 门课程
</p>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error">
<p>{{ error }}</p>
<button @click="store.getCoursesList">重试</button>
</div>
<!-- 空数据状态 -->
<div v-else-if="!hasData" class="empty">
<p>暂无课程数据</p>
</div>
<!-- 课程列表 -->
<div v-else class="course-list">
<template v-for="category in list" :key="category.id">
<div v-if="category.courses && category.courses.length > 0" class="category">
<div class="category-header">
<h2>{{ category.name }}</h2>
<span class="count">{{ category.courses.length }} 门课程</span>
</div>
<div class="courses">
<div
v-for="course in category.courses"
:key="course.id"
class="course-item"
>
<div class="cover">
<img :src="course.cover || '/default-cover.jpg'" :alt="course.title" />
</div>
<div class="info">
<h3>{{ course.title }}</h3>
<p v-if="course.description" class="description">
{{ course.description }}
</p>
<div class="meta">
<span class="author" v-if="course.author">
{{ course.author.username }}
</span>
<span class="price">¥{{ course.price }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.study-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
margin-bottom: 30px;
}
.header h1 {
font-size: 32px;
margin-bottom: 10px;
}
.header p {
color: #909399;
font-size: 14px;
}
.loading,
.error,
.empty {
text-align: center;
padding: 60px 20px;
}
.loading .spinner {
width: 40px;
height: 40px;
border: 3px solid #e4e7ed;
border-top-color: #409eff;
border-radius: 50%;
margin: 0 auto 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #f56c6c;
}
.error button {
margin-top: 20px;
padding: 10px 30px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.category {
margin-bottom: 40px;
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #e4e7ed;
}
.category-header h2 {
font-size: 24px;
}
.category-header .count {
color: #909399;
font-size: 14px;
}
.courses {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.course-item {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s, box-shadow 0.3s;
}
.course-item:hover {
transform: translateY(-5px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.cover {
width: 100%;
height: 160px;
overflow: hidden;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.course-item:hover .cover img {
transform: scale(1.05);
}
.info {
padding: 15px;
}
.info h3 {
font-size: 16px;
margin-bottom: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.description {
color: #606266;
font-size: 14px;
margin-bottom: 15px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.author {
color: #909399;
font-size: 14px;
}
.price {
color: #f56c6c;
font-size: 18px;
font-weight: bold;
}
</style>七、常见问题与解决方案
7.1 类型断言问题
问题描述:res.data.value 类型与 list 类型不匹配。
解决方案:
typescript
// 错误:类型不匹配
this.list = res.data.value // 类型错误
// 方案一:使用 as 断言
this.list = res.data.value as GetCoursesInterface[]
// 方案二:使用泛型
const res = await useFetch<GetCoursesInterface[]>('/api/courses')
// 方案三:先判断再赋值
if (res.data.value && Array.isArray(res.data.value)) {
this.list = res.data.value as GetCoursesInterface[]
}7.2 v-for 与 v-if 冲突问题
问题描述:v-for 和 v-if 在同一元素上使用。
解决方案:
Vue SFC
<!-- 错误 -->
<div v-for="item in list" :key="item.id" v-if="item.show">
{{ item.name }}
</div>
<!-- 方案一:使用 template -->
<template v-for="item in list" :key="item.id">
<div v-if="item.show">
{{ item.name }}
</div>
</template>
<!-- 方案二:使用计算属性 -->
<script setup>
const filteredList = computed(() => {
return list.value.filter(item => item.show)
})
</script>
<div v-for="item in filteredList" :key="item.id">
{{ item.name }}
</div>7.3 storeToRefs 使用问题
问题描述:直接解构 Store 失去响应性。
解决方案:
typescript
// 错误:失去响应性
const { list, loading } = store
// 正确:使用 storeToRefs
const { list, loading } = storeToRefs(store)
// Actions 不需要 storeToRefs
const { getCoursesList } = store八、最佳实践总结
8.1 Store 管理最佳实践
code
Store 管理最佳实践:
│
├── 1. Store 文件组织
│ ├── 按功能模块划分 Store
│ ├── 文件命名:use[功能]Store.ts
│ └── 示例:useStudyStore.ts、useUserStore.ts
│
├── 2. State 定义
│ ├── 使用 TypeScript 类型约束
│ ├── 提供初始值
│ └── 避免使用 any
│
├── 3. Actions 命名
│ ├── 使用动词开头
│ ├── 语义化命名
│ └── 示例:getCoursesList、fetchUserDetail
│
├── 4. 错误处理
│ ├── 添加 loading 状态
│ ├── 添加 error 状态
│ └── 使用 try-catch
│
└── 5. 计算属性
├── 在 Store 中定义计算属性
├── 返回给页面使用
└── 提高代码复用性8.2 TypeScript 类型定义最佳实践
code
TypeScript 类型定义最佳实践:
│
├── 1. 使用 JSON to TS 工具
│ ├── 快速生成 Interface
│ ├── 提高开发效率
│ └── 避免手动定义错误
│
├── 2. 手动调整类型
│ ├── 替换 null 为具体类型
│ ├── 添加可选属性(?)
│ ├── 添加注释说明
│ └── 确保类型准确
│
├── 3. 类型文件组织
│ ├── 统一放在 types 目录
│ ├── 使用 index.d.ts 导出
│ └── 便于统一管理
│
└── 4. 类型复用
├── 提取公共类型
├── 使用 extends 继承
└── 避免重复定义8.3 页面渲染最佳实践
code
页面渲染最佳实践:
│
├── 1. 状态管理
│ ├── loading:加载状态
│ ├── error:错误状态
│ ├── empty:空数据状态
│ └── 提供良好的用户体验
│
├── 2. 数据绑定
│ ├── 使用 storeToRefs 解构
│ ├── 保持响应性
│ └── 使用计算属性处理数据
│
├── 3. 列表渲染
│ ├── 使用唯一 key
│ ├── v-for 和 v-if 分开使用
│ └── 使用 template 包裹
│
└── 4. 性能优化
├── 使用 v-show 频繁切换
├── 使用 v-if 条件较少
└── 避免不必要的计算九、命令速查表
9.1 VS Code 快捷键速查
| 快捷键 | 说明 |
|---|---|
| Mac | |
Cmd + Shift + P | 打开命令面板 |
Cmd + D | 选中下一个相同词 |
Cmd + F2 | 选中所有相同词 |
Cmd + / | 注释/取消注释 |
| Windows | |
Ctrl + Shift + P | 打开命令面板 |
Ctrl + D | 选中下一个相同词 |
Ctrl + F2 | 选中所有相同词 |
Ctrl + / | 注释/取消注释 |
9.2 Vue 3 指令速查
| 指令 | 说明 | 示例 |
|---|---|---|
| v-for | 列表渲染 | v-for="item in list" :key="item.id" |
| v-if | 条件渲染 | v-if="loading" |
| v-else-if | 条件渲染 | v-else-if="error" |
| v-else | 条件渲染 | v-else |
| v-show | 显示/隐藏 | v-show="isVisible" |
| v-bind | 属性绑定 | :src="image" |
| v-on | 事件绑定 | @click="handleClick" |
9.3 Pinia API 速查
| API | 说明 | 示例 |
|---|---|---|
| defineStore | 定义 Store | defineStore('study', () => {}) |
| storeToRefs | 解构响应式数据 | const { list } = storeToRefs(store) |
| computed | 计算属性 | const total = computed(() => list.value.length) |
| ref | 响应式引用 | const count = ref(0) |
| reactive | 响应式对象 | const state = reactive({ count: 0 }) |
十、学习要点总结
10.1 核心知识点
code
课程列表页面对接核心要点:
│
├── 1. Store 创建与管理
│ ├── defineStore 创建 Store
│ ├── state 管理状态
│ ├── actions 管理方法
│ └── computed 计算属性
│
├── 2. TypeScript Interface 定义
│ ├── JSON to TS 工具使用
│ ├── 手动调整类型
│ ├── 可选属性定义
│ └── 类型导入导出
│
├── 3. useFetch 请求
│ ├── 异步请求处理
│ ├── 响应状态判断
│ ├── 错误处理
│ └── 类型断言
│
├── 4. 页面数据渲染
│ ├── storeToRefs 解构
│ ├── v-for 循环渲染
│ ├── v-if 条件判断
│ └── 状态管理
│
└── 5. 动态排序
├── 后端 order 字段
├── 管理后台调整
└── 前端自动渲染10.2 重要程度标注
| 知识点 | 重要程度 | 说明 |
|---|---|---|
| Store 创建 | 必须掌握,Pinia 核心用法 | |
| TypeScript Interface | 必须掌握,类型安全基础 | |
| useFetch 请求 | 必须掌握,Next.js 请求方式 | |
| v-for 与 v-if | 必须掌握,Vue 3 核心指令 | |
| storeToRefs | 必须掌握,响应式数据解构 | |
| 动态排序 | 重要,实际项目常用 |
10.3 学习路径规划
code
学习路径规划:
│
├── 第一阶段:理解概念(1 天)
│ ├── 理解 Store 的作用
│ ├── 理解 TypeScript Interface
│ ├── 理解 useFetch 请求
│ └── 理解 v-for 和 v-if
│
├── 第二阶段:实践操作(2-3 天)
│ ├── 创建 Store
│ ├── 定义 TypeScript Interface
│ ├── 实现请求逻辑
│ ├── 实现页面渲染
│ └── 测试验证
│
└── 第三阶段:深入应用(持续)
├── 复杂业务场景
├── 性能优化
├── 状态管理进阶
└── TypeScript 高级用法重要提示:课程列表页面与接口对接是前后端协作的核心内容,掌握 Store 创建、TypeScript Interface 定义、useFetch 请求、页面数据渲染,对实际项目开发非常重要!推荐使用后端排序方案,通过 order 字段控制显示顺序,前端无需排序逻辑!
下节预告:课程详情页面开发,深入学习动态路由、参数传递、评论功能、收藏功能等高级应用!
笔记已按照您的格式规范整理完成,可直接用于学习复习!