我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)
第二期 · 使用 Vue 3.1 + TypeScript + Router + Tailwind.css 仿 itch.io 平台主页。
我的主题 HapiGames 是仿 itch.io 的 indie game hosting marketplace。本博客将从头到尾模仿 itch.io 的主页。
效果图
代码仓库
alicepolice/Vue at 06 (github.com)
风格指南
当你掌握一门语言的时候,在写项目之前不妨先看看风格指南吧,前人早为你铺好了路。下面是我自己编写项目代码时没有规范到位的几个点。
Prop 定义
Prop 定义应该尽量详细,至少需要指定其类型。Props | Vue.js (vuejs.org)
Vue的选项式API为我们提供了Prop校验,你可以向 props
选项提供一个带有 props 校验选项的对象,当 prop 的校验失败后,Vue 会抛出一个控制台警告 (开发模式)。(如果用ts的话更好)
注意 prop 的校验是在组件实例被创建之前,所以实例的属性 (比如 data
、computed
等) 将在 default
或 validator
函数中不可用。
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。
先从网站顶部开始,该组件在每个页面都会显示,并在滚动过程中固定定位。
编写代码,实现顶部栏。
<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之旅、05 导航栏、登录、注册 (Mobile) - 小能日记 - 博客园 (cnblogs.com)
在前一期内容中,我们创建的导航栏是底部导航栏。
现在我们推倒重来,实现一下侧边导航栏。
侧边栏导航也叫抽屉式导航是隐藏在界面侧边的位置,一般是通过点击界面左上角的icon弹出,主要承载的内容是除了核心功能意外的主要功能。侧边栏还分全侧边和半侧边。
当我们在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
侧边导航栏有相似之处,不妨将这一块提取成独立的组件,然后复用三次。
添加样式 hover:text-rose-500 hover:underline
,在移动端按下时会改变颜色。
<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层(遮蔽层),一般指侧边栏滚出后背景变黑的部分。
我们使用自定义类名实现过渡动画。类名也是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" id="model">
<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"
id="sideBar"
>
搜索框
focus:outline-none focus:ring focus:border-blue-200
当当前光标指向该input标签时更改样式,让四角发光变蓝。该段代码也可以提取成基本组件。
<div class="mt-3 mx-2">
<input
id="search"
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
让图标和下载超链接完全数据化,增加网页动态变化能力。
<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单向数据流的最佳实现。
<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
<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
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
嵌入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
<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
<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
规定了比较复杂的传入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垂直居中
前端 - vue如何动态加载本地图片_个人文章 - SegmentFault 思否
Crown icon PNG and SVG Vector Free Download (uxwing.com)
Star Vector SVG Icon (10) - PNG Repo Free PNG Icons
我的Vue之旅 06 超详细、仿 itch.io 主页设计(Mobile)的更多相关文章
- 阿望教你用vue写扫雷(超详细哦)
前言 话说阿望还在大学时,某一天寝室突然停网了,于是和室友两人不约而同地打开了扫雷,比相同难度下谁更快找出全部的雷,玩得不亦乐乎,就这样,扫雷伴我们度过了断网的一周,是整整一周啊,不用上课的那种,可想 ...
- 我的Vue之旅、05 导航栏、登录、注册 (Mobile)
第一期 · 使用 Vue 3.1 + TypeScript + Router + Tailwind.css 构建手机底部导航栏.仿B站的登录.注册页面. 代码仓库 alicepolice/Vue-05 ...
- 超详细Vue实现导航栏绑定内容锚点+滚动动画+vue-router(hash模式可用)
超详细Vue实现导航栏绑定内容锚点+滚动动画+vue-router(hash模式可用) 转载自:https://www.jianshu.com/p/2ad8c8b5bf75 亲测有效~ <tem ...
- 【强烈推荐,超详细,实操零失误】node.js安装 + npm安装教程 + Vue开发环境搭建
node.js安装 + npm安装教程 + Vue开发环境搭建 [强烈推荐,超详细,实操零失误] 原博客园地址:https://www.cnblogs.com/goldlong/p/8027997.h ...
- 超详细动手搭建一个Vuepress站点及开启PWA与自动部署
超详细动手搭建一个Vuepress站点及开启PWA与自动部署 五一之前就想写一篇关于Vuepress的文章,结果朋友结婚就不了了之了. 记得最后一定要看注意事项! Vuepress介绍 官网:http ...
- Linux 学习笔记之超详细基础linux命令 Part 13
Linux学习笔记之超详细基础linux命令 by:授客 QQ:1033553122 ---------------------------------接Part 12---------------- ...
- Linux 学习笔记之超详细基础linux命令 Part 1
Linux学习笔记之超详细基础linux命令 by:授客 QQ:1033553122 说明:主要是在REHL Server 6操作系统下进行的测试 --字符界面虚拟终端与图形界面之间的切 方法:[ ...
- 超详细的Java时间工具类
package com.td.util; import java.sql.Timestamp; import java.text.ParseException; import java.text.Pa ...
- 【python】10分钟教你用python打造贪吃蛇超详细教程
10分钟教你用python打造贪吃蛇超详细教程 在家闲着没妹子约, 刚好最近又学了一下python,听说pygame挺好玩的.今天就在家研究一下, 弄了个贪吃蛇出来.希望大家喜欢. 先看程序效果: 0 ...
随机推荐
- Python3利用Twilio(国际)以及腾讯云服务(国内)免费发送手机短信
原文转载自「刘悦的技术博客」https://v3u.cn/a_id_152 短信服务验证服务已经不是什么新鲜事了,但是免费的手机短信服务却不多见,本次利用Python3.0基于Twilio和腾讯云服务 ...
- Python3的原生协程(Async/Await)和Tornado异步非阻塞
原文转载自「刘悦的技术博客」https://v3u.cn/a_id_113 我们知道在程序在执行 IO 密集型任务的时候,程序会因为等待 IO 而阻塞,而协程作为一种用户态的轻量级线程,可以帮我们解决 ...
- ansible 的安装及常见模块使用
ansible 基础keys的ssh协议配置的 特性:幂等性:一个任务执行1遍和执行n遍效果一样. ansible是个管理软件不是服务,不需要长期运行 一.通过epel源安装ansible, 1.下 ...
- PyTorch中的CUDA操作
CUDA(Compute Unified Device Architecture)是NVIDIA推出的异构计算平台,PyTorch中有专门的模块torch.cuda来设置和运行CUDA相关操作.本 ...
- HC32L110(五) Ubuntu20.04 VSCode的Debug环境配置
目录 HC32L110(一) HC32L110芯片介绍和Win10下的烧录 HC32L110(二) HC32L110在Ubuntu下的烧录 HC32L110(三) HC32L110的GCC工具链和VS ...
- 【必知必会】手把手教你配置MySQL环境变量——图文详解
一.先决条件 假设我们已经成功安装MySQL数据库.如果还有小伙伴不知道如何安装MySQL数据库,可以在本文下留言,留言数超20,则出一期"手把手教你安装MySQL数据库--图文详解&quo ...
- Java开发学习(三十)----Maven聚合和继承解析
一.聚合 分模块开发后,需要将这四个项目都安装到本地仓库,目前我们只能通过项目Maven面板的install来安装,并且需要安装四个,如果我们的项目足够多,那么一个个安装起来还是比较麻烦的 如果四个项 ...
- KingbaseES V8R6 锁等待检测
背景 对于多数数据库,dba技能之一就是查找锁.锁的存在有效合理的在多并发场景下保证业务有序进行.下面我们看一下KingbaseESV8R6中查找阻塞的方法. 1.找到"被阻塞者" ...
- Oracle_FDW 使用介绍
本文以例子的形式介绍 KingbaseES(Postgresql)数据库如何通过 oracle_fdw 扩展访问Oracle数据库.以下例子在PG12.3 与 KingbaseES V8R6进行过实际 ...
- 接口测试神器Apifox,亲测好用!
自己关注的公众号比较多,之前有收到过有关 Apifox 的文章,自己也是大致看看,还没有用过它! 最近看到比较多有关 Apifox 的文章,所以自己就花了点时间去研究它,使用完后发现确实比Postma ...