person card

This commit is contained in:
jxxghp
2023-07-29 18:40:42 +08:00
parent d6fc34f7a6
commit b3a2585053
10 changed files with 510 additions and 134 deletions
+44
View File
@@ -192,6 +192,29 @@ export interface MediaInfo {
// 详情页面 // 详情页面
detail_link?: string detail_link?: string
// 其它TMDB属性
adult?: boolean
created_by?: string[]
episode_run_time?: string[]
genres?: string[]
first_air_date?: string
homepage?: string
languages?: string[]
last_air_date?: string
networks?: string[]
number_of_episodes?: number
number_of_seasons?: number
origin_countryv: string[]
original_name?: string
production_companies?: string[]
production_countries?: string[]
spoken_languages?: string[]
status?: string
tagline?: string
vote_count?: number
popularity?: number
runtime?: number
next_episode_to_air?: string
} }
// TMDB季信息 // TMDB季信息
@@ -253,6 +276,27 @@ export interface TmdbEpisode {
guest_stars: Object[] guest_stars: Object[]
} }
// TMDB人特信息
export interface TmdbPerson {
// ID
id: number
// 名称
name: string
// 角色
character: string
// 图片
profile_path: string
// 性别
gender: number
// 原名
original_name: string
}
// 站点 // 站点
export interface Site { export interface Site {
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+2 -2
View File
@@ -91,7 +91,7 @@ async function handleAddSubscribe() {
} }
else if (props.media?.type === '电视剧') { else if (props.media?.type === '电视剧') {
// 豆瓣电视剧,只会有一季 // 豆瓣电视剧,只会有一季
const season = props.media?.season || 1 const season = props.media?.season ?? 1
// 添加订阅 // 添加订阅
addSubscribe(season) addSubscribe(season)
} }
@@ -371,7 +371,7 @@ const seasonsHeaders = [
const getImgUrl: Ref<string> = computed(() => { const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) if (imageLoadError.value)
return noImage return noImage
const url = props.media?.poster_path || noImage const url = props.media?.poster_path ?? noImage
// 如果地址中包含douban则使用中转代理 // 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com')) if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}` return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts" setup>
import personIcon from '@images/misc/person.png'
import type { TmdbPerson } from '@/api/types'
const personProps = defineProps({
person: Object as PropType<TmdbPerson>,
width: String,
height: String,
})
// 当前人物
const personInfo = ref(personProps.person)
// 人物图片地址
function getPersonImage() {
if (!personInfo.value?.profile_path)
return personIcon
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
}
</script>
<template>
<VHover v-bind="personProps">
<template #default="hover">
<VCard
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
:class="{
'transition transform-cpu duration-300 scale-105': hover.isHovering,
}"
>
<div
class="person-card relative transform-gpu cursor-pointer rounded text-white shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
>
<div style="padding-bottom: 150%;">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
<div class="relative w-3/4 overflow-hidden rounded-full">
<VImg :src="getPersonImage()" cover />
</div>
</div>
<div class="w-full truncate text-center font-bold">
{{ personInfo?.name }}
</div>
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
{{ personInfo?.character }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
</div>
</div>
</div>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.person-card {
background-image: linear-gradient(45deg, #99999b, #384359 60%);
}
</style>
+13 -11
View File
@@ -3,7 +3,8 @@ import MediaCardListView from '@/views/discover/MediaCardListView.vue'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
type: Array as PropType<string[]> | PropType<string>, // API路径
paths: Array as PropType<string[]> | PropType<string>,
}) })
// 路由参数 // 路由参数
@@ -23,26 +24,27 @@ const titles: { [key: string]: any } = {
tv_weekly_global: '全球剧集榜', tv_weekly_global: '全球剧集榜',
movie_top250: '电影TOP250', movie_top250: '电影TOP250',
}, },
credits: '演员阵容',
media: { media: {
search: '搜索', search: '搜索',
}, },
} }
// 计算API路径 // 计算API路径
function getApiPath(types: string[] | string) { function getApiPath(paths: string[] | string) {
if (Array.isArray(types)) if (Array.isArray(paths))
return types.join('/') return paths.join('/')
else else
return types return paths
} }
// 面包屑标题 // 面包屑标题
function getTitle(types: string[] | string, title: any = '') { function getTitle(paths: string[] | string, title: any = '') {
if (Array.isArray(types)) { if (Array.isArray(paths)) {
if (title) if (title)
return [titles[types[0]][types[1]], title] return [titles[paths[0]][paths[1]], title]
return ['推荐', titles[types[0]][types[1]]] return ['推荐', titles[paths[0]][paths[1]]]
} }
else { else {
return ['发现'] return ['发现']
@@ -52,9 +54,9 @@ function getTitle(types: string[] | string, title: any = '') {
<template> <template>
<div> <div>
<VBreadcrumbs :items="getTitle(props.type || '', route.query?.title)" /> <VBreadcrumbs :items="getTitle(props.paths || '', route.query?.title)" />
<MediaCardListView <MediaCardListView
:apipath="getApiPath(props.type || '')" :apipath="getApiPath(props.paths || '')"
:params="route.query" :params="route.query"
/> />
</div> </div>
+1 -1
View File
@@ -91,7 +91,7 @@ const router = createRouter({
}, },
}, },
{ {
path: '/browse/:type+', path: '/browse/:paths+',
component: () => import('../pages/browse.vue'), component: () => import('../pages/browse.vue'),
props: true, props: true,
meta: { meta: {
+1 -1
View File
@@ -43,7 +43,7 @@ function getParams() {
return params return params
} }
// 获取订阅列表数据 // 获取列表数据
async function fetchData({ done }: { done: any }) { async function fetchData({ done }: { done: any }) {
try { try {
if (!props.apipath) if (!props.apipath)
+148 -119
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PersonCardSlideView from './PersonCardSlideView.vue'
import MediaCardSlideView from './MediaCardSlideView.vue' import MediaCardSlideView from './MediaCardSlideView.vue'
import api from '@/api' import api from '@/api'
import type { MediaInfo } from '@/api/types' import type { MediaInfo } from '@/api/types'
@@ -39,72 +40,73 @@ onMounted(() => {
style="background-image: linear-gradient(180deg, rgba(17, 24, 39, 47%) 0%, rgba(17, 24, 39, 100%) 100%);" style="background-image: linear-gradient(180deg, rgba(17, 24, 39, 47%) 0%, rgba(17, 24, 39, 100%) 100%);"
/> />
</div> </div>
</div> <div class="media-header">
<div class="media-header"> <div class="media-poster">
<div class="media-poster"> <VImg :src="mediaDetail.poster_path" cover />
<VImg :src="mediaDetail.poster_path" cover />
</div>
<div class="media-title">
<div class="media-status" />
<h1 class="media-title">
{{ mediaDetail.title }}
<span class="media-year">
({{ mediaDetail.year }})
</span>
</h1>
<span class="media-attributes">
<span>分级</span>
<span>时长</span>
<span>风格</span>
</span>
</div>
<div class="media-actions" />
</div>
<div class="media-overview">
<div class="media-overview-left">
<div class="tagline">
标签
</div> </div>
<h2>简介</h2> <div class="media-title">
<p>{{ mediaDetail.overview }}</p> <div class="media-status" />
<h1 class="media-title">
{{ mediaDetail.title }}
<span class="media-year">
({{ mediaDetail.year }})
</span>
</h1>
<span class="media-attributes">
<span>{{ mediaDetail.runtime }}</span>
<span>{{ mediaDetail.genres }}</span>
</span>
</div>
<div class="media-actions" />
</div> </div>
<div class="media-overview-right" /> <div class="media-overview">
</div> <div class="media-overview-left">
<div v-if="mediaDetail.tmdb_id"> <div v-if="mediaDetail.tagline" class="tagline">
<MediaCardSlideView :apipath="`tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}`"> {{ mediaDetail.tagline }}
<template #title="{ loaded }">
<div v-if="loaded" class="slider-header mt-3 ms-1">
<RouterLink to="" class="slider-title">
<span>演员阵容</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div> </div>
</template> <h2 v-if="mediaDetail.overview">
</MediaCardSlideView> 简介
</div> </h2>
<div v-if="mediaDetail.tmdb_id"> <p>{{ mediaDetail.overview }}</p>
<MediaCardSlideView :apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`"> </div>
<template #title="{ loaded }"> <div class="media-overview-right" />
<div v-if="loaded" class="slider-header mt-3 ms-1"> </div>
<RouterLink to="" class="slider-title"> <div v-if="mediaDetail.tmdb_id">
<span>推荐</span> <PersonCardSlideView :apipath="`tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}`">
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <template #title="{ loaded }">
</RouterLink> <div v-if="loaded" class="slider-header mt-3 ms-1">
</div> <RouterLink to="" class="slider-title">
</template> <span>演员阵容</span>
</MediaCardSlideView> <VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</div> </RouterLink>
<div v-if="mediaDetail.tmdb_id"> </div>
<MediaCardSlideView :apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"> </template>
<template #title="{ loaded }"> </PersonCardSlideView>
<div v-if="loaded" class="slider-header mt-3 ms-1"> </div>
<RouterLink to="" class="slider-title"> <div v-if="mediaDetail.tmdb_id">
<span>类似</span> <MediaCardSlideView :apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`">
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <template #title="{ loaded }">
</RouterLink> <div v-if="loaded" class="slider-header mt-3 ms-1">
</div> <RouterLink to="" class="slider-title">
</template> <span>推荐</span>
</MediaCardSlideView> <VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
</template>
</MediaCardSlideView>
</div>
<div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView :apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`">
<template #title="{ loaded }">
<div v-if="loaded" class="slider-header mt-3 ms-1">
<RouterLink to="" class="slider-title">
<span>类似</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
</template>
</MediaCardSlideView>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -128,13 +130,6 @@ onMounted(() => {
inset: 0; inset: 0;
} }
@media (min-width: 1280px) {
.media-header {
flex-direction: row;
align-items: flex-end;
}
}
.media-header { .media-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -142,12 +137,35 @@ onMounted(() => {
padding-block-start: 1rem; padding-block-start: 1rem;
} }
@media (min-width: 1280px) {
.media-header {
flex-direction: row;
align-items: flex-end;
}
}
.media-overview {
display: flex;
flex-direction: column;
padding-top: 2rem;
padding-bottom: 1rem;
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.media-overview { .media-overview {
flex-direction: row; flex-direction: row;
} }
} }
.media-poster {
width: 8rem;
overflow: hidden;
border-radius: .25rem;
--tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, .1), 0 1px 2px -1px rgba(0, 0, 0, .1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
@media (min-width: 1280px) { @media (min-width: 1280px) {
.media-poster { .media-poster {
margin-right: 1rem; margin-right: 1rem;
@@ -165,13 +183,12 @@ onMounted(() => {
} }
} }
.media-poster { .media-title {
width: 8rem; margin-top: 1rem;
overflow: hidden; display: flex;
border-radius: .25rem; flex: 1 1 0%;
--tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, .1), 0 1px 2px -1px rgba(0, 0, 0, .1); flex-direction: column;
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); text-align: center;
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
@@ -182,22 +199,24 @@ onMounted(() => {
} }
} }
.media-title { .media-title>h1 {
margin-top: 1rem; font-size: 1.5rem;
display: flex; line-height: 2rem;
flex: 1 1 0%; font-weight: 700;
flex-direction: column;
text-align: center;
--tw-text-opacity: 1;
color: rgb(255 255 255/var(--tw-text-opacity));
} }
@media (min-width: 640px) { @media (min-width: 1280px) {
ul.media-crew { .media-title>h1 {
grid-template-columns: repeat(3,minmax(0,1fr)); font-size: 2.25rem;
line-height: 2.5rem;
} }
} }
h1 .media-year {
font-size: 1.5rem;
line-height: 2rem;
}
ul.media-crew { ul.media-crew {
margin-top: 1.5rem; margin-top: 1.5rem;
display: grid; display: grid;
@@ -205,10 +224,24 @@ ul.media-crew {
gap: 1.5rem; gap: 1.5rem;
} }
@media (min-width: 640px) {
ul.media-crew {
grid-template-columns: repeat(3,minmax(0,1fr));
}
}
.media-status { .media-status {
margin-bottom: .5rem; margin-bottom: .5rem;
} }
.media-attributes {
margin-top: .25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
@media (min-width: 1280px) { @media (min-width: 1280px) {
.media-attributes { .media-attributes {
margin-top: 0; margin-top: 0;
@@ -225,16 +258,11 @@ ul.media-crew {
} }
} }
.media-attributes { .media-actions {
font-size: .75rem; position: relative;
line-height: 1rem; margin-top: 1rem;
--tw-text-opacity: 1;
color: rgb(209 213 219/var(--tw-text-opacity));
}
.media-attributes {
margin-top: .25rem;
display: flex; display: flex;
flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -253,23 +281,8 @@ ul.media-crew {
} }
} }
.media-actions { .media-overview-left {
position: relative; flex: 1 1 0%;
margin-top: 1rem;
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.media-overview {
display: flex;
flex-direction: column;
padding-top: 2rem;
padding-bottom: 1rem;
--tw-text-opacity: 1;
color: rgb(255 255 255/var(--tw-text-opacity));
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -278,8 +291,9 @@ ul.media-crew {
} }
} }
.media-overview-left { .media-overview-right {
flex: 1 1 0%; margin-top: 2rem;
width: 100%;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -289,8 +303,23 @@ ul.media-crew {
} }
} }
.media-overview-right { .slider-title {
margin-top: 2rem; display: inline-flex;
width: 100%; align-items: center;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.75rem;
--tw-text-opacity: 1;
color: rgb(209 213 219/var(--tw-text-opacity));
}
@media (min-width: 640px){
.slider-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.5rem;
line-height: 2.25rem;
}
} }
</style> </style>
+152
View File
@@ -0,0 +1,152 @@
<script lang="ts" setup>
import api from '@/api'
import type { TmdbPerson } from '@/api/types'
import PersonCard from '@/components/cards/PersonCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
// 输入参数
const props = defineProps({
apipath: String,
})
// 判断是否有滚动条
function hasScroll() {
return (
document.body.scrollHeight
- (window.innerHeight || document.documentElement.clientHeight)
> 2
)
}
// 当前页码
const page = ref(1)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 数据列表
const dataList = ref<TmdbPerson[]>([])
const currData = ref<TmdbPerson[]>([])
// 获取列表数据
async function fetchData({ done }: { done: any }) {
try {
if (!props.apipath)
return
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
// 设置加载中
loading.value = true
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 请求API
currData.value = await api.get(props.apipath)
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('ok')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
}
}
else {
// 加载一次
// 请求API
currData.value = await api.get(props.apipath)
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('ok')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
}
// 取消加载中
loading.value = false
// 返回加载成功
done('ok')
}
catch (error) {
console.error(error)
// 返回加载失败
done('error')
}
}
</script>
<template>
<div
v-if="!isRefreshed"
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
>
<VProgressCircular
size="48"
indeterminate
color="primary"
/>
</div>
<VInfiniteScroll
mode="intersect"
side="end"
:items="dataList"
class="overflow-hidden"
@load="fetchData"
>
<template #loading />
<div
v-if="dataList.length > 0"
class="grid gap-4 grid-media-card mx-3"
tabindex="0"
>
<PersonCard
v-for="data in dataList"
:key="data.id"
:media="data"
/>
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="500"
error-title="出错啦"
error-description="无法获取到媒体信息请检查网络连接"
/>
</VInfiniteScroll>
</template>
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
</style>
@@ -0,0 +1,87 @@
<script lang="ts" setup>
import PersionCard from '@/components/cards/PersonCard.vue'
import api from '@/api'
import type { TmdbPerson } from '@/api/types'
// 输入参数
const props = defineProps({
apipath: String,
})
// 组件加载完成
const componentLoaded = ref(false)
// 数据列表
const dataList = ref<TmdbPerson[]>([])
// 获取订阅列表数据
async function fetchData() {
try {
if (!props.apipath)
return
dataList.value = await api.get(props.apipath)
componentLoaded.value = true
}
catch (error) {
console.error(error)
}
}
// 加载时获取数据
onMounted(fetchData)
</script>
<template>
<slot
name="title"
:loaded="componentLoaded"
/>
<VSlideGroup show-arrows="false">
<template #prev>
<VBtn
class="rounded-circle shadow-none"
icon="mdi-chevron-left"
color="grey"
/>
</template>
<VSlideGroupItem
v-for="data in dataList"
:key="data.id"
>
<PersionCard
:key="data.id"
:person="data"
height="15rem"
width="10rem"
/>
</VSlideGroupItem>
<template #next>
<VBtn
class="rounded-circle shadow-none"
icon="mdi-chevron-right"
color="grey"
/>
</template>
</VSlideGroup>
</template>
<style lang="scss">
.v-slide-group .v-card {
@apply m-2;
}
.v-slide-group__prev {
@apply absolute right-11;
z-index: 3;
margin-block-start: -40px;
}
.v-slide-group__next {
@apply absolute right-1;
z-index: 3;
margin-block-start: -40px;
}
</style>