{T}

课程列表页面与接口对接实战

学习目标:掌握 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-if

4.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-forv-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定义 StoredefineStore('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 字段控制显示顺序,前端无需排序逻辑!

下节预告:课程详情页面开发,深入学习动态路由、参数传递、评论功能、收藏功能等高级应用!

笔记已按照您的格式规范整理完成,可直接用于学习复习!