七十二家房客-观看平台v1.6
This commit is contained in:
parent
c37649db5d
commit
96a7dae310
@ -1,6 +1,6 @@
|
||||
{
|
||||
"year": 2024,
|
||||
"updatedAt": "2026-03-10T21:35:04.482Z",
|
||||
"updatedAt": "2026-03-12T04:57:57.972Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "7f012566d7827b5046de9f92a4d7e159",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"year": 2025,
|
||||
"updatedAt": "2026-03-10T21:34:22.356Z",
|
||||
"updatedAt": "2026-03-12T04:57:16.089Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "b2b4f80b846360647d13e297c029be8d",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"year": 2026,
|
||||
"updatedAt": "2026-03-10T21:33:31.594Z",
|
||||
"updatedAt": "2026-03-12T04:56:24.101Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "5abbc88ea530b5db6438a4e584e80281",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"running": false,
|
||||
"lastMessage": "自动采集完成",
|
||||
"updatedAt": "2026-03-10T21:35:26.989Z"
|
||||
"updatedAt": "2026-03-12T05:01:28.586Z"
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"artplayer": "^5.3.0",
|
||||
"axios": "^1.13.6",
|
||||
"bootstrap": "^5.3.8",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"hls.js": "^1.6.15",
|
||||
|
||||
@ -9,25 +9,18 @@ export interface EpisodeData {
|
||||
videoUrl: string
|
||||
}
|
||||
|
||||
export interface QishierListResponse {
|
||||
export interface QishierAllResponse {
|
||||
updatedAt: string | null
|
||||
name: string
|
||||
coverUrl: string
|
||||
displayType: number
|
||||
total: number
|
||||
years: number[]
|
||||
page: number
|
||||
pageSize: number
|
||||
hasMore: boolean
|
||||
list: EpisodeData[]
|
||||
}
|
||||
|
||||
export function getQishierList(page = 1, pageSize = 50) {
|
||||
return axios.get<QishierListResponse>('/api/qishier/list', {
|
||||
params: {
|
||||
page,
|
||||
pageSize
|
||||
},
|
||||
export function getAllQishierList() {
|
||||
return axios.get<QishierAllResponse>('/api/qishier/all', {
|
||||
timeout: 15000
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,45 +1,115 @@
|
||||
<template>
|
||||
<div ref="listPanelRef" class="list-panel">
|
||||
<div
|
||||
v-for="item in episodes"
|
||||
:key="item.id"
|
||||
:data-episode-id="item.id"
|
||||
class="episode-item"
|
||||
:class="{ active: currentEpisodeId === item.id }"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<img :src="item.coverUrl" alt="" loading="lazy" />
|
||||
<div class="episode-text">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="meta">{{ formatDate(item.releasedAt) }}</div>
|
||||
<div class="episode-card card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-transparent border-0 pt-3 pb-2 px-3">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<div class="fw-bold text-dark">剧集列表</div>
|
||||
|
||||
<select v-model="innerYear" class="form-select form-select-sm year-select">
|
||||
<option value="all">全部年份</option>
|
||||
<option v-for="year in years" :key="year" :value="String(year)">
|
||||
{{ year }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="load-more-wrap" v-if="hasMore">
|
||||
<button class="load-more-btn" :disabled="loadingMore" @click="$emit('loadMore')">
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
<div ref="listPanelRef" class="card-body pt-2 episode-panel">
|
||||
<button
|
||||
v-for="item in pagedEpisodes"
|
||||
:key="item.id"
|
||||
:data-episode-id="item.id"
|
||||
type="button"
|
||||
class="episode-row btn w-100 text-start mb-2"
|
||||
:class="currentEpisodeId === item.id ? 'btn-warning current-row' : 'btn-light'"
|
||||
@click="handleSelect(item, $event)"
|
||||
>
|
||||
<div class="episode-date mb-1">{{ formatDate(item.releasedAt) }}</div>
|
||||
<div class="episode-title">{{ item.title }}</div>
|
||||
</button>
|
||||
|
||||
<div v-if="pagedEpisodes.length === 0" class="empty-tip text-center py-4">
|
||||
当前筛选没有数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent border-0 px-3 pb-3 pt-1">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="small text-secondary">
|
||||
共 {{ filteredEpisodes.length }} 条,当前第 {{ currentPage }} / {{ totalPages || 1 }} 页
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="prevPage">
|
||||
上一页
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" :disabled="currentPage >= totalPages" @click="nextPage">
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { EpisodeData } from '@/api/qishier'
|
||||
|
||||
const props = defineProps<{
|
||||
episodes: EpisodeData[]
|
||||
currentEpisodeId: string
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
years: number[]
|
||||
selectedYear: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
select: [episode: EpisodeData]
|
||||
loadMore: []
|
||||
updateYear: [year: string]
|
||||
}>()
|
||||
|
||||
const listPanelRef = ref<HTMLElement | null>(null)
|
||||
const innerYear = ref(props.selectedYear || 'all')
|
||||
const pageSize = 20
|
||||
const currentPage = ref(1)
|
||||
|
||||
watch(
|
||||
() => props.selectedYear,
|
||||
(val) => {
|
||||
const nextVal = val || 'all'
|
||||
if (innerYear.value !== nextVal) {
|
||||
innerYear.value = nextVal
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(innerYear, (val) => {
|
||||
if (val === props.selectedYear) return
|
||||
currentPage.value = 1
|
||||
emit('updateYear', val)
|
||||
})
|
||||
|
||||
const filteredEpisodes = computed(() => {
|
||||
if (innerYear.value === 'all') return props.episodes
|
||||
const year = Number(innerYear.value)
|
||||
return props.episodes.filter(item => new Date(item.releasedAt).getFullYear() === year)
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredEpisodes.value.length / pageSize)
|
||||
})
|
||||
|
||||
const pagedEpisodes = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return filteredEpisodes.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) currentPage.value--
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) currentPage.value++
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
if (!timestamp) return '--'
|
||||
@ -50,11 +120,29 @@ function formatDate(timestamp: number): string {
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
async function scrollToActiveEpisode() {
|
||||
await nextTick()
|
||||
function isMobileDevice() {
|
||||
return window.innerWidth < 992
|
||||
}
|
||||
|
||||
function handleSelect(item: EpisodeData, event: MouseEvent) {
|
||||
;(event.currentTarget as HTMLButtonElement | null)?.blur()
|
||||
emit('select', item)
|
||||
}
|
||||
async function locateCurrentEpisode() {
|
||||
if (!props.currentEpisodeId) return
|
||||
|
||||
const fullList = filteredEpisodes.value
|
||||
const idx = fullList.findIndex(item => item.id === props.currentEpisodeId)
|
||||
if (idx < 0) return
|
||||
|
||||
const targetPage = Math.floor(idx / pageSize) + 1
|
||||
if (currentPage.value !== targetPage) {
|
||||
currentPage.value = targetPage
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const panel = listPanelRef.value
|
||||
if (!panel || !props.currentEpisodeId) return
|
||||
if (!panel) return
|
||||
|
||||
const activeEl = panel.querySelector(
|
||||
`[data-episode-id="${props.currentEpisodeId}"]`
|
||||
@ -62,24 +150,31 @@ async function scrollToActiveEpisode() {
|
||||
|
||||
if (!activeEl) return
|
||||
|
||||
const panelRect = panel.getBoundingClientRect()
|
||||
const itemRect = activeEl.getBoundingClientRect()
|
||||
// 手机端不自动滚整个页面
|
||||
if (isMobileDevice()) {
|
||||
const panelTop = panel.scrollTop
|
||||
const itemTop = activeEl.offsetTop
|
||||
const itemHeight = activeEl.offsetHeight
|
||||
const panelHeight = panel.clientHeight
|
||||
|
||||
const isAbove = itemRect.top < panelRect.top
|
||||
const isBelow = itemRect.bottom > panelRect.bottom
|
||||
|
||||
if (isAbove || isBelow) {
|
||||
activeEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
panel.scrollTo({
|
||||
top: itemTop - panelHeight / 2 + itemHeight / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
activeEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.currentEpisodeId,
|
||||
async () => {
|
||||
await scrollToActiveEpisode()
|
||||
await locateCurrentEpisode()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@ -87,92 +182,62 @@ watch(
|
||||
watch(
|
||||
() => props.episodes.length,
|
||||
async () => {
|
||||
await scrollToActiveEpisode()
|
||||
await locateCurrentEpisode()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedYear,
|
||||
async () => {
|
||||
await locateCurrentEpisode()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-panel {
|
||||
max-height: 80vh;
|
||||
.episode-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.episode-panel {
|
||||
max-height: 72vh;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background: #1b1b1b;
|
||||
border-radius: 12px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.episode-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
transition: 0.2s;
|
||||
border: 1px solid transparent;
|
||||
.episode-row {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 12px 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.episode-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
.episode-row:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.episode-item.active {
|
||||
background: rgba(255, 193, 7, 0.16);
|
||||
border-color: rgba(255, 193, 7, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.12) inset;
|
||||
.current-row {
|
||||
box-shadow: 0 8px 20px rgba(255, 193, 7, 0.22);
|
||||
}
|
||||
|
||||
.episode-item img {
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
.episode-date {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.episode-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
.episode-title {
|
||||
line-height: 1.45;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.episode-item.active .title {
|
||||
color: #ffd666;
|
||||
.year-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.load-more-wrap {
|
||||
padding: 12px 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-more-btn:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.load-more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
.empty-tip {
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
|
||||
155
src/components/qishier/QishierProjectLinks.vue
Normal file
155
src/components/qishier/QishierProjectLinks.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<section class="project-links-section">
|
||||
<div class="project-links-card">
|
||||
<div class="project-links-left">
|
||||
<div class="project-links-badge">项目源码</div>
|
||||
<h4 class="project-links-title">72QishierPlayer 开源仓库</h4>
|
||||
<p class="project-links-desc">
|
||||
包含前端播放器、后端采集服务、数据缓存、播放记录与剧集列表逻辑,方便部署与二次开发。
|
||||
</p>
|
||||
|
||||
<div class="project-links-url">
|
||||
<span class="label">仓库地址:</span>
|
||||
<a
|
||||
:href="gitUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="git-url-link"
|
||||
>
|
||||
{{ gitUrl }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="project-links-code">
|
||||
<code>git clone {{ gitUrl }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-links-right">
|
||||
<a
|
||||
class="btn btn-dark btn-sm px-3"
|
||||
:href="gitUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
打开仓库
|
||||
</a>
|
||||
|
||||
<button class="btn btn-outline-secondary btn-sm px-3" @click="copyGitUrl">
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const gitUrl = 'https://git.a-hxin.cn/ahxin/72QishierPlayer.git'
|
||||
|
||||
async function copyGitUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(gitUrl)
|
||||
alert('Git 仓库链接已复制')
|
||||
} catch {
|
||||
alert('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-links-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.project-links-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f7f8fa 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.project-links-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-links-badge {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #0d6efd;
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
|
||||
.project-links-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.project-links-desc {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.project-links-url {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.project-links-url .label {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.git-url-link {
|
||||
color: #0d6efd;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.git-url-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.project-links-code {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #f1f3f5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.project-links-code code {
|
||||
color: #212529;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-links-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.project-links-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.project-links-right {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
362
src/components/qishier/QishierShowcase.vue
Normal file
362
src/components/qishier/QishierShowcase.vue
Normal file
@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<section class="showcase-section" v-if="episodes.length > 0">
|
||||
<div class="section-head d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="section-title mb-1">推荐展示</h4>
|
||||
<div class="section-subtitle">基于当前节目数据生成展示内容</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary d-none d-lg-inline-flex"
|
||||
v-if="featuredEpisode"
|
||||
@click="$emit('select', featuredEpisode)"
|
||||
>
|
||||
立即播放
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 电脑端 -->
|
||||
<div class="desktop-showcase d-none d-lg-block">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-8" v-if="featuredEpisode">
|
||||
<div class="featured-card" @click="$emit('select', featuredEpisode)">
|
||||
<div
|
||||
class="featured-cover"
|
||||
:style="coverStyle(featuredEpisode.coverUrl)"
|
||||
></div>
|
||||
|
||||
<div class="featured-content">
|
||||
<div class="featured-badge">精选推荐</div>
|
||||
<h3 class="featured-title">{{ featuredEpisode.title }}</h3>
|
||||
<p class="featured-desc">
|
||||
发布时间:{{ formatDate(featuredEpisode.releasedAt) }}
|
||||
<span class="mx-2">|</span>
|
||||
时长:{{ formatDuration(featuredEpisode.timeLength) }}
|
||||
</p>
|
||||
<div class="featured-meta">
|
||||
<span>自动播放</span>
|
||||
<span>支持续播</span>
|
||||
<span>高清资源</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="side-panel">
|
||||
<div
|
||||
v-for="item in sideList"
|
||||
:key="item.id"
|
||||
class="side-item"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<div
|
||||
class="side-thumb"
|
||||
:style="coverStyle(item.coverUrl)"
|
||||
></div>
|
||||
<div class="side-text">
|
||||
<div class="side-item-title">{{ item.title }}</div>
|
||||
<div class="side-item-meta">
|
||||
{{ formatDate(item.releasedAt) }} | {{ formatDuration(item.timeLength) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="row g-3">
|
||||
<div
|
||||
v-for="item in bottomList"
|
||||
:key="item.id"
|
||||
class="col-md-6 col-xl-3"
|
||||
>
|
||||
<div class="mini-card" @click="$emit('select', item)">
|
||||
<div
|
||||
class="mini-cover"
|
||||
:style="coverStyle(item.coverUrl)"
|
||||
></div>
|
||||
<div class="mini-title">{{ item.title }}</div>
|
||||
<div class="mini-meta">
|
||||
{{ formatDate(item.releasedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手机端 -->
|
||||
<div class="mobile-showcase d-lg-none">
|
||||
<div class="mobile-grid">
|
||||
<div
|
||||
v-for="item in mobileList"
|
||||
:key="item.id"
|
||||
class="mobile-card"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<div
|
||||
class="mobile-cover"
|
||||
:style="coverStyle(item.coverUrl)"
|
||||
></div>
|
||||
<div class="mobile-title">{{ item.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { EpisodeData } from '@/api/qishier'
|
||||
|
||||
const props = defineProps<{
|
||||
episodes: EpisodeData[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [episode: EpisodeData]
|
||||
}>()
|
||||
|
||||
const featuredEpisode = computed(() => props.episodes[0] || null)
|
||||
const sideList = computed(() => props.episodes.slice(1, 4))
|
||||
const bottomList = computed(() => props.episodes.slice(4, 8))
|
||||
const mobileList = computed(() => props.episodes.slice(0, 6))
|
||||
|
||||
function coverStyle(url?: string) {
|
||||
if (!url) {
|
||||
return {
|
||||
background: '#dcdcdc'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
if (!timestamp) return '--'
|
||||
const d = new Date(timestamp)
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds && seconds !== 0) return '--'
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.showcase-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.featured-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 20px;
|
||||
min-height: 280px;
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f5f7fa 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.featured-cover,
|
||||
.side-thumb,
|
||||
.mini-cover,
|
||||
.mobile-cover {
|
||||
background-color: #dcdcdc;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.featured-cover {
|
||||
border-radius: 18px;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.featured-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.featured-badge {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #8a5a00;
|
||||
background: rgba(255, 193, 7, 0.18);
|
||||
}
|
||||
|
||||
.featured-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 28px;
|
||||
line-height: 1.35;
|
||||
font-weight: 800;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.featured-desc {
|
||||
margin-bottom: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.featured-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.featured-meta span {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
background: #eef1f4;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f7f8fa 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.side-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.side-item + .side-item {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.side-thumb {
|
||||
width: 110px;
|
||||
height: 66px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.side-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.side-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.side-item-meta {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fb 100%);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.05);
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mini-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.mini-meta {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* 手机端 */
|
||||
.mobile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mobile-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
color: #3a3a3a;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.showcase-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.mobile-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -11,6 +11,8 @@ import Hls from 'hls.js'
|
||||
import type { EpisodeData } from '@/api/qishier'
|
||||
import { getEpisodeProgress, saveEpisodeProgress } from '@/utils/playHistory'
|
||||
|
||||
|
||||
|
||||
type ArtWithHls = Artplayer & {
|
||||
hls?: Hls
|
||||
}
|
||||
@ -27,8 +29,18 @@ const emit = defineEmits<{
|
||||
|
||||
const playerRef = ref<HTMLDivElement | null>(null)
|
||||
let art: ArtWithHls | null = null
|
||||
let playRetryTimer: number | null = null
|
||||
|
||||
function clearPlayRetryTimer() {
|
||||
if (playRetryTimer !== null) {
|
||||
window.clearTimeout(playRetryTimer)
|
||||
playRetryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function destroyPlayer() {
|
||||
clearPlayRetryTimer()
|
||||
|
||||
if (art?.hls) {
|
||||
art.hls.destroy()
|
||||
art.hls = undefined
|
||||
@ -44,6 +56,16 @@ function destroyPlayer() {
|
||||
}
|
||||
}
|
||||
|
||||
function safeAutoPlay() {
|
||||
clearPlayRetryTimer()
|
||||
|
||||
playRetryTimer = window.setTimeout(() => {
|
||||
void art?.video?.play().catch(() => {
|
||||
// 某些浏览器自动播放会被拦截,这里静默处理
|
||||
})
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function buildPlayer(episode: EpisodeData) {
|
||||
if (!playerRef.value) return
|
||||
|
||||
@ -58,23 +80,56 @@ function buildPlayer(episode: EpisodeData) {
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
hotkey: true,
|
||||
|
||||
autoHide: 2000,
|
||||
|
||||
mutex: true,
|
||||
theme: '#f5c542',
|
||||
moreVideoAttr: {
|
||||
playsInline: true
|
||||
playsInline: true,
|
||||
preload: 'auto'
|
||||
} as any,
|
||||
customType: {
|
||||
m3u8(video: HTMLVideoElement, url: string, artInstance: Artplayer) {
|
||||
const artplayer = artInstance as ArtWithHls
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls()
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
backBufferLength: 30,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
maxBufferHole: 1,
|
||||
highBufferWatchdogPeriod: 2,
|
||||
nudgeOffset: 0.1,
|
||||
nudgeMaxRetry: 5
|
||||
})
|
||||
|
||||
artplayer.hls = hls
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(video)
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
console.error('HLS Error:', data)
|
||||
|
||||
if (!data.fatal) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
console.warn('HLS NETWORK_ERROR,尝试恢复')
|
||||
hls.startLoad()
|
||||
emit('error', '网络异常,正在尝试恢复')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
console.warn('HLS MEDIA_ERROR,尝试恢复')
|
||||
hls.recoverMediaError()
|
||||
emit('error', '播放异常,正在尝试恢复')
|
||||
return
|
||||
}
|
||||
|
||||
emit('error', '视频播放失败')
|
||||
})
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url
|
||||
} else {
|
||||
@ -86,7 +141,7 @@ function buildPlayer(episode: EpisodeData) {
|
||||
|
||||
art.type = 'm3u8'
|
||||
|
||||
const restore = () => {
|
||||
const restoreProgress = () => {
|
||||
const progress = getEpisodeProgress(episode.id)
|
||||
if (progress > 0 && art?.video) {
|
||||
try {
|
||||
@ -95,9 +150,21 @@ function buildPlayer(episode: EpisodeData) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
safeAutoPlay()
|
||||
}
|
||||
|
||||
art.on('video:loadedmetadata', restore)
|
||||
art.on('video:loadedmetadata', restoreProgress)
|
||||
|
||||
art.on('video:canplay', () => {
|
||||
// 某些设备 loadedmetadata 后还没准备好,canplay 时再兜底一次
|
||||
safeAutoPlay()
|
||||
})
|
||||
|
||||
art.on('video:waiting', () => {
|
||||
// 这里不报错,只作为缓冲状态
|
||||
console.warn('视频缓冲中...')
|
||||
})
|
||||
|
||||
art.on('video:timeupdate', () => {
|
||||
if (art?.video) {
|
||||
@ -135,7 +202,7 @@ watch(
|
||||
watch(
|
||||
() => props.autoNext,
|
||||
() => {
|
||||
// 让 ended 事件读取最新 autoNext 值
|
||||
// 保持响应式依赖
|
||||
}
|
||||
)
|
||||
|
||||
@ -152,6 +219,8 @@ onBeforeUnmount(() => {
|
||||
.player-wrap {
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.artplayer-container {
|
||||
@ -164,4 +233,8 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.art-video-player) {
|
||||
background: #000;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
|
||||
@ -1,97 +1,117 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>{{ columnInfo.name || '七十二家房客播放器' }}</h1>
|
||||
<p>共 {{ total }} 条,当前显示 {{ episodeList.length }} 条</p>
|
||||
<p class="sub">最后更新:{{ updatedAtText }}</p>
|
||||
<div class="container-fluid qishier-page py-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="top-card card border-0 shadow-sm">
|
||||
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<h2 class="mb-2 page-title">{{ columnInfo.name || '七十二家房客播放器' }}</h2>
|
||||
<div class="page-subtitle">
|
||||
共 {{ allEpisodeList.length }} 条 | 最后更新:{{ updatedAtText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-dark btn-sm px-3" @click="handleRefresh">刷新数据</button>
|
||||
<button class="btn btn-warning btn-sm px-3" @click="openSource">数据来源:荔枝网</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="action-btn" @click="handleRefresh">刷新数据</button>
|
||||
<button class="action-btn source-btn" @click="openSource">
|
||||
数据来源:荔枝网
|
||||
</button>
|
||||
<div class="col-12">
|
||||
<div class="toolbar-card card border-0 shadow-sm">
|
||||
<div class="card-body d-flex flex-wrap gap-2 align-items-center">
|
||||
<button class="btn btn-outline-dark btn-sm" @click="playPrevEpisode" :disabled="!hasPrevEpisode">
|
||||
上一集
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-dark btn-sm" @click="playNextEpisode" :disabled="!hasNextEpisode">
|
||||
下一集
|
||||
</button>
|
||||
|
||||
<div class="form-check form-switch ms-2">
|
||||
<input id="autoNext" v-model="autoNext" class="form-check-input" type="checkbox" />
|
||||
<label class="form-check-label" for="autoNext">自动下一集</label>
|
||||
</div>
|
||||
|
||||
<select v-model="playMode" class="form-select form-select-sm mode-select">
|
||||
<option value="asc">顺序播放</option>
|
||||
<option value="desc">倒序播放</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="action-btn small-btn" @click="playPrevEpisode" :disabled="!hasPrevEpisode">
|
||||
上一集
|
||||
</button>
|
||||
<div class="col-xl-8 col-lg-7">
|
||||
<div class="content-card card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<QishierVideoPlayer
|
||||
:episode="currentEpisode"
|
||||
:auto-next="autoNext"
|
||||
@error="handlePlayerError"
|
||||
@ended-next="handleEndedNext"
|
||||
/>
|
||||
|
||||
<button class="action-btn small-btn" @click="playNextEpisode" :disabled="!hasNextEpisode && !hasMore">
|
||||
下一集
|
||||
</button>
|
||||
<div v-if="currentEpisode" class="episode-info mt-3">
|
||||
<div class="episode-badge mb-2">当前播放</div>
|
||||
<h4 class="mb-2 episode-title">{{ currentEpisode.title }}</h4>
|
||||
<div class="episode-meta">
|
||||
发布时间:{{ formatDate(currentEpisode.releasedAt) }}
|
||||
<span class="mx-2">|</span>
|
||||
时长:{{ formatDuration(currentEpisode.timeLength) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="switch-item">
|
||||
<input v-model="autoNext" type="checkbox" />
|
||||
<span>自动下一集</span>
|
||||
</label>
|
||||
<div v-if="errorMsg" class="alert alert-danger mt-3 mb-0">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<select v-model="playMode" class="mode-select">
|
||||
<option value="asc">顺序</option>
|
||||
<option value="desc">倒序</option>
|
||||
</select>
|
||||
</div>
|
||||
<QishierShowcase
|
||||
v-if="showcaseEpisodes.length > 0"
|
||||
:episodes="showcaseEpisodes"
|
||||
@select="playEpisode"
|
||||
/>
|
||||
|
||||
<div class="layout">
|
||||
<div class="player-panel">
|
||||
<QishierVideoPlayer
|
||||
:episode="currentEpisode"
|
||||
:auto-next="autoNext"
|
||||
@error="handlePlayerError"
|
||||
@ended-next="handleEndedNext"
|
||||
<QishierProjectLinks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4 col-lg-5">
|
||||
<QishierEpisodeList
|
||||
:episodes="displayEpisodeList"
|
||||
:current-episode-id="currentEpisode?.id || ''"
|
||||
:years="years"
|
||||
:selected-year="selectedYear"
|
||||
@select="playEpisode"
|
||||
@updateYear="handleYearChange"
|
||||
/>
|
||||
|
||||
<div v-if="currentEpisode" class="info">
|
||||
<h2>{{ currentEpisode.title }}</h2>
|
||||
<p>
|
||||
发布时间:{{ formatDate(currentEpisode.releasedAt) }}
|
||||
| 时长:{{ formatDuration(currentEpisode.timeLength) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="error">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QishierEpisodeList
|
||||
:episodes="displayEpisodeList"
|
||||
:current-episode-id="currentEpisode?.id || ''"
|
||||
:has-more="hasMore"
|
||||
:loading-more="loadingMore"
|
||||
@select="playEpisode"
|
||||
@load-more="loadMoreEpisodes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { getQishierList, refreshQishierCache, type EpisodeData } from '@/api/qishier'
|
||||
import { getAllQishierList, refreshQishierCache, type EpisodeData } from '@/api/qishier'
|
||||
import { getCurrentEpisodeId, saveCurrentEpisodeId } from '@/utils/playHistory'
|
||||
import QishierVideoPlayer from '@/components/qishier/QishierVideoPlayer.vue'
|
||||
import QishierEpisodeList from '@/components/qishier/QishierEpisodeList.vue'
|
||||
import QishierShowcase from '@/components/qishier/QishierShowcase.vue'
|
||||
import QishierProjectLinks from '@/components/qishier/QishierProjectLinks.vue'
|
||||
|
||||
const columnInfo = reactive({
|
||||
name: '',
|
||||
coverUrl: ''
|
||||
})
|
||||
|
||||
const episodeList = ref<EpisodeData[]>([])
|
||||
const allEpisodeList = ref<EpisodeData[]>([])
|
||||
const currentEpisode = ref<EpisodeData | null>(null)
|
||||
const errorMsg = ref('')
|
||||
const updatedAt = ref<string | null>(null)
|
||||
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(30)
|
||||
const hasMore = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const years = ref<number[]>([])
|
||||
const selectedYear = ref('all')
|
||||
|
||||
const autoNext = ref(true)
|
||||
const playMode = ref<'asc' | 'desc'>('asc')
|
||||
@ -102,10 +122,23 @@ const updatedAtText = computed(() => {
|
||||
})
|
||||
|
||||
const displayEpisodeList = computed(() => {
|
||||
if (playMode.value === 'desc') {
|
||||
return [...episodeList.value].reverse()
|
||||
let list = [...allEpisodeList.value]
|
||||
|
||||
if (selectedYear.value !== 'all') {
|
||||
const year = Number(selectedYear.value)
|
||||
list = list.filter(item => new Date(item.releasedAt).getFullYear() === year)
|
||||
}
|
||||
return episodeList.value
|
||||
|
||||
if (playMode.value === 'desc') {
|
||||
list.reverse()
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const showcaseEpisodes = computed(() => {
|
||||
const list = displayEpisodeList.value.length > 0 ? displayEpisodeList.value : allEpisodeList.value
|
||||
return list.slice(0, 8)
|
||||
})
|
||||
|
||||
const currentDisplayIndex = computed(() => {
|
||||
@ -126,85 +159,68 @@ function handlePlayerError(message: string) {
|
||||
errorMsg.value = message
|
||||
}
|
||||
|
||||
async function fetchFirstPage(): Promise<void> {
|
||||
try {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
function handleYearChange(year: string) {
|
||||
selectedYear.value = year
|
||||
}
|
||||
|
||||
const res = await getQishierList(1, pageSize.value)
|
||||
function getEpisodeYear(item: EpisodeData) {
|
||||
return String(new Date(item.releasedAt).getFullYear())
|
||||
}
|
||||
|
||||
async function fetchAllEpisodes() {
|
||||
try {
|
||||
errorMsg.value = ''
|
||||
const res = await getAllQishierList()
|
||||
const data = res.data
|
||||
|
||||
columnInfo.name = data.name || '七十二家房客'
|
||||
columnInfo.coverUrl = data.coverUrl || ''
|
||||
updatedAt.value = data.updatedAt || null
|
||||
total.value = data.total || 0
|
||||
currentPage.value = data.page || 1
|
||||
hasMore.value = !!data.hasMore
|
||||
episodeList.value = Array.isArray(data.list) ? data.list : []
|
||||
years.value = Array.isArray(data.years) ? [...data.years].sort((a, b) => b - a) : []
|
||||
allEpisodeList.value = Array.isArray(data.list) ? data.list : []
|
||||
|
||||
if (episodeList.value.length > 0) {
|
||||
const savedEpisodeId = getCurrentEpisodeId()
|
||||
const savedEpisode = episodeList.value.find(item => item.id === savedEpisodeId)
|
||||
const firstEpisode = displayEpisodeList.value[0]
|
||||
|
||||
if (savedEpisode) {
|
||||
await playEpisode(savedEpisode)
|
||||
} else if (firstEpisode) {
|
||||
await playEpisode(firstEpisode)
|
||||
}
|
||||
} else {
|
||||
if (allEpisodeList.value.length === 0) {
|
||||
errorMsg.value = '暂无节目数据'
|
||||
return
|
||||
}
|
||||
|
||||
const savedEpisodeId = getCurrentEpisodeId()
|
||||
const savedEpisode = allEpisodeList.value.find(item => item.id === savedEpisodeId)
|
||||
|
||||
if (savedEpisode) {
|
||||
selectedYear.value = getEpisodeYear(savedEpisode)
|
||||
currentEpisode.value = savedEpisode
|
||||
return
|
||||
}
|
||||
|
||||
const firstEpisode = allEpisodeList.value[0]
|
||||
if (firstEpisode) {
|
||||
selectedYear.value = getEpisodeYear(firstEpisode)
|
||||
currentEpisode.value = firstEpisode
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('fetchFirstPage error:', error)
|
||||
console.error('fetchAllEpisodes error:', error)
|
||||
errorMsg.value = error?.message || '读取数据失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreEpisodes(): Promise<void> {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
|
||||
try {
|
||||
loadingMore.value = true
|
||||
const nextPage = currentPage.value + 1
|
||||
const res = await getQishierList(nextPage, pageSize.value)
|
||||
const data = res.data
|
||||
|
||||
const oldIds = new Set(episodeList.value.map(item => item.id))
|
||||
const newItems = (data.list || []).filter(item => !oldIds.has(item.id))
|
||||
|
||||
episodeList.value.push(...newItems)
|
||||
currentPage.value = data.page || nextPage
|
||||
hasMore.value = !!data.hasMore
|
||||
total.value = data.total || total.value
|
||||
updatedAt.value = data.updatedAt || updatedAt.value
|
||||
} catch (error: any) {
|
||||
console.error('loadMoreEpisodes error:', error)
|
||||
errorMsg.value = error?.message || '加载更多失败'
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
async function handleRefresh() {
|
||||
try {
|
||||
await refreshQishierCache()
|
||||
await fetchFirstPage()
|
||||
await fetchAllEpisodes()
|
||||
} catch (error: any) {
|
||||
console.error('handleRefresh error:', error)
|
||||
errorMsg.value = error?.message || '刷新失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function playEpisode(item: EpisodeData): Promise<void> {
|
||||
async function playEpisode(item: EpisodeData) {
|
||||
currentEpisode.value = item
|
||||
selectedYear.value = getEpisodeYear(item)
|
||||
errorMsg.value = ''
|
||||
saveCurrentEpisodeId(item.id)
|
||||
}
|
||||
|
||||
async function playPrevEpisode(): Promise<void> {
|
||||
async function playPrevEpisode() {
|
||||
if (!hasPrevEpisode.value) return
|
||||
const prev = displayEpisodeList.value[currentDisplayIndex.value - 1]
|
||||
if (prev) {
|
||||
@ -212,27 +228,18 @@ async function playPrevEpisode(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function playNextEpisode(): Promise<void> {
|
||||
if (hasNextEpisode.value) {
|
||||
const next = displayEpisodeList.value[currentDisplayIndex.value + 1]
|
||||
if (next) {
|
||||
await playEpisode(next)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMore.value) {
|
||||
const oldLength = displayEpisodeList.value.length
|
||||
await loadMoreEpisodes()
|
||||
const next = displayEpisodeList.value[oldLength]
|
||||
if (next) {
|
||||
await playEpisode(next)
|
||||
}
|
||||
async function playNextEpisode() {
|
||||
if (!hasNextEpisode.value) return
|
||||
const next = displayEpisodeList.value[currentDisplayIndex.value + 1]
|
||||
if (next) {
|
||||
await playEpisode(next)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndedNext() {
|
||||
await playNextEpisode()
|
||||
if (autoNext.value) {
|
||||
await playNextEpisode()
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
@ -252,131 +259,62 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchFirstPage()
|
||||
void fetchAllEpisodes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
.qishier-page {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 193, 7, 0.12), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(13, 110, 253, 0.1), transparent 24%),
|
||||
linear-gradient(180deg, #f8f9fa 0%, #eef1f5 100%);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
.top-card,
|
||||
.toolbar-card,
|
||||
.content-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 8px;
|
||||
.page-title {
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0 0 6px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.switch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #ddd;
|
||||
.page-subtitle {
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mode-select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
.episode-info {
|
||||
padding: 2px 2px 6px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #444;
|
||||
.episode-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 193, 7, 0.18);
|
||||
color: #9a6a00;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
.episode-title {
|
||||
color: #212529;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.player-panel {
|
||||
background: #1b1b1b;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.info h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info p {
|
||||
margin: 0;
|
||||
color: #aaa;
|
||||
.episode-meta {
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0 16px 16px;
|
||||
color: #ff7875;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user