我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

时间:2022-10-19 07:14:38

第二期 · 使用 Vue 3.1 + TypeScript + Router + Tailwind.css 仿 itch.io 平台主页。

我的主题 HapiGames 是仿 itch.ioindie game hosting marketplace

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)


代码仓库

alicepolice/Vue at 06 (github.com)

风格指南

当你掌握一门语言的时候,在写项目之前不妨先看看风格指南吧,前人早为你铺好了路。下面是我自己编写项目代码时没有规范到位的几个点。

风格指南 — Vue.js (vuejs.org)


Prop 定义

Prop 定义应该尽量详细,至少需要指定其类型。Props | Vue.js (vuejs.org)

Vue的选项式API为我们提供了Prop校验,你可以向 props 选项提供一个带有 props 校验选项的对象,当 prop 的校验失败后,Vue 会抛出一个控制台警告 (开发模式)。(如果用ts的话更好)

注意 prop 的校验是在组件实例被创建之前,所以实例的属性 (比如 datacomputed 等) 将在 defaultvalidator 函数中不可用。


v-for和v-if同时在一个标签时,将v-if提取到计算属性

因为 v-for 优先级比 v-if 高,所以每次渲染时必定会遍历数组所有元素。避免 v-if 和 v-for 用在一起

将v-if提取到计算属性后的好处

  • 过滤后的列表会在对应数组发生相关变化时才被重新运算,过滤更高效。
  • 使用 v-for="item in afterComputed" 之后,在渲染的时候遍历元素少了,渲染更高效。
  • 解耦渲染层的逻辑,可维护性 (对逻辑的更改和扩展) 更强。

紧密耦合的组件名

和父组件紧密耦合的子组件应该以父组件名作为前缀命名。紧密耦合的组件名

如果一个组件只在某个父组件的场景下有意义,这层关系应该体现在其名字上。因为编辑器通常会按字母顺序组织文件,所以这样做可以把相关联的文件排在一起。

不建议为了紧密耦合搞目录区分,因为会出现文件名名字相同、IDE侧边栏浏览组件花费时间多的问题。

components/
|- TodoList.vue
|- TodoListItem.vue
|- TodoListItemButton.vue

自闭合组件

在单文件组件、字符串模板和 JSX 中没有内容的组件应该是自闭合的——但在 DOM 模板里永远不要这样做。 自闭合组件

<!-- 在单文件组件、字符串模板和 JSX 中 -->
<MyComponent/>

<!-- 在 DOM 模板中 -->
<my-component></my-component>

Prop 名大小写

在声明 prop 的时候,其命名应该始终使用 camelCase,而在模板和 JSX 中应该始终使用 kebab-case。 Prop 名大小写

props: {
  greetingText: String
}
<WelcomeMessage greeting-text="hi"/>

简单的计算属性

应该把复杂计算属性分割为尽可能多的更简单的 property。 简单的计算属性

好处是易于测试、易于阅读、更好的“拥抱变化”。


单文件组件的*元素的顺序

单文件组件应该总是让 <script>、<template> 和 <style> 标签的顺序保持一致。且 <style> 要放在最后,因为另外两个标签至少要有一个。 单文件组件的*元素的顺序


隐性的父子组件通信

应该优先通过 prop 和事件进行父子组件之间的通信,而不是 this.$parent 或变更 prop。 隐性的父子组件通信

数据流应该是单向的,不要反向修改 props。


方便调试

为了方便调试,我们在 index.css 下新增一个样式组合,通过添加test类样式类看到块元素的边框。

  .test{
    @apply border border-gray-900
  }

目录结构

├───assets
│   ├───avater
│   │   用户头像
│   ├───blog
│   │   博文封面图
│   ├───diffuse
│   │   模糊背景
│   ├───game
│   │   游戏封面图
│   ├───logo
│   │   网站logo
│   ├───slideshow
│   │	轮播图样图
│   └───svg
│		很多矢量图
├───components
│   ├───common
│   │       BottomBar.vue
│   │       CommentArea.vue
│   │       SideBar.vue
│   │       SideBarHref.vue
│   │       SlideShow.vue
│   │       TopBar.vue
│   │
│   └───HomeView
│           GameBlog.vue
│           GameInfo.vue
│           GameList.vue
│           HomeFAQ.vue
│           HomeFooter.vue
│           PlatformNavigation.vue
│           TopNavigation.vue
│
├───router
│       index.ts
└───views
        AboutView.vue
        CommentTestView.vue
        HomeLoginView.vue
        HomeView.vue
        LoginView.vue
        RegisterView.vue

网站顶部组件 TopBar.vue

在 src/components/common 下新建 TopBar.vue,并移入之前写的 BottomBar.vue。

先从网站顶部开始,该组件在每个页面都会显示,并在滚动过程中固定定位。

编写代码,实现顶部栏。

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

<template>
  <div class="h-12 shadow-md">
    <div class="inline-block h-full w-16">
      <b-icon-list class="text-3xl mt-2 ml-4"></b-icon-list>
    </div>
    <div class="inline-block h-full w-48">
      <img src="@/assets/logo/logo3.png" class="mt-1 h-4/5 w-full" />
    </div>
    <div class="inline-block float-right mt-2.5 mr-4">
      <div
        class="
          border-2 border-gray-300
          px-3.5
          py-0.5
          rounded-sm
          text-sm
          font-bold
        "
      >
        Log in
      </div>
    </div>
  </div>
</template>

侧边导航栏组件 SideBar.vue

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

注释底部导航栏

我的Vue之旅、05 导航栏、登录、注册 (Mobile) - 小能日记 - 博客园 (cnblogs.com)

在前一期内容中,我们创建的导航栏是底部导航栏

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

现在我们推倒重来,实现一下侧边导航栏

侧边栏导航也叫抽屉式导航是隐藏在界面侧边的位置,一般是通过点击界面左上角的icon弹出,主要承载的内容是除了核心功能意外的主要功能。侧边栏还分全侧边和半侧边。

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

当我们在App.vue中注释掉现有的底部导航栏,此时会出现错误item.routerName => item对象的类型为 "unknown"。

<template>
  <router-view @set-bottom-flag="setBottomFlag" />
  <!-- <BottomBar v-show="bottomFlag" :items="bottomItems" /> -->
</template>


<script lang="ts">
import { defineComponent } from "vue";
// import BottomBar from "@/components/BottomBar.vue";

export default defineComponent({
  name: "App",
  components: {
    // BottomBar,
  }
  ...

[TS]使用高级类型PropType注释props类型

IDE报错并不影响当前Vue实例,因为BottomBar组件并未挂载。但为了去除报错,使用高级类型注释来修改BottomBar.vue。

运行时 props 选项仅支持使用构造函数来作为一个 prop 的类型,没有办法指定多层级对象或函数签名之类的复杂类型。在这里可以使用 PropType 注释复杂的props类型,报错解决。

<script lang="ts">
import { PropType } from "vue";

interface BottomItem {
  text: string;
  icon: string;
  routerName: string;
}
export default {
  props: {
    items: {
      type: Array as PropType<BottomItem[]>,
      required: true,
    },
  },
};
</script>

SideBarHref.vue

在 src/components/common 下新建 SideBarHref.vue

侧边导航栏有相似之处,不妨将这一块提取成独立的组件,然后复用三次。

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

添加样式 hover:text-rose-500 hover:underline,在移动端按下时会改变颜色。

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

<a :href="value.href">用于临时超链接占位,后续可改为router-link

<template>
  <div class="mt-8 mx-2">
    <div class="font-bold text-stone-700 text-sm">{{ items.title }}</div>
    <div class="w-full text-stone-600 mt-2 text-sm">
      <template v-for="(value, index) in items.items" :key="index">
        <a :href="value.href">
          <div
            class="
              py-1
              inline-block
              w-1/2
              align-middle
              hover:text-rose-500 hover:underline
            "
            v-html="value.text"
          ></div>
        </a>
      </template>
    </div>
  </div>
</template>

<script lang="ts">
import { PropType } from "vue";

interface item {
  text: string;
  href: string;
}

interface items {
  items: item[];
  title: string;
}

export default {
  name: "SideBarHref",
  props: {
    items: { type: Object as PropType<items>, required: true },
  },
};
</script>

SideBar.vue

遮蔽层

在 src/components/common 下新建 SideBar.vue 以下代码片段均为分段表示,不是完整代码。

先写model层(遮蔽层),一般指侧边栏滚出后背景变黑的部分。

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

我们使用自定义类名实现过渡动画。类名也是TailWind.css的类样式,给定200毫秒时间,过渡透明度状态。

div嵌套了两层,把opacity-50写到里面的div层能解决opacity-50在外面div层的时候出现背景全黑问题。

fixed 用于固定遮蔽层。z-30用于设置优先级,先显示在前面。v-show由App.vue传入,顶部组件通知App.vue事件对应的方法修改,进而引发当前transition的过渡。

html - Vue Transition with Tailwind - Stack Overflow

<template>
  <transition
    enter-active-class="duration-200"
    enter-from-class="opacity-0"
    enter-to-class="opacity-100"
    leave-active-class="duration-200"
    leave-to-class="opacity-0"
    leave-from-class="opacity-100"
  >
    <div class="fixed z-30 h-full w-full" v-show="showFlag" >
      <div class="bg-black h-full w-full opacity-50"></div>
    </div>
  </transition>

侧边栏

侧边栏的动画效果跟遮蔽层一个原理,只不过修改成为了移动而不是改变透明度。

overflow-auto 可以让侧边栏在内容溢出时具备滚动条。

 <transition
    enter-active-class="duration-200 ease-out"
    enter-from-class="-translate-x-64"
    enter-to-class="translate-x-0"
    leave-active-class="duration-200 ease-in"
    leave-from-class="translate-x-0"
    leave-to-class="-translate-x-64"
  >
   <div
      class="fixed z-40 top-12 w-64 h-full bg-stone-100 border-r overflow-auto"
      v-show="showFlag"
      
    >

搜索框

focus:outline-none focus:ring focus:border-blue-200当当前光标指向该input标签时更改样式,让四角发光变蓝。该段代码也可以提取成基本组件。

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

      <div class="mt-3 mx-2">
        <input
          
          class="
            bg-white
            focus:outline-none focus:ring focus:border-blue-200
            py-1.5
            pl-3
            w-full
            border border-gray-300
            text-sm
          "
          type="text"
          placeholder="Search games & creators"
          v-model="search"
        />
      </div>

SideBarHref

三次复用之前定义的SideBarHref组件,并传入了props

      <SideBarHref :items="popularTags"></SideBarHref>
      <SideBarHref :items="browse"></SideBarHref>
      <SideBarHref :items="gamesByPrice"></SideBarHref>

download app

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

让图标和下载超链接完全数据化,增加网页动态变化能力。

      <div class="h-20 text-center">
        <div class="pt-6">
          <template v-for="(value, index) in appInfo.apps" :key="index">
            <a :href="value.href">
              <component
                :is="value.icon"
                class="inline m-1 text-xl hover:text-rose-500"
              ></component>
            </a>
          </template>
          <a :href="appInfo.download.href">
            <span
              class="
                text-xs text-stone-800
                mx-2
                hover:text-rose-500 hover:underline
              "
              >{{ appInfo.download.title }}</span
            >
          </a>
        </div>
      </div>

数据驱动

除非结构要改,现在完全可以靠data里的对象数据驱动当前侧边栏的所有内容。

  data() {
    return {
      search: "",
      popularTags: {
        title: "POPULAR TAGS",
        items: [
          { text: "Horror games", href: "" },
          { text: "Multiplayer", href: "" },
          { text: "Visual novels", href: "" },
          { text: "HTML5 games", href: "" },
          { text: "Simulation", href: "" },
          { text: "macOS games", href: "" },
          { text: "Roguelike", href: "" },
          { text: "Linux games", href: "" },
          { text: "Browse all tags", href: "" },
        ],
      },
      browse: {
        title: "BROWSE",
	  ....

联动顶部组件与侧边导航栏组件

我们的想法是按下顶部组件左边的 list icon,弹出导航栏,再按一次关闭导航栏。

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

很容易想到父子通信的解决方案,这也是Vue单向数据流的最佳实现。

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)
<template>
  <TopBar @changeSideFlag="changeSideFlag"></TopBar>
  <SideBar :show-flag="sideFlag"></SideBar>
  <div class="absolute top-12 w-full z-10">
    <router-view />
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import TopBar from "./components/common/TopBar.vue";
import SideBar from "./components/common/SideBar.vue";

export default defineComponent({
  name: "App",
  components: {
    TopBar,
    SideBar,
  },
  data() {
    return {
      sideFlag: false as boolean,
    };
  },
  methods: {
    changeSideFlag(): void {
      this.sideFlag = !this.sideFlag;
    },
  },
});
</script>

[TS]defineComponent 作用

App.vue 里的 export default defineComponent({ 是什么?

搭配 TypeScript 使用 Vue | Vue.js (vuejs.org)

defineComponent 是TypeScript独有的,可以根据选项式API的props、data自动推导各个字段的类型,当在生命周期函数、Methods函数、模板表达式中使用这些字段时可以进行类型检查。(不显式引入编译器默认自动引入)


移动端主页

HomeView.vue

我们将一个主页拆分为各个组件,并完全依托数据驱动,图片仅用来本地测试。

<template>
  <HomeFAQ />
  <TopNavigation :top-navigation="topNavigation"></TopNavigation>
  <GameInfo :game-info="gameInfo"></GameInfo>
  <GameBlog :game-blog="gameBlog"></GameBlog>
  <PlatformNavigation
    :platform-navigation="platformNavigation"
  ></PlatformNavigation>
  <GameList :game-list="latestGames"></GameList>
  <GameList :game-list="mostFeatureGames"></GameList>
  <HomeFooter />
</template>

<script lang="ts">
import { defineComponent } from "vue";
import GameInfo from "../components/HomeView/GameInfo.vue";
import GameBlog from "../components/HomeView/GameBlog.vue";
import HomeFAQ from "../components/HomeView/HomeFAQ.vue";
import TopNavigation from "../components/HomeView/TopNavigation.vue";
import GameList from "../components/HomeView/GameList.vue";
import PlatformNavigation from "../components/HomeView/PlatformNavigation.vue";
import HomeFooter from "../components/HomeView/HomeFooter.vue";

export default defineComponent({
  name: "HomeView",
  components: {
    GameInfo,
    GameBlog,
    HomeFAQ,
    TopNavigation,
    GameList,
    PlatformNavigation,
    HomeFooter,
  },
  data() {
    return {
      topNavigation: [
        { text: "All Games", href: "" },
        { text: "Game jams", href: "" },
        { text: "Developer Logs", href: "" },
        { text: "Community", href: "" },
        { text: "Bundles", href: "" },
      ],
      gameInfo: {
        youtube:
          "https://www.youtube.com/embed/U7MJljsoUSo?autoplay=0&fs=0&iv_load_policy=3&showinfo=0&rel=0&cc_load_policy=0&start=0&end=0",
        title: "Baba Is You",
        desc: "You can change the rules by which you play",
        price: "$14.99",
        platforms: ["b-icon-windows", "b-icon-apple"],
        images: [
          require("@/assets/slideshow/1.png"),
          require("@/assets/slideshow/2.png"),
          require("@/assets/slideshow/3.png"),
          require("@/assets/slideshow/4.png"),
          require("@/assets/slideshow/5.png"),
        ],
      },
      gameBlog: [
        {
          title: "Games of the Month: surrealist solitaire puzzles",
          text: `What’s that? You need more games? I hear you, anonymous hapi fan.
          We’ve reached the part of the year when games start coming out fast`,
          img: require("@/assets/blog/1.jpg"),
        },
        {
          title: "Games of the Month: Puzzles!",
          text: `Sometimes you need a good puzzle game, just something to throw all of
          your attention at and ignore anything else going on. Well if that
          sometime for you is right now, then you’re in luck because in this
          Games of the Month`,
          img: require("@/assets/blog/2.jpg"),
        },
        {
          title: "The next hapi Creator Day is July 29th!",
          text: ` I don’t think I’m allowed to make the entire body of this post “The
          next itch.io Creator Day is taking place on Friday July 29th.” I mean
          it’s true, we are hosting the next itch.io Creator Day on Friday July
          29th but I should probably write more here.`,
          img: require("@/assets/blog/3.jpg"),
        },
      ],
      platformNavigation: [
        {
          title: "Windows",
          href: "",
          img: require("@/assets/svg/windows.svg"),
        },
        {
          title: "macOS",
          href: "",
          img: require("@/assets/svg/apple.svg"),
        },
        {
          title: "Linux",
          href: "",
          img: require("@/assets/svg/linux.svg"),
        },
        {
          title: "Android",
          href: "",
          img: require("@/assets/svg/android.svg"),
        },
        {
          title: "iOS",
          href: "",
          img: require("@/assets/svg/apple.svg"),
        },
        {
          title: "Web",
          href: "",
          img: require("@/assets/svg/web.svg"),
        },
        {
          title: "Free",
          href: "",
          img: require("@/assets/svg/free.svg"),
        },
        {
          title: "On Sale",
          href: "",
          img: require("@/assets/svg/sale.svg"),
        },
        {
          title: "Top Seller",
          href: "",
          img: require("@/assets/svg/star.svg"),
        },
        {
          title: "Recent",
          href: "",
          img: require("@/assets/svg/recent.svg"),
        },
      ],
      latestGames: {
        title: "Latest Featured Games",
        button: {
          title: "View all",
          href: "",
        },
        games: [
          {
            title: "Late Night Mop",
            text: "A haunted house cleaning simulator.",
            img: require("@/assets/game/1.png"),
            price: 0,
          },
          {
            title: "an average day at the cat cafe",
            text: "A haunted house cleaning simulator.",
            img: require("@/assets/game/2.png"),
            price: 0,
            web: true,
          },
          {
            title: "Corebreaker",
            text: "A fast-paced action-platform shooter game with roguelike elements.",
            img: require("@/assets/game/3.png"),
            price: 19.99,
            tags: ["Difficult", "Fast-Paced"],
          },
          {
            title: "Beacon Pines",
            text: "Normal isn't what it used to be.",
            img: require("@/assets/game/4.png"),
            price: 4.99,
          },
          {
            title: "Atuel",
            text: "Traverse a surrealist landscape inspired by the Atuel River in Argentina.",
            img: require("@/assets/game/5.png"),
            price: 0,
          },
        ],
      },
      mostFeatureGames: {
        title: "Most Featured Games",
        button: {
          title: "View all",
          href: "",
        },
        games: [
          {
            title: "Hitobito no Hikari - Heian Jidai",
            text: "A survival horror TTRPG about cursed priestesses.",
            img: require("@/assets/game/6.png"),
            tags: ["Physical games"],
            price: 3,
          },
          {
            title: "Doko Roko",
            text: "A symbiosis with ancient shadows. A tower full of demons. A proverb.",
            img: require("@/assets/game/7.png"),
            price: 10,
          },
          {
            title: "The Zachtronics Solitaire Collection",
            text: "All seven Zachtronics solitaire games, updated with new 4K graphics, plus one brand new Tarot-themed solitaire variant.",
            img: require("@/assets/game/8.png"),
            price: 9.99,
            tags: ["Card Game", "Singleplayer"],
          },
          {
            title: "Mixolumia",
            text: "Entrancing musical falling block puzzler.",
            img: require("@/assets/game/9.png"),
            price: 10,
            tags: ["High Score", "Arcade"],
          },
          {
            title: "Atuel",
            text: "Traverse a surrealist landscape inspired by the Atuel River in Argentina.",
            img: require("@/assets/game/5.png"),
            price: 0,
          },
          {
            title: "Corebreaker",
            text: "A fast-paced action-platform shooter game with roguelike elements.",
            img: require("@/assets/game/3.png"),
            price: 19.99,
            tags: ["Difficult", "Fast-Paced"],
          },
        ],
      },
    };
  },
});
</script>

HomeFAQ.vue

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

<template>
  <div class="h-24 p-2 text-sm bg-stone-100">
    <div class="mt-1">
      <b>HapiGames</b> is a simple way to find and share indie games online for
      free.
    </div>
    <div class="mt-2">
      <a class="underline text-rose-500">Add your game</a> or
      <a class="underline text-rose-500">Read the FAQ</a>
    </div>
  </div>
</template>
<script>
export default {
  name: "HomeFAQ",
}
</script>

TopNavigation

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

overflow-x-auto flex flex布局,并在溢出时开启横轴滚动条

whitespace-nowrap 类可以防止换行,让所有元素保持在一行上。

html - Div with horizontal scrolling only - Stack Overflow

<template>
  <div class="overflow-x-auto flex bg-white">
    <template v-for="(value, index) in topNavigation" :key="index">
      <a :href="value.href">
        <div
          class="
            p-3
            font-bold
            text-sm text-stone-800
            hover:text-rose-500
            whitespace-nowrap
          "
        >
          {{ value.text }}
        </div>
      </a>
    </template>
  </div>
</template>
<script lang="ts">
import { PropType } from "vue";

interface TopNavigation {
  text: string;
  href: string;
}

export default {
  name: "topNavigation",
  props: {
    topNavigation: {
      type: Array as PropType<TopNavigation[]>,
      required: true,
    },
  },
};
</script>

GameInfo.vue

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

嵌入YOUTUBE视频可参考 youtubeembedcode.com

      <div class="w-full">
        <img :src="currentImg[0]" class="w-1/2 inline-block" />
        <img :src="currentImg[1]" class="w-1/2 inline-block" />
      </div>

用于生成两张轮播图,每四秒切换一次,具体方法如下。注意 currentImg: function (): string[] { 可以给计算属性添加类型检查。

  methods: {
    startSlide: function (): void {
      this.timer = setInterval(this.next, 4000);
    },

    next: function (): void {
      this.currentIndex += 1;
    },
  },
  computed: {
    currentImg: function (): string[] {
      let index = Math.abs(this.currentIndex) % this.gameInfo.images.length;
      let index2 = (index + 1) % this.gameInfo.images.length;
      return [this.gameInfo.images[index], this.gameInfo.images[index2]];
    },
  },

考虑 img 标签的 :src 只能接收 string ,我们假设所有 require 方法获取的图片均为 string 类型。定义prop类型

import { PropType } from "vue";

interface GameInfo {
  youtube: string;
  title: string;
  desc: string;
  price: number;
  platforms: string[];
  images: string[];
}

完整代码如下

<template>
  <div
    class="bg-auto p-1 text-white"
    :style="
      'background-image:url(' + require('@/assets/diffuse/diffuse.jpg') + ')'
    "
  >
    <div class="h-52 m-2">
      <iframe
        class="h-full w-full"
        frameborder="0"
        scrolling="no"
        marginheight="0"
        marginwidth="0"
        type="text/html"
        :src="gameInfo.youtube"
      ></iframe>
    </div>
    <div class="ml-2 font-bold text-xl">{{ gameInfo.title }}</div>
    <div class="ml-2 text-sm">{{ gameInfo.desc }}.</div>
    <div class="m-2">
      <div class="w-full">
        <img :src="currentImg[0]" class="w-1/2 inline-block" />
        <img :src="currentImg[1]" class="w-1/2 inline-block" />
      </div>
    </div>
    <div class="h-8 m-2">
      <div
        class="
          inline-block
          text-black
          bg-white
          rounded-md
          text-xs
          px-1
          py-0.5
          font-bold
        "
      >
        ${{ gameInfo.price }}
      </div>
      <template v-for="(value, index) in gameInfo.platforms" :key="index">
        <component :is="value" class="inline-block ml-2"></component>
      </template>
    </div>
    <div class="h-10 m-2 w-44">
      <div
        class="
          h-full
          text-lg
          font-bold
          py-1
          px-3
          border-2 border-white
          rounded-sm
        "
      >
        <span class="inline-block">Get the game</span>
        <b-icon-arrow-right
          class="inline-block ml-1 text-lg"
        ></b-icon-arrow-right>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import { PropType } from "vue";

interface GameInfo {
  youtube: string;
  title: string;
  desc: string;
  price: number;
  platforms: string[];
  images: string[];
}

export default {
  name: "GameInfo",
  props: {
    gameInfo: {
      type: Object as PropType<GameInfo>,
      required: true,
    },
  },
  data() {
    return {
      timer: null as unknown,
      currentIndex: 0,
    };
  },
  mounted() {
    this.startSlide();
  },
  methods: {
    startSlide: function (): void {
      this.timer = setInterval(this.next, 4000);
    },

    next: function (): void {
      this.currentIndex += 1;
    },
  },
  computed: {
    currentImg: function (): string[] {
      let index = Math.abs(this.currentIndex) % this.gameInfo.images.length;
      let index2 = (index + 1) % this.gameInfo.images.length;
      return [this.gameInfo.images[index], this.gameInfo.images[index2]];
    },
  },
};
</script>

GameBlog.vue

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

<template>
  <div class="m-2 mt-4">
    <div class="font-bold">From the blog</div>
    <div class="overflow-x-auto flex mt-2">
      <template v-for="(value, index) in gameBlog" :key="index">
        <div class="w-48 flex-shrink-0 mr-2">
          <img class="h-24 w-full" :src="value.img" />
          <div class="text-xs font-bold mt-1 text-stone-800 whitespace-normal">
            {{ value.title }}
          </div>
          <div class="h-12 text-xs overflow-clip mt-1 text-stone-500">
            {{ value.text }}
          </div>
        </div>
      </template>
    </div>
  </div>
</template>
<script lang="ts">
import { PropType } from "vue";

interface GameBlog {
  title: string;
  text: string;
  img: string;
}

export default {
  name: "GameBlog",
  props: {
    gameBlog: {
      type: Array as PropType<GameBlog[]>,
      required: true,
    },
  },
};
</script>

TopNavigation.vue

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

<template>
  <div class="m-2 mt-4">
    <div class="font-bold inline-block">Platform & Sale</div>
    <div class="flex mt-2 flex-wrap">
      <a
        :href="value.href"
        v-for="(value, index) in platformNavigation"
        :key="index"
        class="w-1/5 flex-shrink-0 hover:text-rose-500"
      >
        <div>
          <img :src="value.img" class="w-2/5 mx-auto mt-1" />
          <div class="text-center m-1.5 text-xs">{{ value.title }}</div>
        </div>
      </a>
    </div>
  </div>
</template>
<script lang="ts">
import { PropType } from "vue";

interface platformNavigation {
  title: string;
  href: string;
  img: string;
}

export default {
  name: "PlatformNavigation",
  props: {
    platformNavigation: {
      type: Array as PropType<platformNavigation[]>,
      required: true,
    },
  },
  data() {
    return {};
  },
};
</script>

GameList.vue

我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)

规定了比较复杂的传入prop类型,考虑到tags可能为空,在原来的模板外层div做v-if判断,否则会ts报错value.tags可能为undefined。

interface Game {
  title: string;
  text: string;
  img: string;
  price: number;
  web?: boolean;
  tags?: string[];
}

interface GameList {
  title: string;
  button: {
    title: string;
    href: string;
  };
  games: Game[];
}
<div class="text-xs font-normal mt-1" v-if="value.tags">
  <template v-for="(tag, index) in value.tags" :key="index">
    <a class="text-rose-500" href="">#{{ tag }}</a>
    <template v-if="index != value.tags.length - 1">,</template>
  </template>
</div>

完整代码

<template>
  <div class="m-2 mt-4">
    <div>
      <div class="font-bold inline-block">{{ gameList.title }}</div>

      <div v-if="gameList.button" class="float-right">
        <div
          class="
            border border-rose-400
            text-sm
            font-bold
            text-rose-500
            rounded-sm
            px-4
            py-1
            active:bg-rose-400 active:text-white
          "
        >
          {{ gameList.button.title }}
          <b-icon-arrow-right
            class="inline-block text-lg align-text-top"
          ></b-icon-arrow-right>
        </div>
      </div>

      <div class="w-full mt-4 flex flex-wrap justify-between">
        <template v-for="(value, index) in gameList.games" :key="index">
          <div class="w-44 inline-block align-top">
            <img class="h-28 w-full" :src="value.img" />
            <div
              class="text-xs font-bold mt-1 text-stone-800 w-3/4 inline-block"
            >
              {{ value.title }}
            </div>
            <div
              class="
                inline-block
                w-1/4
                align-top
                text-xs
                bg-stone-200
                rounded-sm
                py-0.5
                mt-1
                text-center
                font-bold
              "
              :class="{ 'bg-stone-500': value.price != 0 }"
            >
              <span v-if="value.web">WEB</span>
              <span v-else-if="value.price == 0">FREE</span>
              <span v-else-if="value.price != 0" class="font-normal text-white"
                >${{ value.price }}</span
              >
            </div>
            <div class="text-xs font-normal mt-1" v-if="value.tags">
              <template v-for="(tag, index) in value.tags" :key="index">
                <a class="text-rose-500" href="">#{{ tag }}</a>
                <template v-if="index != value.tags.length - 1">,</template>
              </template>
            </div>
            <div class="text-xs font-normal text-stone-500 mt-1">
              {{ value.text }}
            </div>
            <div class="my-1"></div>
          </div>
        </template>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import { PropType } from "vue";

interface Game {
  title: string;
  text: string;
  img: string;
  price: number;
  web?: boolean;
  tags?: string[];
}

interface GameList {
  title: string;
  button: {
    title: string;
    href: string;
  };
  games: Game[];
}

export default {
  name: "GameList",
  props: {
    gameList: {
      type: Object as PropType<GameList>,
      required: true,
    },
  },
};
</script>

HomeFooter.vue

<template>
  <div class="mx-2 my-4">
    <div class="text-center font-bold text-sm">
      Don't see anything you like?
    </div>
    <div
      class="
        w-11/12
        h-10
        pt-2.5
        text-center
        m-auto
        mt-4
        border border-rose-500
        font-bold
        text-sm text-rose-500
      "
    >
      View all Games
      <b-icon-arrow-right
        class="inline-block text-lg align-text-top"
      ></b-icon-arrow-right>
    </div>
    <div
      class="
        w-11/12
        h-10
        pt-2.5
        text-center
        m-auto
        mt-4
        border border-rose-500
        font-bold
        text-sm text-rose-500
      "
    >
      View something random
      <b-icon-arrow-left-right
        class="inline-block text-lg align-text-top"
      ></b-icon-arrow-left-right>
    </div>
  </div>
</template>
<script>
export default {
  name: "HomeFooter",
  props: {
  }
}
</script>

几个问题

这里列举我在开发过程遇到的一些问题,也许能帮助到你。

ERROR Error: The project seems to require yarn but it's not installed.

明明 yarn serve 成功了,并显示如下内容,但连接网页还是转圈圈。尝试重启电脑后重新 yarn serve

  App running at:
  - Local:   http://localhost:8080/ 

得到如下报错

 ERROR  Error: The project seems to require yarn but it's not installed.

解决方法:删除当前目录下的 yarn.lock 文件,命令行输入 npm install -g yarn


Type assertion expressions can only be used in TypeScript files.Vetur(8016)

解决方法:修改 <script> 为 <script lang="ts">


网站资源

Bootstrap Icons · Official open source SVG icon library for Bootstrap (getbootstrap.com)

Working with props declaration in Vue 3 + Typescript - DEV Community ????‍????????‍????

单组件的编写 | Vue3 入门指南与实战案例 (chengpeiquan.com)

vue3中的组件定义中defineComponent作用? - #2 由 cuidong - 中文 - Vue Forum (vuejs.org)

使用 CSS 实现垂直居中的8种方法_wincheshe的博客-CSDN博客_css垂直居中

导航栏样式大全-UICN

前端 - vue如何动态加载本地图片_个人文章 - SegmentFault 思否

Crown icon PNG and SVG Vector Free Download (uxwing.com)

Star Vector SVG Icon (10) - PNG Repo Free PNG Icons