nuxt简易实战oppo官网

ssr 实战

oppo 官网,实现了 OPPO 专区、OnePlus 专区、智能硬件、详情页 的内容。

服务、登录、注册 只实现了简单的页面跳转实现。

OPPO 专区 轮播图下的那 8 个 分类可以点击跳转到 详情页,其他两个页面的不行。

另外封装了 useFetch 进行数据请求,并且做了数据备份,即使后面的 api 失效了,会自动使用本地数据。

项目演示

项目技术

  • vue3
  • nuxt3
  • element-plus
  • TypeScript
  • pinia
  • scss
  • normalize.css

项目目录

  • assets
    • css
      • global.scss – 全局的样式重置
      • variables.scss – 全局变量
    • cus-font/ – iconfont 文件
    • images
      • saveImg/ – 备份图片
  • components
    • appFooter.vue – 尾部组件
    • appHeader.vue – 头部组件
    • baseContent.vue – 统一管理组件
    • categoryGrid.vue – 分类信息内容组件
    • categorySection.vue – 分类信息组件
    • categoryTitle.vue – 分类信息的名称组件
    • gridItem.vue – 分类信息的小 i tem 组件
    • navbar.vue – 导航组件
    • search.vue – 搜索框组件
    • swiper.vue – 轮播图组件
    • tabCategory.vue – 分类组件
  • layouts
    • default.vue – 默认布局
    • empty.vue – 空布局,登录、注册、404 等组件使用
  • pages
    • […slug].vue – 404 组件
    • category-detail.vue – 分类详情
    • index.vue – 主页、OPPO 专区
    • intelligent.vue – 智能硬件
    • login.vue – 登录
    • one-plus.vue – OnePlus 专区
    • oppo-service.vue – 服务
    • register.vue – 注册
  • plugins
    • element-plus.client.js – ElementPlus 中文化配置
  • public/ – 静态目录,可以直接使用该目录下的图片
  • service
    • saveData/ – 备份的 api 数据
      • detailInfo.ts – 详情信息
      • homeInfo.ts – OPPO 专区
      • homeIntelligentInfo.ts – 智能硬件
      • onePlusInfo.ts – OnePlus 专区
    • detail.ts – 详情页 的接口请求
    • home.ts – 首页等三个页面的 接口请求
    • index.ts – useFetch() 接口封装
  • store
    • detail.ts – 详情页类型定义
    • home.ts – 首页类型定义与 pinia 使用
  • utils
    • reqUnite.ts – 请求数据和设置备用数据的抽离代码
  • app.vue
  • nuxt.config.ts
  • package-lock.json
  • package.json
  • README.md
  • tsconfig.json

项目搭建

  • 创建项目:npx nuxi init oppo-nuxt

  • 安装依赖:npm i

  • 初始化 css:npm i normalize.css

  • 安装 sass 与 element-plus:npm i sass element-plus @element-plus/nuxt

    // 新建 plugins/element-plus.client.js
    // 配置 element-plus 中文化
    import ElementPLus from 'element-plus'
    import { zhCn } from 'element-plus/es/locale/index.mjs'
    // import zhCn from 'element-plus/es/locale/lang/zh-cn'

    export default defineNuxtPlugin(nuxtApp => {
    return nuxtApp.vueApp.use(ElementPLus, {
    locale: zhCn
    })
    })
  • 修改 nuxt.config.ts 配置:

    export default defineNuxtConfig({
    css: ['normalize.css', 'element-plus/dist/index.css'],
    modules: ['@element-plus/nuxt'],
    plugins: ['@/plugins/element-plus.client.js']
    })
  • 在 app.vue 中测试上面的配置是否生效

    <template>
    <div class="page">
    <el-button type="primary">默认</el-button>
    <el-button type="success">成功</el-button>
    </div>
    </template>

    <style lang="scss">
    .page {
    border: 1px solid red;
    background: skyblue;
    }
    </style>
  • 终端运行:npm run dev 查看结果,背景颜色、border 生效,表明 sass 生效;div 盒子紧靠页面左边,表明 normalize.css 生效;button 样式和 element-plus 的效果一样,表明其生效。

配置 SCSS

编写公共样式数据

assets/css/variables.scss 公共样式文件。

$contentWidth: 1248px; // 内容宽

$appHeaderHeight: 36px; // app header 的高度
$navBarHeight: 84px; // 导航栏高度 68 + 16

// logo
$logoWidth: 250px;
$logoHeight: 50px;

$swiperHeight: 480px; // 轮播图高
$categoryBarHeight: 120px; // 分类栏高

// 商品图片
$imgWidth: 130px;
$imgHeight: 150px;

$gridItemHeight: 300px; // 商品图片 item 高度

// appFooter
$appFooterTop: 82px;
$appFooterCenter: 360px;
$appFooterBottom: 58px;

// 字体
$fontSize16: 16px;
$fontSize15: 15px;
$fontSize14: 14px;
$fontSize12: 12px;

// 字体颜色
$textTitleColor: #000; // 标题字体颜色
$textSubColor: #535353; // 导航字体颜色
$textRedColor: rgb(246, 52, 52); // 红色字体
$priceColor: #f34141; // 价格字体颜色

// 背景颜色
$bgColor: #fff; // 白色背景
$bgGrayColor: #fafafa; // 灰色背景

// 背景精灵图 -- 没有这张图片
@mixin bgSpride() {
background-image: url('@/assets/images/spride.png');
background-repeat: no-repeat;
}

// 水平居中的 flex
@mixin normalFlex($direction: row, $content: space-between) {
display: flex;
flex-direction: $direction;
justify-content: $content;
}

// 粘性定位
@mixin elementSticky($top: 0px, $z: 100) {
postion: sticky;
top: $top;
z-index: $z;
}

// 鼠标hover
@mixin hoverEffect() {
transition: all 0.2s linear;
transform: translateY(-3px);
box-shadow: 0 4px 8px 0 rgb(0 0 0 / 10%);
}

// 单行字体省略
@mixin textEllipsis() {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

// 多行文字省略
@mixin textMultiEllipsis($line: 2) {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
}

/* 调试专用 */
// boder
@mixin border($color: red) {
border: 1px solid $color;
}

编写全局样式

assets/css/global.scss 全局样式文件

// @use '@/assets/css/variables.scss' as *; // nuxt.config.ts 配置自动导入
@import 'normalize.css';
@import 'element-plus/dist/index.css';

.wrapper {
margin: 0 auto;
width: $contentWidth;
}

body {
font-size: $fontSize14;
color: #333;
}

ul,
li {
margin: 0;
padding: 0;
}

ul {
list-style: none;
}

a {
text-decoration: none;
font-size: 12px;
}

修改配置文件

nuxt.config.ts 文件添加 .scss 文件的自动导入配置。

export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
// css: ['normalize.css', 'element-plus/dist/index.css'], // 在 assets/css/global.scss 中导入了
css: ['@/assets/css/global.scss'],
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "@/assets/css/variables.scss" as *;'
}
}
}
},
modules: ['@element-plus/nuxt'],
plugins: ['@/plugins/element-plus.client.js']
})

测试

修改 app.vue 文件

<template>
<div class="wrapper bg">Page: index</div>
</template>

<style scoped lang="scss">
.bg {
background-color: $bgGrayColor;
@include border();
}
</style>

编写默认布局

layouts/default.vue

新建 components/appHeader.vuecomponents/appFooter.vue 文件。

<template>
<div>
<app-header />
<slot></slot>
<app-footer />
</div>
</template>

app.vue

<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

编写头部

将 iconfont 内容复制到 assets 目录,这里的 iconfongt 文件夹叫 cus-font 目录。修改 nuxt.config.ts

css: ['@/assets/css/global.scss', '@/assets/cus-font/iconfont.css']

修改 components/appHeader.vue

<template>
<div class="app-header">
<div class="wrapper content">
<div class="content-left">
<nuxt-link class="link" to="/">
<span>OPPO官网</span>
</nuxt-link>
<nuxt-link class="link" to="/">
<span>一加官网</span>
</nuxt-link>
</div>
<div class="content-right">
<a href="#" class="link download">
<span>下载 OPPO 商城APP</span>
<div class="app">
<img class="ecode" src="@/assets/images/ecode.png" alt="OPPO" />
<div class="name">扫码下载 OPPO 商城 App</div>
</div>
</a>
<NuxtLink to="/login" class="link">
<i class="iconfont icon-user"></i>
<span>登录</span>
</NuxtLink>
<NuxtLink to="/register" class="link">
<span>注册</span>
</NuxtLink>
<a href="#" class="link">
<i class="iconfont icon-shoppingcart"></i>
<span>购物车 </span>
<span>(0)</span>
</a>
</div>
</div>
</div>
</template>

<script setup>
defineOptions({ name: 'AppHeader' })
</script>

<style scoped lang="scss">
.app-header {
height: $appHeaderHeight;
background-color: #000;
@include elementSticky(0, 110);
.link span {
opacity: 0.8; // 字体变暗
}
.content {
/* @include border(); */
height: 100%;
@include normalFlex();
&-left {
@include normalFlex(row, flex-start);
align-items: center;
.link {
margin-right: 24px;
cursor: pointer;
}
}
&-right {
@include normalFlex(row, flex-end);
align-items: center;
.link {
padding: 0 14px;
border-right: 1px solid #fff;
}
.link:last-child {
border-right: none;
}
i {
margin-right: 6px;
}
.icon {
&-user {
font-size: 12px;
}
&-shoppingcart {
font-size: 15px;
}
}
.download {
position: relative;
&:hover .app {
display: block;
}
.app {
display: none;
position: absolute;
top: 25px;
left: 0;
padding: 8px;
min-width: 146px;
box-shadow: 0 4px 8px 0 rgb(0 0 0 / 10%);
z-index: 10000;
}
.ecode {
width: 140px;
height: 140px;
margin-bottom: 4px;
}
.name {
color: #000;
}
}
}
}
}
</style>

编写尾部

将图片f-icon1到5 放到 public/images 目录下,方便直接使用。编写 components/appFooter.vue文件。

<template>
<div class="app-footer">
<div class="wrapper content">
<div class="content-top">
<a href="#" class="top-item" v-for="item in footerIcons" :key="item.id">
<img :src="item.picStr" :alt="item.title" />
<div>{{ item.title }}</div>
</a>
</div>
<div class="content-center">
<div class="center-item">
<ul>
<li><a class="first" href="">OPPO</a></li>
<li><a href="">Find N 全新折叠旗舰</a></li>
<li><a href="">OPPO Find X5 Pro</a></li>
<li><a href="">OPPO Find X5</a></li>
<li><a href="">OPPO Reno9 Pro+</a></li>
<li><a href="">OPPO Reno9 Pro</a></li>
<li><a href="">OPPO Reno9</a></li>
<li><a href="">OPPO Reno8</a></li>
<li><a href="">OPPO K10x</a></li>
<li><a href="">OPPO K10</a></li>
</ul>
</div>
<div class="center-item">
<ul>
<li><a class="first" href="">一加</a></li>
<li><a href="">一加 Ace Pro</a></li>
<li><a href="">一加 Ace</a></li>
<li><a href="">一加 Ace 竞速版</a></li>
<li><a href="">一加 10 Pro</a></li>
<li><a href="">一加手表</a></li>
<li><a href="">一加 Buds Pro</a></li>
<li><a href="">一加 Buds Z2</a></li>
</ul>
</div>
<div class="center-item">
<ul>
<li><a class="first" href="">智能硬件</a></li>
<li><a href="">OPPO Watch 3</a></li>
<li><a href="">OPPO 手环 2</a></li>
<li><a href="">OPPO Watch 2</a></li>
<li><a href="">OPPO Pad</a></li>
<li><a href="">OPPO Pad Air</a></li>
<li><a href="">OPPO Enco X2</a></li>
<li><a href="">OPPO Enco Air2 Pro</a></li>
<li><a href="">OPPO Enco Air2</a></li>
</ul>
</div>
<div class="connect">
<img src="@/assets/images/phone.svg" alt="phone" />
<span>在线客服</span>
</div>
</div>
<div class="content-bottom">
<div class="bottom-item">
<a href=""> © 2004-2022 OPPO 版权所有粤ICP备14056724号</a>
<span>|</span><a href="">隐私政策</a><span>|</span><a href="">用户使用协议</a>
<span>|</span><a href="">资质证照</a><span>|</span><a href="">知识产权</a>
</div>
<div class="bottom-item">
<img class="police" src="@/assets/images/police.png" alt="police" />
<a href="#">粤公安网备 44190002001939号</a>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
interface IFooterIcon {
id: number
picStr: string
title: string
}

defineProps({
footerIcons: {
type: Array<IFooterIcon>,
default: () => [
{
id: 100,
picStr: '/images/f-icon1.svg', // 需要将图片放在public目录
title: '全国联保'
},
{
id: 100,
picStr: '/images/f-icon2.svg',
title: '7天无理由退货'
},
{
id: 100,
picStr: '/images/f-icon3.svg',
title: '官方换货保障'
},
{
id: 100,
picStr: '/images/f-icon4.svg',
title: '满69元包邮'
},
{
id: 100,
picStr: '/images/f-icon5.svg',
title: '900+ 家售后网点'
}
]
}
})
defineOptions({ name: 'AppFooter' })
</script>

<style scoped lang="scss">
.app-footer {
padding-top: 42px;
.content-top {
padding-bottom: 40px;
@include normalFlex();
height: $appFooterTop;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
.top-item {
@include normalFlex(column, center);
align-items: center;
gap: 8px 0;
font-size: 17px;
color: rgba(0, 0, 0, 0.7);
}
}
.content-center {
position: relative;
margin: 42px 0;
@include normalFlex(row, flex-start);
height: $appFooterCenter;
border-bottom: 1px sold rgba(0, 0, 0, 0.12);
.center-item {
margin-right: 30px;
a {
margin-bottom: 12px;
line-height: 35px;
white-space: nowrap;
font-size: 17px;
color: #535353;
opacity: 0.8;
}
.first {
opacity: 1;
color: rgba(0, 0, 0, 0.87);
}
}
.connect {
position: absolute;
top: 0;
right: 0;
width: 173px;
height: 51px;
@include normalFlex(row, center);
align-items: center;
gap: 0 10px;
font-size: 15px;
color: #fff;
background-color: #000;
border-radius: 26px;
cursor: pointer;
}
}
.content-bottom {
margin-bottom: 30px;
height: $appFooterBottom;
.bottom-item {
margin-top: 14px;
margin-right: 3px;
font-size: 12px;
color: #acabb0;
a {
margin: 0 6px;
color: #acabb0;
}
.police {
/* position: relative;
top: 4px; */
vertical-align: middle;
}
}
}
}
</style>

配置 SEO

// nuxt.config.ts
app: {
head: {
meta: [
{
name: 'description',
content: 'OPPO专区,官方正品,最新最全的OPPO手机产品以及配件在线抢购!'
},
{
name: 'keywords',
content: 'OPPO商城,OPPO专区, OPPO手机,OPPO配件,OPPO, OPPO官网商城'
}
],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/logo.png' }],
noscript: [{ children: 'Javascript is required' }]
}
},

配置 slug 页面

slug 页面就是 404 页面。新建 pages/[...slug].vue

<template>
<el-empty :image-size="200" description="暂无此页面" />
</template>

<script setup>
definePageMeta({
name: 'emptyPage',
layout: 'empty'
})
</script>
<style scoped lang="scss">
.el-empty {
width: 100vw;
height: 100vh;
@include normalFlex(column, center);
align-items: center;
}
::v-deep(.el-empty__description p) {
font-size: 26px !important;
}
</style>

空布局

layouts/empty.vue,给不显示 header 和 footer 的页面使用。通过 definePageMeta({layout: 'empty'}) 使用。

<template>
<slot></slot>
</template>

登录注册

这里登录注册就不详细编写了,有个页面就可以,主要是熟悉 nuxt 的编写流程那些。

登录

pages/login.vue

<script setup lang="ts">
definePageMeta({ layout: 'empty' })
</script>

<template>
<div>Page: login</div>
</template>

注册

pages/register.vue

<script setup lang="ts">
definePageMeta({ layout: 'empty' })
</script>

<template>
<div>Page: register</div>
</template>

编写主页剩下内容

因为主页现在剩下的内容在其他两个页面时可以复用的,所以采用组件可复用形式。

下面的组件内容,均是请求完数据后的代码数据,这里不展示静态的代码结构。

编写顺序:

  • 导航。
  • 统一组件:轮播图、分类、单个分类信息。
  • OnePlus 专区、智能硬件:复用统一组件
  • 首页的导航设置其点击跳转到详情页

修改默认布局

layouts/default.vue。这里将导航放置到默认布局中,能够让使用默认布局的页面直接复用。

<template>
<div>
<!-- 头部 -->
<app-header />
<!-- 导航 -->
<navbar :navbars="homeInfoStore.navbars" />
<slot></slot>
<!-- 尾部 -->
<app-footer />
</div>
</template>

<script setup lang="ts">
import { getHomeInfoReq } from '~/service/home'
import { useHomeInfoStore } from '~/store/home'
import type { Categorys, Navbars } from '~/store/home'
import homeInfoSave from '~/service/saveData/homeInfo'

// 首页和默认布局中都获取了首页数据。
// 首页和其余页面的切换都需要重新获取首页数据
// 不同的是,默认布局只在详情页才进行获取首页数据。
const homeInfoStore = useHomeInfoStore() // pinia 仓库
// 在详情页刷新时请求首页数据
if (homeInfoStore.banners.length === 0) {
const { data } = await getHomeInfoReq() // 请求首页数据
// 错误处理
const errorSolve = () => {
// client 和 server 需要分开处理
if (import.meta.client) {
ElMessage.error('获取首页数据失败,使用备份数据')
} else {
console.log('获取首页数据失败,使用备份数据')
}
// 使用备份数据
const homeInfo = ref(homeInfoSave.data)
// 更新pinia仓库数据
homeInfoStore.updateNavbar(homeInfo.value.navbars as Navbars)
homeInfoStore.updateBanner(homeInfo.value.banners)
homeInfoStore.updateCategory(homeInfo.value.categorys as unknown as Categorys)
}
// 接口请求失败则使用备份数据
if (!data.value) {
errorSolve()
} else {
const { code, data: curData } = data.value
if (code === 200) {
// 更新pinia仓库数据
homeInfoStore.updateNavbar(curData.navbars)
homeInfoStore.updateBanner(curData.banners)
homeInfoStore.updateCategory(curData.categorys)
} else {
// 返回数据失败则使用备份数据
errorSolve()
}
}
}
</script>

编写导航

导航组件

components/navbar.vue

<template>
<div class="navbar">
<div class="wrapper content">
<!-- logo -->
<NuxtLink to="/" class="content-left">
<!-- logo图片 -->
<img src="/logo.png" class="logo" alt="" />
<!-- logo title,设置有助于 SEO-->
<!-- 设置 z-index 将其设置到页面外 -->
<h1 class="title">OPPO官网</h1>
</NuxtLink>
<ul class="content-center">
<template v-if="navbars.length > 0">
<li
:class="{ active: item.title === curTabName }"
@click="changeTabHandler(item.title as string)"
v-for="item in navbars"
:key="item.id"
>
<NuxtLink class="link" :to="getPathName(item.title as string)">
{{ item.title }}
</NuxtLink>
</li>
</template>
</ul>
<!-- 搜索框组件 -->
<search class="content-right"></search>
</div>
</div>
</template>

<script setup lang="ts">
import type { INavbar } from '~/store/home'

defineProps({
navbars: {
type: Array<INavbar>,
default: () => []
}
})
const route = useRoute()
const paths: any = reactive({
OPPO专区: '/',
OnePlus专区: '/one-plus',
智能硬件: '/intelligent',
服务: '/oppo-service'
})
const curTabName = ref<string>('OPPO专区')
// 切换tab
const changeTabHandler = (tabName: string) => {
curTabName.value = tabName
}
// 获取路径
// 这里需要根据传递的title获取具体的路径,通过函数调用,所以返回的一个函数
const getPathName = computed(() => {
return (title: string) => {
return paths[title] || '/'
}
})
// 页面加载完成后,根据当前路径激活当前 tab
onMounted(() => {
const curPath = route.path
// 遍历 paths,找到当前路径对应的 tabName
for (const [key, value] of Object.entries(paths)) {
if (value === curPath) {
curTabName.value = key
break
}
}
})
</script>

<style scoped lang="scss">
.navbar {
@include elementSticky(36px);
height: $navBarHeight;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background-color: #fff;
z-index: 99;
.content {
@include normalFlex(row, flex-start);
align-items: center;
height: 100%;
&-left {
display: inline-block;
width: $logoWidth;
height: $logoHeight;
.logo {
height: 100%;
}
.title {
margin: 0;
height: 0;
text-indent: -999px;
}
}
&-center {
@include normalFlex();
margin-left: 60px;
width: 530px;
.link {
opacity: 0.55;
font-size: 14px;
color: #000;
&:hover {
opacity: 1;
}
}
.active .link {
opacity: 1;
}
}
&-right {
margin-left: 50px;
}
}
}
</style>

获取数据

首页的数据包含:菜单、轮播图、分类。

封装 useFetch

service/index.ts

import type { AsyncData, UseFetchOptions } from '#app'

// 网络请求地址
const BASE_URL = 'http://codercba.com:9060/oppo-nuxt/api'
export type methodType = 'GET' | 'POST' // 请求方式
// 响应数据接口
export interface IResponseResult<T> {
code: number
data: T
}

class RXRequest {
request<T>(
url: string,
method: methodType,
data?: any,
options?: UseFetchOptions<T, any, any>
): Promise<AsyncData<T, any>> {
return new Promise((resolve, reject) => {
const newOptions: UseFetchOptions<T, any, any> = {
baseURL: BASE_URL,
method: method || 'GET',
...options
}
if (method === 'GET') {
newOptions.query = data
} else {
newOptions.body = data
}
useFetch<T>(url, newOptions as any)
.then(res => {
resolve(res as AsyncData<T, any>)
})
.catch(err => {
reject(err)
})
})
}

get<T>(url: string, data?: any, options?: UseFetchOptions<T, any, any>) {
return this.request<T>(url, 'GET', data, options)
}

post<T>(url: string, params?: any, options?: UseFetchOptions<T, any, any>) {
return this.request<T>(url, 'POST', params, options)
}
}

export default new RXRequest()
请求首页数据

service/home.ts。这里的 ts 类型在 store/home.ts 中编写的。可以先写 any,然后再根据请求的数据来编写 ts 类型。

import RXRequest from './index'
import type { IResponseResult } from './index'
import type { IHomeInfo } from '@/store/home'

// 首页(OPPO专区)数据请求
export const getHomeInfoReq = () => {
return RXRequest.get<IResponseResult<IHomeInfo>>('/home/info', { type: 'oppo' })
}
// OnePlus专区数据请求
export const getHomeOnePlusInfoReq = () => {
return RXRequest.get<IResponseResult<IHomeInfo>>('/home/info', { type: 'onePlus' })
}
// 智能硬件数据请求
export const getHomeIntelligentInfoReq = () => {
return RXRequest.get<IResponseResult<IHomeInfo>>('/home/info', { type: 'intelligent' })
}
配置 pinia

这里我们采用 pinia 保存数据。

安装:npm i pinia @nuxt/pinia

配置 nuxt.config.ts

export default defineNuxtConfig({
modules: ['@pinia/nuxt']
})
保存首页数据

store/home.ts

import { defineStore } from 'pinia'

// navbar 接口
export interface INavbar {
id?: string | number
title?: string
type?: string | number
showName?: number
url?: string
jsonUrl?: string
clickUrl?: string
jsonClickUrl?: string
beginAt?: null
endAt?: null
seq?: number
labelDetailss?: []
link?: string
isLogin?: number
moduleCode?: string
rows?: number
cols?: number
maxProductNum?: number
}
// banner 接口
export interface IBanner {
id?: string | number
picStr?: string
link?: string
}
// activityList 接口
export interface IActivityList {
type?: string | number
activityInfo?: string
}
// activityList 类型
export type activityList = IActivityList[]
// productDetailss 接口
export interface IProductDetailss {
id?: string | number
skuId?: number
title?: string
secondTitle?: string
thirdTitle?: string
url?: string
jsonUrl?: string
video?: string
seq?: number
configKeyLattice?: number
latticeIndex?: number
configProductType?: number
goodsSpuId?: number
goodsSpuName?: string
isShowIcon?: number
topIcon?: string
cardType?: number
backColor?: string
liveInfoJson?: null
businessInfoJson?: null
priceInfo?: {
originalPrice?: string
price?: string
marketPrice?: string
buyPrice?: string
prefix?: string
suffix?: string
currencyTag?: string
}
price?: number
originalPrice?: null
categoryId?: number
link?: string
isLogin?: number
marketPrice?: string
nameLabel?: null
imageLabel?: null
extendList?: null
heytapInfo?: null
activityList?: activityList
placeholderLabel?: {
type?: number | string
activityInfo?: string
}
vipDiscounts?: null
nameLabelWidth?: null
nameLabelHeight?: null
pricePrefix?: string
priceSuffix?: null
goodsTopCategoryId?: number
goodsTopCategoryName?: string
goodsCategoryId?: number
goodsCategoryName?: string
skuName?: string
cardInfoType?: null
liveUrl?: null
storage?: null
seckill?: null
rankInfo?: null
businessLink?: string
sellPoints?: null
commentCount?: null
commentRate?: null
interenve?: boolean
productDetailLabelss?: any[]
}
// productDetailss 类型
export type ProductDetailss = IProductDetailss[]
// category 接口
export interface ICategory {
id?: string | number
picStr?: string
title?: string
type?: string | number
url?: string
moduleCode?: string
productDetailss?: ProductDetailss
link?: string
}
export type Navbars = INavbar[] // navbars 类型
export type Banners = IBanner[] // banners 类型
export type Categorys = ICategory[] // categorys 类型
// homeInfo 接口
export interface IHomeInfo {
navbars: Navbars
banners: Banners
categorys: Categorys
}

export const useHomeInfoStore = defineStore('homeInfo', {
state: (): IHomeInfo => ({
navbars: [], // 导航数据
banners: [], // 轮播图数据
categorys: [] // 分类数据
}),
actions: {
// 更新导航数据
updateNavbar(payload: Navbars) {
this.navbars = payload
},
// 更新轮播图数据
updateBanner(payload: Banners) {
this.banners = payload
},
// 更新分类数据
updateCategory(payload: Categorys) {
this.categorys = payload
}
}
})

统一管理组件

OPPO 专区、OnePlus 专区、智能硬件中均存在 轮播图、分类、单个分类信息,且结构类似,所以将这几个部分单独在一个组件中进行引用,后续直接单独引用这一个组件即可。

components/baseContent.vue。这个组件包含主页剩下的所有组件,方便在后面两个页面的引入。

<template>
<div>
<!-- 轮播图 -->
<swiper :banners="banners" />
<!-- 分类导航 -->
<tab-category :categorys="categorys" />
<!-- 分类详情 -->
<template v-for="category in categorys" :key="category.id">
<!-- 单个分类详情 -->
<category-section :category="category" />
</template>
</div>
</template>

<script setup lang="ts">
import type { IBanner, ICategory } from '~/store/home'

defineProps({
banners: {
type: Array<IBanner>,
default: () => []
},
categorys: {
type: Array<ICategory>,
default: () => []
}
})
</script>

修改主页

pages/index.vue

<template>
<div class="home">
<!-- 页面统一管理组件 -->
<base-content
:banners="homeInfoStore.banners"
:categorys="homeInfoStore.categorys"
class="wrapper"
/>
</div>
</template>

<script setup lang="ts">
import { getHomeInfoReq } from '~/service/home'
import { useHomeInfoStore } from '~/store/home'
import type { Categorys, Navbars } from '~/store/home'
import homeInfoSave from '~/service/saveData/homeInfo'

// 首页和默认布局中都获取了首页数据。
// 首页和其余页面的切换都需要重新获取首页数据
// 不同的是,默认布局只在详情页才进行获取首页数据。
const homeInfoStore = useHomeInfoStore() // pinia 仓库
const { data } = await getHomeInfoReq() // 请求首页数据
// 错误处理
const errorSolve = () => {
// client 和 server 需要分开处理
if (import.meta.client) {
ElMessage.error('获取首页数据失败,使用备份数据')
} else {
console.log('获取首页数据失败,使用备份数据')
}
// 使用备份数据
const homeInfo = ref(homeInfoSave.data)
// 更新pinia仓库数据
homeInfoStore.updateNavbar(homeInfo.value.navbars as Navbars)
homeInfoStore.updateBanner(homeInfo.value.banners)
homeInfoStore.updateCategory(homeInfo.value.categorys as unknown as Categorys)
}
// 接口请求失败则使用备份数据
if (!data.value) {
errorSolve()
} else {
const { code, data: curData } = data.value
if (code === 200) {
// 更新pinia仓库数据
homeInfoStore.updateNavbar(curData.navbars)
homeInfoStore.updateBanner(curData.banners)
homeInfoStore.updateCategory(curData.categorys)
} else {
// 返回数据失败则使用备份数据
errorSolve()
}
}
</script>

<style scoped lang="scss">
.home {
background-color: $bgGrayColor;
}
</style>

编写轮播图

components/swiper.vue

<template>
<div class="swiper">
<el-carousel
height="480px"
trigger="click"
indicator-position="none"
@change="changeHandler"
:active-index="activeIndex"
>
<el-carousel-item v-for="item in banners" :key="item.id">
<img class="pic-str" :src="item.picStr" alt="OPPO" />
</el-carousel-item>
</el-carousel>
<!-- 指示器 -->
<ul class="dots">
<li
class="dot"
v-for="(item, index) in banners"
:key="item.id"
:class="{ active: index === activeIndex }"
></li>
</ul>
</div>
</template>

<script setup lang="ts">
import type { IBanner } from '~/store/home'

defineProps({
banners: {
type: Array<IBanner>,
default: () => []
}
})
const activeIndex = ref<number>(0)
const changeHandler = (index: number) => {
activeIndex.value = index
}
</script>

<style scoped lang="scss">
.swiper {
position: relative;
padding-top: 36px;
.pic-str {
width: 100%;
height: 100%;
border-radius: 10px;
}
.dots {
position: absolute;
bottom: 0;
left: 0;
@include normalFlex(row, center);
align-items: center;
gap: 0 10px;
width: 100%;
height: 40px;
.dot {
opacity: 0.8;
width: 10px;
height: 10px;
background-color: #fff;
border-radius: 10px;
box-sizing: border-box;
cursor: pointer;
}
.active {
opacity: 1;
width: 12px;
height: 12px;
background-color: transparent;
border: 1px solid #fff;
}
}
}
</style>

编写分类

components/tabCategory.vue

<template>
<div class="tab-category wrapper">
<template v-for="item in categorys" :key="item.id">
<!-- 这里跳转详情页只在 OPPO专区(首页)的分类中进行跳转,其他页面不需要跳转 -->
<NuxtLink :to="$route.path === '/' ? `/category-detail?type=${item.type}` : ''">
<div class="category-item">
<img class="pic-str" :src="item.picStr" alt="OPPO" />
<div class="title">{{ item.title }}</div>
</div>
</NuxtLink>
</template>
</div>
</template>

<script setup lang="ts">
import type { ICategory } from '~/store/home'

defineProps({
categorys: {
type: Array<ICategory>,
default: () => []
}
})
</script>

<style scoped lang="scss">
.tab-category {
margin-top: 40px;
@include normalFlex();
align-items: center;
.category-item {
@include normalFlex(column, center);
align-items: center;
cursor: pointer;
.pic-str {
width: 80px;
height: 80px;
}
.title {
margin-top: 16px;
max-width: 120px;
text-align: center;
font-size: 16px;
color: #000;
/* @include border(); */
}
}
}
</style>

编写单个分类信息

这里将每个分类内容的 titile 和 内容 拆分为各自单独的组件,内容后面可以在详情页复用。

分类主页

components/categorySection.vue

<template>
<!-- 分类信息存在才展示这个页面,没有不展示 -->
<div class="category-section" v-if="(category?.productDetailss ?? []).length > 0">
<!-- 分类标题 -->
<category-title :title="category?.title" />
<!-- 分类item,包含大图介绍和小的item -->
<category-grid :categoryUrl="category?.url" :productDetailss="category?.productDetailss" />
</div>
</template>

<script setup lang="ts">
import type { ICategory } from '~/store/home'

interface prop {
category?: ICategory
}
const { category } = defineProps<prop>()
</script>

分类 title

components/categoryTitle.vue

<template>
<div class="category-title">
<h2>{{ title }}</h2>
</div>
</template>

<script setup lang="ts">
defineProps({ title: String })
</script>

<style scoped lang="scss">
.category-title {
h2 {
margin: 0 0 24px 0;
padding-top: 60px;
font-size: 24px;
font-weight: 500;
}
}
</style>

分类内容

主页

components/categoryGrid.vue

<template>
<div class="category-grid wrapper">
<!-- 详情页不展示分类介绍图片 -->
<!-- 可以在详情页传入空置或者使用布尔类型直接判断 -->
<div class="category-grid-item first" v-if="categoryUrl.length > 0">
<img :src="categoryUrl" alt="OPPO" />
</div>
<div
class="category-grid-item"
v-for="productDetail in productDetailss"
:key="productDetail.id"
>
<!-- 单个item信息 -->
<grid-item :productDetail />
</div>
</div>
</template>

<script setup lang="ts">
import type { IProductDetailss } from '~/store/home'

defineProps({
productDetailss: {
type: Array<IProductDetailss>,
default: () => []
},
// 大图片,占两个位置,40%的宽度
categoryUrl: {
type: String,
default: ''
}
})
</script>

<style scoped lang="scss">
.category-grid {
@include normalFlex(row, flex-start);
flex-wrap: wrap;
/* 使其与上面的内容对齐 */
width: $contentWidth + 18px;
.category-grid-item {
margin-bottom: 18px;
padding-right: 18px;
width: 20%;
height: $gridItemHeight;
box-sizing: border-box;
background-color: $bgGrayColor;
cursor: pointer;
}
.first {
width: 40%;
img {
width: 100%;
height: 100%;
transition: all 0.2s linear;
&:hover {
@include hoverEffect();
}
}
}
}
</style>
单个 item

components/gridItem.vue

<template>
<div class="grid-item">
<div class="item-img"><img class="img" :src="productDetail.url" alt="" /></div>
<div class="item-title">{{ productDetail.title }}</div>
<div class="item-labels">
<!-- 没有活动内容的显示'敬请期待' -->
<template v-if="(productDetail.activityList ?? []).length > 0">
<span class="label" v-for="item in productDetail.activityList" :key="item.type">
{{ item.activityInfo }}
</span>
</template>
<span class="label" v-else>敬请期待</span>
</div>
<div class="item-price">
<span class="prefix">
{{ productDetail.priceInfo?.prefix }}{{ productDetail.priceInfo?.currencyTag }}
</span>
<!-- 没有提供价格则显示'暂无报价' -->
<span class="price">{{ productDetail.priceInfo?.buyPrice || '暂无报价' }}</span>
</div>
</div>
</template>

<script setup lang="ts">
import type { IProductDetailss } from '~/store/home'

interface prop {
productDetail: IProductDetailss
}
const { productDetail } = defineProps<prop>()
</script>

<style scoped lang="scss">
.grid-item {
background-color: #fff;
text-align: center;
border-radius: 8px;
transition: all 0.2s linear;
&:hover {
@include hoverEffect();
}
.item-img .img {
margin-top: 14px;
margin-bottom: 7px;
width: $imgWidth;
height: $imgHeight;
}
.item-title {
margin-top: 2px;
padding: 0 20px;
font-size: 15px;
font-weight: 500;
text-align: center;
@include textEllipsis();
}
.item-labels {
@include normalFlex(row, center);
align-items: center;
height: 45px;
/* @include border(); */
.label {
display: inline-block;
margin: 0 4px 4px 0;
padding: 1px 2px;
color: $priceColor;
font-size: 13px;
border: 1px solid $priceColor;
}
}
.item-price {
padding-bottom: 40px;
.prefix {
color: $priceColor;
font-size: 13px;
}
.price {
line-height: 1;
color: $priceColor;
font-size: 20px;
}
}
}
</style>

OnePlus 专区

pages/one-plus.vue

<template>
<div class="home">
<!-- 页面统一管理组件 -->
<base-content
:banners="homeInfoStore.banners"
:categorys="homeInfoStore.categorys"
class="wrapper"
/>
</div>
</template>

<script setup lang="ts">
import { getHomeOnePlusInfoReq } from '~/service/home'
import { useHomeInfoStore } from '~/store/home'
import type { Categorys, Navbars } from '~/store/home'
import homeOnePlusInfoSave from '~/service/saveData/onePlusInfo'

const homeInfoStore = useHomeInfoStore() // pinia 仓库
const { data } = await getHomeOnePlusInfoReq() // 请求数据
// 错误处理
const errorSolve = () => {
// client 和 server 需要分开处理
if (import.meta.client) {
ElMessage.error('获取OnePlus专区数据失败,使用备份数据')
} else {
console.log('获取OnePlus专区数据失败,使用备份数据')
}
// 使用备份数据
const homeOnePlusInfo = ref(homeOnePlusInfoSave.data)
// 更新pinia仓库数据
homeInfoStore.updateNavbar(homeOnePlusInfo.value.navbars as Navbars)
homeInfoStore.updateBanner(homeOnePlusInfo.value.banners)
homeInfoStore.updateCategory(homeOnePlusInfo.value.categorys as unknown as Categorys)
}
// 接口请求失败则使用备份数据
if (!data.value) {
errorSolve()
} else {
const { code, data: curData } = data.value
if (code === 200) {
// 更新pinia仓库数据
homeInfoStore.updateNavbar(curData.navbars)
homeInfoStore.updateBanner(curData.banners)
homeInfoStore.updateCategory(curData.categorys)
} else {
// 返回数据失败则使用备份数据
errorSolve()
}
}
</script>

<style scoped lang="scss">
.home {
background-color: $bgGrayColor;
}
</style>

智能硬件

pages/intelligent.vue

<template>
<div class="home">
<!-- 页面统一管理组件 -->
<base-content
:banners="homeInfoStore.banners"
:categorys="homeInfoStore.categorys"
class="wrapper"
/>
</div>
</template>

<script setup lang="ts">
import { getHomeIntelligentInfoReq } from '~/service/home'
import { useHomeInfoStore } from '~/store/home'
import type { Categorys, Navbars } from '~/store/home'
import homeIntelligentInfoSave from '~/service/saveData/homeIntelligentInfo'

const homeInfoStore = useHomeInfoStore() // pinia 仓库
const { data } = await getHomeIntelligentInfoReq() // 请求数据
// 错误处理
const errorSolve = () => {
// client 和 server 需要分开处理
if (import.meta.client) {
ElMessage.error('获取智能硬件数据失败,使用备份数据')
} else {
console.log('获取智能硬件数据失败,使用备份数据')
}
// 使用备份数据
const homeIntelligentInfo = ref(homeIntelligentInfoSave.data)
// 更新pinia仓库数据
homeInfoStore.updateNavbar(homeIntelligentInfo.value.navbars as Navbars)
homeInfoStore.updateBanner(homeIntelligentInfo.value.banners)
homeInfoStore.updateCategory(homeIntelligentInfo.value.categorys as unknown as Categorys)
}
// 接口请求失败则使用备份数据
if (!data.value) {
errorSolve()
} else {
const { code, data: curData } = data.value
if (code === 200) {
// 更新pinia仓库数据
homeInfoStore.updateNavbar(curData.navbars)
homeInfoStore.updateBanner(curData.banners)
homeInfoStore.updateCategory(curData.categorys)
} else {
// 返回数据失败则使用备份数据
errorSolve()
}
}
</script>

<style scoped lang="scss">
.home {
background-color: $bgGrayColor;
}
</style>

服务

pages/oppo-service.vue。服务只是一个简单的页面,并没有做相关的代码编写。

<template>
<div>Page: oppo-service</div>
</template>

详情

pages/category-detail.vue

<template>
<div class="oppo-detail">
<div class="wrapper">
<el-tabs class="oppo-tabs" v-model="activeName">
<template v-for="item in detailData" :key="item?.id">
<el-tab-pane :label="item?.title" :name="item?.title">
<category-grid :categoryUrl="''" :product-detailss="item?.productDetailss" />
</el-tab-pane>
</template>
</el-tabs>
<el-divider>
<span class="no-more">没有更多</span>
</el-divider>
</div>
</div>
</template>

<script setup lang="ts">
import { getDetailReq } from '~/service/detail'
import detailInfoSave from '~/service/saveData/detailInfo'
import type { oppoType } from '~/service/detail'
import type { IProductDetailss } from '~/store/detail'

const route = useRoute()
const { data } = await getDetailReq(route.query.type as oppoType)
const detailData = reactive<IProductDetailss>({}) // 详情数据
const activeName = ref('')
// 错误处理
const errorSolve = () => {
// client 和 server 需要分开处理
if (import.meta.client) {
ElMessage.error('获取详情数据失败,使用备份数据')
} else {
console.log('获取详情数据失败,使用备份数据')
}
// 使用备份数据
const detailInfo = ref(detailInfoSave.data)
Object.assign(detailData, detailInfo.value)
activeName.value = detailInfo.value[0].title as string
}
if (!data.value) {
errorSolve()
} else {
const { code, data: curData } = data.value
if (code === 200) {
Object.assign(detailData, curData)
activeName.value = curData[0].title as string
} else {
errorSolve()
}
}
</script>

<style scoped lang="scss">
.oppo-detail {
padding: 8px 0 60px 0;
background-color: $bgGrayColor;
.oppo-tabs {
:deep(.el-tabs__header) {
background-color: #fff;
}
:deep(.el-tabs__nav-wrap) {
padding: 0 52px;
height: 48px;
/* 底部线 */
&::after {
background-color: #fff;
}
/* 按钮 */
.el-tabs__nav-prev,
.el-tabs__nav-next {
width: 48px;
.el-icon,
svg {
width: 25px;
height: 25px;
}
svg {
position: relative;
top: 10px;
}
}
.el-tabs__active-bar {
background-color: $priceColor;
}
}

:deep(.el-tabs__item) {
position: relative;
padding-top: 5px;
opacity: 0.6;
height: 48px;
font-weight: 400;
&:hover,
&.is-active {
color: $priceColor;
}
}
}
.no-more {
opacity: 0.5;
font-size: 18px;
color: #343a40;
}
}
</style>

提取代码

前面看到主页的三个页面都有一段类似的 JS 代码,功能是去请求当前页的数据和配置备用数据。

我们就可以将这部分代码抽取出来,将其封装为一个工具函数,在需要使用的页面进行调用和传参即可。

工具函数

utils/reqUnite.ts

import type { IResponseResult } from '~/service'
import type { Banners, Categorys, IHomeInfo, Navbars } from '~/store/home'

// dataSave 类型为 any 是不想去重新调整数据的类型
export default function reqUnite(
homeInfoStore: {
updateNavbar: (navbars: Navbars) => void
updateBanner: (banners: Banners) => void
updateCategory: (categorys: Categorys) => void
},
data: Ref<IResponseResult<IHomeInfo>>,
dataSave: any
) {
// 错误处理
const errorSolve = () => {
// client 和 server 需要分开处理
if (import.meta.client) {
ElMessage.error('获取首页数据失败,使用备份数据')
} else {
console.log('获取首页数据失败,使用备份数据')
}
// 使用备份数据
const save = ref(dataSave.data)
// 更新pinia仓库数据
homeInfoStore.updateNavbar(save.value.navbars as Navbars)
homeInfoStore.updateBanner(save.value.banners)
homeInfoStore.updateCategory(save.value.categorys as unknown as Categorys)
}
// 接口请求失败则使用备份数据
if (!data.value) {
errorSolve()
} else {
const { code, data: curData } = data.value
if (code === 200) {
// 更新pinia仓库数据
homeInfoStore.updateNavbar(curData.navbars)
homeInfoStore.updateBanner(curData.banners)
homeInfoStore.updateCategory(curData.categorys)
} else {
// 返回数据失败则使用备份数据
errorSolve()
}
}
}

修改主页相关代码

OPPO 专区

import { getHomeInfoReq } from '~/service/home'
import homeInfoSave from '~/service/saveData/homeInfo'
import { useHomeInfoStore } from '~/store/home'
// 首页和默认布局中都获取了首页数据。
// 首页和其余页面的切换都需要重新获取首页数据
// 不同的是,默认布局只在详情页才进行获取首页数据。
const homeInfoStore = useHomeInfoStore() // pinia 仓库
const { data } = await getHomeInfoReq() // 请求首页数据
reqUnite(homeInfoStore, data, homeInfoSave) // 统一处理请求数据

OnePlus 专区

import { getHomeOnePlusInfoReq } from '~/service/home'
import { useHomeInfoStore } from '~/store/home'
import homeOnePlusInfoSave from '~/service/saveData/onePlusInfo'

const homeInfoStore = useHomeInfoStore() // pinia 仓库
const { data } = await getHomeOnePlusInfoReq() // 请求数据
reqUnite(homeInfoStore, data, homeOnePlusInfoSave) // 统一处理请求数据

智能硬件

import { getHomeIntelligentInfoReq } from '~/service/home'
import { useHomeInfoStore } from '~/store/home'
import homeIntelligentInfoSave from '~/service/saveData/homeIntelligentInfo'

const homeInfoStore = useHomeInfoStore() // pinia 仓库
const { data } = await getHomeIntelligentInfoReq() // 请求数据