想做 Anime List 很久了,之前也考虑过 Notion 的方案,但是本人平常也不用 Notion,不想为了这个事情多用一个平台。最近发现自己使用的 bangumi 有 API,于是就想到了用它来做 Anime List。
目前在博客导航栏选择动画即可进入 Anime List 页面。
编码过程得到了 windicss 这一原子 CSS 框架和 Copilot 的大力帮助。
Copilot
你可以直接往代码里粘贴 bangumi 接口 的返回数据示例,然后敲一个 interface Anime
,后面就是 Copilot 发挥了。
interface Anime {
updated_at: string
comment: string
tags: { name: string; count: number }[]
subject: {
date: string
images: {
small: string
grid: string
large: string
medium: string
common: string
}
name: string
name_cn: string
short_summary: string
tags: { name: string; count: number }[]
score: number
type: number
id: number
eps: number
volumes: number
collection_total: number
rank: number
}
subject_id: number
vol_status: number
ep_status: number
subject_type: number
type: number
rate: number
private: boolean
}
windicss
<n-card
v-for="anime in animeList"
:key="anime.subject.id"
content-style="display:flex;flex-direction:column;justify-content:space-between"
style="height:100%"
>
<div class="mb-4">
<img
:src="anime.subject.images.medium"
:alt="anime.subject.name"
class="rounded-lg"
>
<div class="mt-2">
<p class="text-lg font-bold">
{{ anime.subject.name_cn || anime.subject.name }}
</p>
<p class="text-sm text-gray-500 mt-4 whitespace-pre-line">
{{ anime.subject.short_summary }}……
</p>
</div>
</div>
<hr>
<div class="mt-2">
<p v-if="anime.comment" class="mt-2 text-sm">
{{ anime.comment }}
</p>
<div class="text-sm mt-4 flex justify-center">
<n-rate :default-value="anime.rate / 2" readonly allow-half />
<span class="text-gray-500 ml-2 text-xs mt-1">
{{ timeToDate(anime.updated_at) }}
</span>
</div>
</div>
</n-card>
写个样式非常方便,而且也可以 Copilot 生成类名(
分页
bangumi 的这个 API 是分页的,出于性能考虑可以写一个滚动加载。
const animeList = ref([] as Anime[])
const loading = ref(true)
const pageSize = 12
let page = 0
const fetchAnimeList = async () => {
if (!loading.value) return
const offset = page * pageSize
try {
const res = await fetch(
`https://api.bgm.tv/v0/users/undef_baka/collections?subject_type=2&type=2&limit=${pageSize}&offset=${offset}`,
)
if (!res.ok) {
throw new Error('Network response was not ok')
}
const data = await res.json()
const totalSize = data.total
animeList.value = animeList.value.concat(data.data)
if (offset + data.data.length >= totalSize) {
loading.value = false
}
page++
} catch (error) {
loading.value = false
}
}
// 节流
let ticking = false
async function updateOnScroll(event: Event) {
if (ticking) return
ticking = true
const element = event.target as HTMLElement
// 这里在处理兼容问题,正常情况元素滚动用元素,页面滚动用 document 就行
const isBottom = document.documentElement.scrollTop ? document.documentElement.scrollHeight - document.documentElement.scrollTop <= document.documentElement.clientHeight + 100 :
element.scrollHeight - element.scrollTop <= element.clientHeight + 100
if (isBottom) {
await fetchAnimeList()
}
ticking = false
}
onMounted(async () => {
await fetchAnimeList()
nextTick(() => {
const element = document.querySelector('.n-layout-content .n-scrollbar-container')
element?.addEventListener('scroll', updateOnScroll)
document.addEventListener('scroll', updateOnScroll)
})
})
onUnmounted(() => {
const element = document.querySelector('.n-layout-content .n-scrollbar-container')
element?.removeEventListener('scroll', updateOnScroll)
document.removeEventListener('scroll', updateOnScroll)
})