# 综合实战：产品展示站


## 概述

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

我们要做一个**产品展示站**，包含三个页面：

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

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

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

## 1. 创建项目

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

```shell
npm create vue@latest
```

创建时勾选 **TypeScript**、**Router** 和 **Pinia**。

创建完成后安装 Tailwind CSS：

```shell
npm install tailwindcss @tailwindcss/vite
```

在 `vite.config.ts` 中添加 Tailwind 插件：

```typescript
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({  
  plugins: [tailwindcss()]
})
```

在入口 CSS 中引入 Tailwind：

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

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

> [!tip] 技巧
> Tailwind CSS v4 使用 `@import "tailwindcss"` 而非旧版的 `@tailwind` 指令。

### 目录结构

```
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                    # 入口
```

## 2. 类型定义

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

```typescript
// src/types/index.ts
export interface Product {
  id: number
  name: string
  price: number
  description: string
  image: string
}

export interface CartItem {
  product: Product
  quantity: number
}
```

## 3. 封装产品数据（组合式函数）

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

```typescript
// 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 接口，结构和用法不变。

## 4. 路由设计

三个页面，配置路由：

```typescript
// 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
```

路由结构很简单：

| 路径 | 名称 | 页面 |
|------|------|------|
| `/` | `home` | HomePage — 产品列表 |
| `/product/:id` | `product` | ProductPage — 产品详情（动态路由） |
| `/cart` | `cart` | CartPage — 购物车 |
| 未匹配 | — | 重定向回首页 |

> [!tip] 技巧
> `props: true` 可以把路由参数 `:id` 作为 props 传入组件。这样组件更方便测试和复用。

## 5. 购物车状态（Pinia）

用 Pinia 管理购物车，采用 Setup 写法：

```typescript
// 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 }
})
```

> [!info] 说明
> 购物车的数据只在内存中，刷新页面会丢失。实际项目中可以用 `watch` 把数据同步到 `localStorage`，或者用 `pinia-plugin-persistedstate`。

## 6. 组件开发

### HeaderBar — 顶部导航

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

```vue
<!-- 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。

### ProductCard — 产品卡片

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

```vue
<!-- 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>
```

> [!info] 说明
> 产品图片用了 picsum.photos 的占位图。实际项目中替换为真实图片即可。

### ProductGrid — 产品网格

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

```vue
<!-- 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>
```

## 7. 页面组装

### HomePage — 首页

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

```vue
<!-- 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>
```

### ProductPage — 产品详情页

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

```vue
<!-- 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` 查找对应产品。如果找不到则显示"产品不存在"。

### CartPage — 购物车页

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

```vue
<!-- 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>
```

## 8. App.vue 组装

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

```vue
<!-- 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>
```

## 9. 运行效果

启动项目：

```shell
npm run dev
```

整个应用的页面流如下：

```
首页（产品列表）
  ├─ 点击产品卡片 → 产品详情页
  │   └─ 点击"加入购物车" → 数据存入 Pinia store
  └─ 导航栏点击"购物车" → 购物车页
      ├─ 显示商品列表、数量、合计
      └─ 支持加减数量、删除商品、清空购物车
```

> [!summary] 总结
> 本文用一个完整的小项目串联了 Vue 3 的各个核心知识点：
>
> - **路由** — 三个页面的 URL 映射和参数传递
> - **Pinia** — 购物车状态的跨页面共享
> - **组合式函数** — 产品数据逻辑的封装
> - **组件 + Props/事件** — ProductCard 的父子通信
> - **插槽** — ProductGrid 的内容分发
> - **Tailwind CSS** — 基于实用类的样式编写
>
> 这个项目麻雀虽小五脏俱全。如果要扩展到真实项目，可以从这几个方向入手：
>
> - 将模拟数据改为真实的 API 调用
> - 购物车数据持久化到 localStorage
> - 添加用户登录和订单结算流程
> - 产品列表支持搜索、筛选、分页


---

> 作者: Aphros  
> URL: https://blog.papergate.top/posts/%E7%BB%BC%E5%90%88%E5%AE%9E%E6%88%98%E4%BA%A7%E5%93%81%E5%B1%95%E7%A4%BA%E7%AB%99/  

