综合实战:产品展示站

第 1 节 概述

前几章我们分别学了 Vue 3 的各个知识点。这一章把它们串起来,从零开始做一个完整的小项目。

我们要做一个产品展示站,包含三个页面:

  • 首页 — 产品列表,网格展示
  • 产品详情页 — 点进某个产品看详情和加购
  • 购物车页 — 查看已加购的商品

涉及的技术栈和对应知识点:

技术用途对应章节
Vue 3 + Vite项目框架第 1 章
Vue Router页面路由第 8 章
Pinia购物车状态管理第 9 章
组合式函数封装产品数据逻辑第 10 章
组件 + Props/事件产品卡片、头部导航第 4、5 章
插槽通用的网格布局组件第 6 章
Tailwind CSS页面样式

第 2 节 1. 创建项目

先用 Vite 创建 Vue 3 + TypeScript 项目:

1
npm create vue@latest

创建时勾选 TypeScriptRouterPinia

创建完成后安装 Tailwind CSS:

1
npm install tailwindcss @tailwindcss/vite

vite.config.ts 中添加 Tailwind 插件:

1
2
3
4
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({  
  plugins: [tailwindcss()]
})

在入口 CSS 中引入 Tailwind:

1
2
/* src/assets/main.css */
@import "tailwindcss";

然后在 main.ts 中引入这个 CSS 文件(脚手架已自动配置)。

技巧

Tailwind CSS v4 使用 @import "tailwindcss" 而非旧版的 @tailwind 指令。

2.1 目录结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
src/
├── assets/
│   └── main.css              # 全局样式,引入 Tailwind
├── components/
│   ├── HeaderBar.vue          # 顶部导航
│   ├── ProductCard.vue        # 产品卡片
│   └── ProductGrid.vue        # 产品网格容器
├── composables/
│   └── useProducts.ts         # 产品数据逻辑
├── router/
│   └── index.ts               # 路由配置
├── stores/
│   └── cart.ts                # 购物车状态
├── types/
│   └── index.ts               # 类型定义
├── views/
│   ├── HomePage.vue           # 首页
│   ├── ProductPage.vue        # 产品详情
│   └── CartPage.vue           # 购物车
├── App.vue                    # 根组件
└── main.ts                    # 入口

第 3 节 2. 类型定义

先把项目中用到的数据类型写清楚:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// src/types/index.ts
export interface Product {
  id: number
  name: string
  price: number
  description: string
  image: string
}

export interface CartItem {
  product: Product
  quantity: number
}

第 4 节 3. 封装产品数据(组合式函数)

用组合式函数来封装产品的获取逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// src/composables/useProducts.ts
import { ref } from 'vue'
import type { Product } from '@/types'

// 模拟数据
const mockProducts: Product[] = [
  {
    id: 1,
    name: '无线降噪耳机',
    price: 599,
    description: '40dB 混合降噪,30 小时续航,佩戴舒适。支持蓝牙 5.3 和 LDAC 高清编码。',
    image: 'https://picsum.photos/seed/headphone/400/300',
  },
  {
    id: 2,
    name: '机械键盘',
    price: 399,
    description: '87 键紧凑配列,Gasket 结构,全键热插拔。兼容 Mac/Win 双系统。',
    image: 'https://picsum.photos/seed/keyboard/400/300',
  },
  {
    id: 3,
    name: '便携显示器',
    price: 1299,
    description: '15.6 英寸 2K OLED,100% DCI-P3 色域,Type-C 一线连接。',
    image: 'https://picsum.photos/seed/monitor/400/300',
  },
  {
    id: 4,
    name: '智能手表',
    price: 899,
    description: '1.5 英寸 AMOLED 屏幕,14 天续航,心率血氧监测,IP68 防水。',
    image: 'https://picsum.photos/seed/watch/400/300',
  },
  {
    id: 5,
    name: '桌面充电站',
    price: 199,
    description: '100W 总功率,3 个 USB-C + 1 个 USB-A,氮化镓技术,桌面不凌乱。',
    image: 'https://picsum.photos/seed/charger/400/300',
  },
  {
    id: 6,
    name: '笔记本支架',
    price: 149,
    description: '全铝合金,可调节高度和角度,镂空散热设计。承重 10kg。',
    image: 'https://picsum.photos/seed/stand/400/300',
  },
]

export function useProducts() {
  const products = ref<Product[]>(mockProducts)
  const loading = ref(false)

  function getProductById(id: number): Product | undefined {
    return products.value.find((p) => p.id === id)
  }

  return { products, loading, getProductById }
}

这里用了 ref 包裹数据,组件调用时数据是响应式的。实际项目中可以改成调用 API 接口,结构和用法不变。

第 5 节 4. 路由设计

三个页面,配置路由:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from '@/views/HomePage.vue'
import ProductPage from '@/views/ProductPage.vue'
import CartPage from '@/views/CartPage.vue'

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomePage,
  },
  {
    path: '/product/:id',
    name: 'product',
    component: ProductPage,
    props: true,
  },
  {
    path: '/cart',
    name: 'cart',
    component: CartPage,
  },
  {
    path: '/:pathMatch(.*)*',
    redirect: '/',
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

路由结构很简单:

路径名称页面
/homeHomePage — 产品列表
/product/:idproductProductPage — 产品详情(动态路由)
/cartcartCartPage — 购物车
未匹配重定向回首页
技巧

props: true 可以把路由参数 :id 作为 props 传入组件。这样组件更方便测试和复用。

第 6 节 5. 购物车状态(Pinia)

用 Pinia 管理购物车,采用 Setup 写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// src/stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Product, CartItem } from '@/types'

export const useCartStore = defineStore('cart', () => {
  // state
  const items = ref<CartItem[]>([])

  // getters
  const totalCount = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.product.price * item.quantity, 0)
  )

  // actions
  function addItem(product: Product) {
    const existing = items.value.find((item) => item.product.id === product.id)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ product, quantity: 1 })
    }
  }

  function removeItem(productId: number) {
    items.value = items.value.filter((item) => item.product.id !== productId)
  }

  function updateQuantity(productId: number, quantity: number) {
    if (quantity <= 0) {
      removeItem(productId)
      return
    }
    const item = items.value.find((item) => item.product.id === productId)
    if (item) {
      item.quantity = quantity
    }
  }

  function clearCart() {
    items.value = []
  }

  return { items, totalCount, totalPrice, addItem, removeItem, updateQuantity, clearCart }
})
说明

购物车的数据只在内存中,刷新页面会丢失。实际项目中可以用 watch 把数据同步到 localStorage,或者用 pinia-plugin-persistedstate

第 7 节 6. 组件开发

7.1 HeaderBar — 顶部导航

每个页面顶部都有导航栏,包含 Logo、导航链接和购物车入口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- src/components/HeaderBar.vue -->
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()
</script>

<template>
  <header class="border-b border-gray-200 bg-white sticky top-0 z-10">
    <div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
      <router-link to="/" class="text-xl font-bold text-gray-800">
        极简数码
      </router-link>

      <nav class="flex items-center gap-6">
        <router-link to="/" class="text-gray-600 hover:text-gray-900">
          首页
        </router-link>

        <router-link to="/cart" class="relative text-gray-600 hover:text-gray-900">
          <span class="text-lg">购物车</span>
          <span
            v-if="cart.totalCount > 0"
            class="absolute -top-2 -right-4 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
          >
            {{ cart.totalCount }}
          </span>
        </router-link>
      </nav>
    </div>
  </header>
</template>

购物车数量显示为红色角标,数量为 0 时隐藏。数据来自 Pinia store 的 totalCount getter。

7.2 ProductCard — 产品卡片

产品卡片接收产品数据,对外抛出"加入购物车"事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!-- src/components/ProductCard.vue -->
<script setup lang="ts">
import type { Product } from '@/types'

const props = defineProps<{
  product: Product
}>()

const emit = defineEmits<{
  addToCart: [product: Product]
}>()
</script>

<template>
  <div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
    <router-link :to="{ name: 'product', params: { id: product.id } }">
      <img
        :src="product.image"
        :alt="product.name"
        class="w-full h-48 object-cover"
      />
    </router-link>

    <div class="p-4">
      <router-link :to="{ name: 'product', params: { id: product.id } }">
        <h3 class="font-semibold text-gray-900 text-lg">{{ product.name }}</h3>
      </router-link>

      <p class="text-sm text-gray-500 mt-1 line-clamp-2">{{ product.description }}</p>

      <div class="flex items-center justify-between mt-4">
        <span class="text-xl font-bold text-orange-500">¥{{ product.price }}</span>
        <button
          @click="emit('addToCart', product)"
          class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600 transition-colors"
        >
          加入购物车
        </button>
      </div>
    </div>
  </div>
</template>
说明

产品图片用了 picsum.photos 的占位图。实际项目中替换为真实图片即可。

7.3 ProductGrid — 产品网格

一个通用的网格布局容器,使用插槽让内容灵活填充:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!-- src/components/ProductGrid.vue -->
<script setup lang="ts">
defineProps<{
  columns?: number
}>()
</script>

<template>
  <div
    class="grid gap-6"
    :class="{
      'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3': !columns || columns === 3,
    }"
  >
    <slot />
  </div>
</template>

第 8 节 7. 页面组装

8.1 HomePage — 首页

首页从组合式函数获取产品列表,渲染成网格:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!-- src/views/HomePage.vue -->
<script setup lang="ts">
import { useProducts } from '@/composables/useProducts'
import { useCartStore } from '@/stores/cart'
import ProductCard from '@/components/ProductCard.vue'
import ProductGrid from '@/components/ProductGrid.vue'

const { products } = useProducts()
const cart = useCartStore()
</script>

<template>
  <div class="max-w-6xl mx-auto px-4 py-8">
    <h1 class="text-2xl font-bold text-gray-900 mb-6">全部产品</h1>

    <ProductGrid>
      <ProductCard
        v-for="product in products"
        :key="product.id"
        :product="product"
        @add-to-cart="cart.addItem"
      />
    </ProductGrid>
  </div>
</template>

8.2 ProductPage — 产品详情页

点击产品卡片跳转到此页,展示完整信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!-- src/views/ProductPage.vue -->
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useProducts } from '@/composables/useProducts'
import { useCartStore } from '@/stores/cart'
import { computed } from 'vue'

const route = useRoute()
const router = useRouter()
const { getProductById } = useProducts()
const cart = useCartStore()

const product = computed(() => {
  const id = Number(route.params.id)
  return getProductById(id) || null
})

function handleAdd() {
  if (product.value) {
    cart.addItem(product.value)
  }
}
</script>

<template>
  <div class="max-w-4xl mx-auto px-4 py-8">
    <button
      @click="router.back()"
      class="text-gray-500 hover:text-gray-700 mb-6 block"
    >
       返回
    </button>

    <div v-if="product" class="flex flex-col md:flex-row gap-8">
      <img
        :src="product.image"
        :alt="product.name"
        class="w-full md:w-96 h-72 object-cover rounded-xl"
      />

      <div>
        <h1 class="text-2xl font-bold text-gray-900">{{ product.name }}</h1>
        <p class="text-4xl font-bold text-orange-500 mt-4">¥{{ product.price }}</p>
        <p class="text-gray-600 mt-6 leading-relaxed">{{ product.description }}</p>

        <button
          @click="handleAdd"
          class="mt-8 bg-orange-500 text-white px-8 py-3 rounded-lg text-lg hover:bg-orange-600 transition-colors"
        >
          加入购物车
        </button>
      </div>
    </div>

    <div v-else class="text-center py-20">
      <p class="text-gray-500 text-lg">产品不存在</p>
      <router-link to="/" class="text-orange-500 hover:underline mt-4 block">
        返回首页
      </router-link>
    </div>
  </div>
</template>

路由参数 :id 通过 useRoute 获取,再用组合式函数的 getProductById 查找对应产品。如果找不到则显示"产品不存在"。

8.3 CartPage — 购物车页

展示购物车中的商品,支持数量和删除操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<!-- src/views/CartPage.vue -->
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()
</script>

<template>
  <div class="max-w-4xl mx-auto px-4 py-8">
    <div class="flex items-center justify-between mb-6">
      <h1 class="text-2xl font-bold text-gray-900">购物车</h1>
      <button
        v-if="cart.items.length > 0"
        @click="cart.clearCart()"
        class="text-sm text-gray-500 hover:text-red-500"
      >
        清空购物车
      </button>
    </div>

    <div v-if="cart.items.length === 0" class="text-center py-20">
      <p class="text-gray-400 text-lg">购物车是空的</p>
      <router-link to="/" class="text-orange-500 hover:underline mt-4 block">
        去逛逛
      </router-link>
    </div>

    <div v-else class="space-y-4">
      <div
        v-for="item in cart.items"
        :key="item.product.id"
        class="flex items-center gap-4 bg-white rounded-xl border border-gray-100 p-4"
      >
        <img
          :src="item.product.image"
          :alt="item.product.name"
          class="w-20 h-20 object-cover rounded-lg"
        />

        <div class="flex-1 min-w-0">
          <router-link
            :to="{ name: 'product', params: { id: item.product.id } }"
            class="font-semibold text-gray-900 hover:text-orange-500"
          >
            {{ item.product.name }}
          </router-link>
          <p class="text-orange-500 font-bold mt-1">¥{{ item.product.price }}</p>
        </div>

        <div class="flex items-center gap-2">
          <button
            @click="cart.updateQuantity(item.product.id, item.quantity - 1)"
            class="w-8 h-8 rounded-full border border-gray-300 flex items-center justify-center hover:bg-gray-100"
          >
            -
          </button>
          <span class="w-8 text-center">{{ item.quantity }}</span>
          <button
            @click="cart.addItem(item.product)"
            class="w-8 h-8 rounded-full border border-gray-300 flex items-center justify-center hover:bg-gray-100"
          >
            +
          </button>
        </div>

        <div class="text-right w-24">
          <p class="font-bold text-gray-900">¥{{ item.product.price * item.quantity }}</p>
          <button
            @click="cart.removeItem(item.product.id)"
            class="text-xs text-gray-400 hover:text-red-500 mt-1"
          >
            删除
          </button>
        </div>
      </div>

      <div class="bg-white rounded-xl border border-gray-100 p-4 text-right">
        <p class="text-gray-500">
           <span class="font-bold text-gray-900">{{ cart.totalCount }}</span> 件商品
        </p>
        <p class="text-2xl font-bold text-orange-500 mt-1">
          合计:¥{{ cart.totalPrice }}
        </p>
      </div>
    </div>
  </div>
</template>

第 9 节 8. App.vue 组装

根组件只做布局——放 HeaderBar 和页面出口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- src/App.vue -->
<script setup lang="ts">
import HeaderBar from '@/components/HeaderBar.vue'
</script>

<template>
  <div class="min-h-screen bg-gray-50">
    <HeaderBar />
    <router-view />
  </div>
</template>

第 10 节 9. 运行效果

启动项目:

1
npm run dev

整个应用的页面流如下:

1
2
3
4
5
6
首页(产品列表)
  ├─ 点击产品卡片 → 产品详情页
  │   └─ 点击"加入购物车" → 数据存入 Pinia store
  └─ 导航栏点击"购物车" → 购物车页
      ├─ 显示商品列表、数量、合计
      └─ 支持加减数量、删除商品、清空购物车
总结

本文用一个完整的小项目串联了 Vue 3 的各个核心知识点:

  • 路由 — 三个页面的 URL 映射和参数传递
  • Pinia — 购物车状态的跨页面共享
  • 组合式函数 — 产品数据逻辑的封装
  • 组件 + Props/事件 — ProductCard 的父子通信
  • 插槽 — ProductGrid 的内容分发
  • Tailwind CSS — 基于实用类的样式编写

这个项目麻雀虽小五脏俱全。如果要扩展到真实项目,可以从这几个方向入手:

  • 将模拟数据改为真实的 API 调用
  • 购物车数据持久化到 localStorage
  • 添加用户登录和订单结算流程
  • 产品列表支持搜索、筛选、分页