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
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
import ElementPLus from 'element-plus' import { zhCn } from 'element-plus/es/locale/index.mjs' 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 ; $navBarHeight : 84px ; $logoWidth : 250px ;$logoHeight : 50px ;$swiperHeight : 480px ; $categoryBarHeight : 120px ; $imgWidth : 130px ;$imgHeight : 150px ;$gridItemHeight : 300px ; $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; } @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 ; } @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 ; } @mixin border($color : red) { border : 1px solid $color ; }
编写全局样式 assets/css/global.scss 全局样式文件
@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 : ['@/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.vue、components/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 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' export const getHomeInfoReq = ( ) => { return RXRequest .get <IResponseResult <IHomeInfo >>('/home/info' , { type : 'oppo' }) } 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' 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 } export interface IBanner { id?: string | number picStr?: string link?: string } export interface IActivityList { type ?: string | number activityInfo?: string } export type activityList = IActivityList []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 [] } export type ProductDetailss = IProductDetailss []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 [] export type Banners = IBanner [] export type Categorys = ICategory [] 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' 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 = ( ) => { if (import .meta .client ) { ElMessage .error ('获取首页数据失败,使用备份数据' ) } else { console .log ('获取首页数据失败,使用备份数据' ) } const save = ref (dataSave.data ) 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 ) { 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 () 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 () 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 () const { data } = await getHomeIntelligentInfoReq ()