七十二家房客-观看平台v1.6
This commit is contained in:
parent
c37649db5d
commit
96a7dae310
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"year": 2024,
|
"year": 2024,
|
||||||
"updatedAt": "2026-03-10T21:35:04.482Z",
|
"updatedAt": "2026-03-12T04:57:57.972Z",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "7f012566d7827b5046de9f92a4d7e159",
|
"id": "7f012566d7827b5046de9f92a4d7e159",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"year": 2025,
|
"year": 2025,
|
||||||
"updatedAt": "2026-03-10T21:34:22.356Z",
|
"updatedAt": "2026-03-12T04:57:16.089Z",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "b2b4f80b846360647d13e297c029be8d",
|
"id": "b2b4f80b846360647d13e297c029be8d",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"year": 2026,
|
"year": 2026,
|
||||||
"updatedAt": "2026-03-10T21:33:31.594Z",
|
"updatedAt": "2026-03-12T04:56:24.101Z",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "5abbc88ea530b5db6438a4e584e80281",
|
"id": "5abbc88ea530b5db6438a4e584e80281",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastMessage": "自动采集完成",
|
"lastMessage": "自动采集完成",
|
||||||
"updatedAt": "2026-03-10T21:35:26.989Z"
|
"updatedAt": "2026-03-12T05:01:28.586Z"
|
||||||
}
|
}
|
||||||
@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"artplayer": "^5.3.0",
|
"artplayer": "^5.3.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
|
|||||||
@ -9,25 +9,18 @@ export interface EpisodeData {
|
|||||||
videoUrl: string
|
videoUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QishierListResponse {
|
export interface QishierAllResponse {
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
name: string
|
name: string
|
||||||
coverUrl: string
|
coverUrl: string
|
||||||
displayType: number
|
displayType: number
|
||||||
total: number
|
total: number
|
||||||
years: number[]
|
years: number[]
|
||||||
page: number
|
|
||||||
pageSize: number
|
|
||||||
hasMore: boolean
|
|
||||||
list: EpisodeData[]
|
list: EpisodeData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getQishierList(page = 1, pageSize = 50) {
|
export function getAllQishierList() {
|
||||||
return axios.get<QishierListResponse>('/api/qishier/list', {
|
return axios.get<QishierAllResponse>('/api/qishier/all', {
|
||||||
params: {
|
|
||||||
page,
|
|
||||||
pageSize
|
|
||||||
},
|
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,45 +1,115 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="listPanelRef" class="list-panel">
|
<div class="episode-card card border-0 shadow-sm h-100">
|
||||||
<div
|
<div class="card-header bg-transparent border-0 pt-3 pb-2 px-3">
|
||||||
v-for="item in episodes"
|
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
:key="item.id"
|
<div class="fw-bold text-dark">剧集列表</div>
|
||||||
:data-episode-id="item.id"
|
|
||||||
class="episode-item"
|
<select v-model="innerYear" class="form-select form-select-sm year-select">
|
||||||
:class="{ active: currentEpisodeId === item.id }"
|
<option value="all">全部年份</option>
|
||||||
@click="$emit('select', item)"
|
<option v-for="year in years" :key="year" :value="String(year)">
|
||||||
>
|
{{ year }}
|
||||||
<img :src="item.coverUrl" alt="" loading="lazy" />
|
</option>
|
||||||
<div class="episode-text">
|
</select>
|
||||||
<div class="title">{{ item.title }}</div>
|
|
||||||
<div class="meta">{{ formatDate(item.releasedAt) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="load-more-wrap" v-if="hasMore">
|
<div ref="listPanelRef" class="card-body pt-2 episode-panel">
|
||||||
<button class="load-more-btn" :disabled="loadingMore" @click="$emit('loadMore')">
|
<button
|
||||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import type { EpisodeData } from '@/api/qishier'
|
import type { EpisodeData } from '@/api/qishier'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
episodes: EpisodeData[]
|
episodes: EpisodeData[]
|
||||||
currentEpisodeId: string
|
currentEpisodeId: string
|
||||||
hasMore: boolean
|
years: number[]
|
||||||
loadingMore: boolean
|
selectedYear: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [episode: EpisodeData]
|
select: [episode: EpisodeData]
|
||||||
loadMore: []
|
updateYear: [year: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const listPanelRef = ref<HTMLElement | null>(null)
|
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 {
|
function formatDate(timestamp: number): string {
|
||||||
if (!timestamp) return '--'
|
if (!timestamp) return '--'
|
||||||
@ -50,11 +120,29 @@ function formatDate(timestamp: number): string {
|
|||||||
return `${y}-${m}-${day}`
|
return `${y}-${m}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollToActiveEpisode() {
|
function isMobileDevice() {
|
||||||
await nextTick()
|
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
|
const panel = listPanelRef.value
|
||||||
if (!panel || !props.currentEpisodeId) return
|
if (!panel) return
|
||||||
|
|
||||||
const activeEl = panel.querySelector(
|
const activeEl = panel.querySelector(
|
||||||
`[data-episode-id="${props.currentEpisodeId}"]`
|
`[data-episode-id="${props.currentEpisodeId}"]`
|
||||||
@ -62,24 +150,31 @@ async function scrollToActiveEpisode() {
|
|||||||
|
|
||||||
if (!activeEl) return
|
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
|
panel.scrollTo({
|
||||||
const isBelow = itemRect.bottom > panelRect.bottom
|
top: itemTop - panelHeight / 2 + itemHeight / 2,
|
||||||
|
behavior: 'smooth'
|
||||||
if (isAbove || isBelow) {
|
|
||||||
activeEl.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'center'
|
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeEl.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.currentEpisodeId,
|
() => props.currentEpisodeId,
|
||||||
async () => {
|
async () => {
|
||||||
await scrollToActiveEpisode()
|
await locateCurrentEpisode()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@ -87,92 +182,62 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => props.episodes.length,
|
() => props.episodes.length,
|
||||||
async () => {
|
async () => {
|
||||||
await scrollToActiveEpisode()
|
await locateCurrentEpisode()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectedYear,
|
||||||
|
async () => {
|
||||||
|
await locateCurrentEpisode()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.list-panel {
|
.episode-card {
|
||||||
max-height: 80vh;
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-panel {
|
||||||
|
max-height: 72vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
|
||||||
background: #1b1b1b;
|
|
||||||
border-radius: 12px;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-item {
|
.episode-row {
|
||||||
display: flex;
|
border-radius: 14px;
|
||||||
gap: 12px;
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
padding: 10px;
|
padding: 12px 14px;
|
||||||
border-radius: 10px;
|
transition: all 0.2s ease;
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
transition: 0.2s;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-item:hover {
|
.episode-row:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-item.active {
|
.current-row {
|
||||||
background: rgba(255, 193, 7, 0.16);
|
box-shadow: 0 8px 20px rgba(255, 193, 7, 0.22);
|
||||||
border-color: rgba(255, 193, 7, 0.45);
|
|
||||||
box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.12) inset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-item img {
|
.episode-date {
|
||||||
width: 120px;
|
font-size: 12px;
|
||||||
height: 68px;
|
color: #6c757d;
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-text {
|
.episode-title {
|
||||||
flex: 1;
|
line-height: 1.45;
|
||||||
min-width: 0;
|
font-size: 14px;
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-item.active .title {
|
.year-select {
|
||||||
color: #ffd666;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.empty-tip {
|
||||||
font-size: 13px;
|
color: #6c757d;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</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 type { EpisodeData } from '@/api/qishier'
|
||||||
import { getEpisodeProgress, saveEpisodeProgress } from '@/utils/playHistory'
|
import { getEpisodeProgress, saveEpisodeProgress } from '@/utils/playHistory'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type ArtWithHls = Artplayer & {
|
type ArtWithHls = Artplayer & {
|
||||||
hls?: Hls
|
hls?: Hls
|
||||||
}
|
}
|
||||||
@ -27,8 +29,18 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const playerRef = ref<HTMLDivElement | null>(null)
|
const playerRef = ref<HTMLDivElement | null>(null)
|
||||||
let art: ArtWithHls | null = null
|
let art: ArtWithHls | null = null
|
||||||
|
let playRetryTimer: number | null = null
|
||||||
|
|
||||||
|
function clearPlayRetryTimer() {
|
||||||
|
if (playRetryTimer !== null) {
|
||||||
|
window.clearTimeout(playRetryTimer)
|
||||||
|
playRetryTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function destroyPlayer() {
|
function destroyPlayer() {
|
||||||
|
clearPlayRetryTimer()
|
||||||
|
|
||||||
if (art?.hls) {
|
if (art?.hls) {
|
||||||
art.hls.destroy()
|
art.hls.destroy()
|
||||||
art.hls = undefined
|
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) {
|
function buildPlayer(episode: EpisodeData) {
|
||||||
if (!playerRef.value) return
|
if (!playerRef.value) return
|
||||||
|
|
||||||
@ -58,23 +80,56 @@ function buildPlayer(episode: EpisodeData) {
|
|||||||
setting: true,
|
setting: true,
|
||||||
playbackRate: true,
|
playbackRate: true,
|
||||||
hotkey: true,
|
hotkey: true,
|
||||||
|
|
||||||
autoHide: 2000,
|
|
||||||
|
|
||||||
mutex: true,
|
mutex: true,
|
||||||
theme: '#f5c542',
|
theme: '#f5c542',
|
||||||
moreVideoAttr: {
|
moreVideoAttr: {
|
||||||
playsInline: true
|
playsInline: true,
|
||||||
|
preload: 'auto'
|
||||||
} as any,
|
} as any,
|
||||||
customType: {
|
customType: {
|
||||||
m3u8(video: HTMLVideoElement, url: string, artInstance: Artplayer) {
|
m3u8(video: HTMLVideoElement, url: string, artInstance: Artplayer) {
|
||||||
const artplayer = artInstance as ArtWithHls
|
const artplayer = artInstance as ArtWithHls
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
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
|
artplayer.hls = hls
|
||||||
hls.loadSource(url)
|
hls.loadSource(url)
|
||||||
hls.attachMedia(video)
|
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')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
video.src = url
|
video.src = url
|
||||||
} else {
|
} else {
|
||||||
@ -86,7 +141,7 @@ function buildPlayer(episode: EpisodeData) {
|
|||||||
|
|
||||||
art.type = 'm3u8'
|
art.type = 'm3u8'
|
||||||
|
|
||||||
const restore = () => {
|
const restoreProgress = () => {
|
||||||
const progress = getEpisodeProgress(episode.id)
|
const progress = getEpisodeProgress(episode.id)
|
||||||
if (progress > 0 && art?.video) {
|
if (progress > 0 && art?.video) {
|
||||||
try {
|
try {
|
||||||
@ -95,9 +150,21 @@ function buildPlayer(episode: EpisodeData) {
|
|||||||
// ignore
|
// 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', () => {
|
art.on('video:timeupdate', () => {
|
||||||
if (art?.video) {
|
if (art?.video) {
|
||||||
@ -135,7 +202,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => props.autoNext,
|
() => props.autoNext,
|
||||||
() => {
|
() => {
|
||||||
// 让 ended 事件读取最新 autoNext 值
|
// 保持响应式依赖
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -152,6 +219,8 @@ onBeforeUnmount(() => {
|
|||||||
.player-wrap {
|
.player-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #000;
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artplayer-container {
|
.artplayer-container {
|
||||||
@ -164,4 +233,8 @@ onBeforeUnmount(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.art-video-player) {
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import App from './App.vue'
|
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).mount('#app')
|
||||||
|
|||||||
@ -1,97 +1,117 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="container-fluid qishier-page py-3">
|
||||||
<div class="header">
|
<div class="row g-3">
|
||||||
<div>
|
<div class="col-12">
|
||||||
<h1>{{ columnInfo.name || '七十二家房客播放器' }}</h1>
|
<div class="top-card card border-0 shadow-sm">
|
||||||
<p>共 {{ total }} 条,当前显示 {{ episodeList.length }} 条</p>
|
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||||
<p class="sub">最后更新:{{ updatedAtText }}</p>
|
<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>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="col-12">
|
||||||
<button class="action-btn" @click="handleRefresh">刷新数据</button>
|
<div class="toolbar-card card border-0 shadow-sm">
|
||||||
<button class="action-btn source-btn" @click="openSource">
|
<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>
|
||||||
|
|
||||||
|
<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>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="col-xl-8 col-lg-7">
|
||||||
<button class="action-btn small-btn" @click="playPrevEpisode" :disabled="!hasPrevEpisode">
|
<div class="content-card card border-0 shadow-sm">
|
||||||
上一集
|
<div class="card-body">
|
||||||
</button>
|
<QishierVideoPlayer
|
||||||
|
:episode="currentEpisode"
|
||||||
|
:auto-next="autoNext"
|
||||||
|
@error="handlePlayerError"
|
||||||
|
@ended-next="handleEndedNext"
|
||||||
|
/>
|
||||||
|
|
||||||
<button class="action-btn small-btn" @click="playNextEpisode" :disabled="!hasNextEpisode && !hasMore">
|
<div v-if="currentEpisode" class="episode-info mt-3">
|
||||||
下一集
|
<div class="episode-badge mb-2">当前播放</div>
|
||||||
</button>
|
<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">
|
<div v-if="errorMsg" class="alert alert-danger mt-3 mb-0">
|
||||||
<input v-model="autoNext" type="checkbox" />
|
{{ errorMsg }}
|
||||||
<span>自动下一集</span>
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
<select v-model="playMode" class="mode-select">
|
<QishierShowcase
|
||||||
<option value="asc">顺序</option>
|
v-if="showcaseEpisodes.length > 0"
|
||||||
<option value="desc">倒序</option>
|
:episodes="showcaseEpisodes"
|
||||||
</select>
|
@select="playEpisode"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div class="layout">
|
<QishierProjectLinks />
|
||||||
<div class="player-panel">
|
</div>
|
||||||
<QishierVideoPlayer
|
</div>
|
||||||
:episode="currentEpisode"
|
</div>
|
||||||
:auto-next="autoNext"
|
|
||||||
@error="handlePlayerError"
|
<div class="col-xl-4 col-lg-5">
|
||||||
@ended-next="handleEndedNext"
|
<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>
|
</div>
|
||||||
|
|
||||||
<QishierEpisodeList
|
|
||||||
:episodes="displayEpisodeList"
|
|
||||||
:current-episode-id="currentEpisode?.id || ''"
|
|
||||||
:has-more="hasMore"
|
|
||||||
:loading-more="loadingMore"
|
|
||||||
@select="playEpisode"
|
|
||||||
@load-more="loadMoreEpisodes"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
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 { getCurrentEpisodeId, saveCurrentEpisodeId } from '@/utils/playHistory'
|
||||||
import QishierVideoPlayer from '@/components/qishier/QishierVideoPlayer.vue'
|
import QishierVideoPlayer from '@/components/qishier/QishierVideoPlayer.vue'
|
||||||
import QishierEpisodeList from '@/components/qishier/QishierEpisodeList.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({
|
const columnInfo = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
coverUrl: ''
|
coverUrl: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const episodeList = ref<EpisodeData[]>([])
|
const allEpisodeList = ref<EpisodeData[]>([])
|
||||||
const currentEpisode = ref<EpisodeData | null>(null)
|
const currentEpisode = ref<EpisodeData | null>(null)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const updatedAt = ref<string | null>(null)
|
const updatedAt = ref<string | null>(null)
|
||||||
|
const years = ref<number[]>([])
|
||||||
const total = ref(0)
|
const selectedYear = ref('all')
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(30)
|
|
||||||
const hasMore = ref(false)
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadingMore = ref(false)
|
|
||||||
|
|
||||||
const autoNext = ref(true)
|
const autoNext = ref(true)
|
||||||
const playMode = ref<'asc' | 'desc'>('asc')
|
const playMode = ref<'asc' | 'desc'>('asc')
|
||||||
@ -102,10 +122,23 @@ const updatedAtText = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const displayEpisodeList = computed(() => {
|
const displayEpisodeList = computed(() => {
|
||||||
if (playMode.value === 'desc') {
|
let list = [...allEpisodeList.value]
|
||||||
return [...episodeList.value].reverse()
|
|
||||||
|
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(() => {
|
const currentDisplayIndex = computed(() => {
|
||||||
@ -126,85 +159,68 @@ function handlePlayerError(message: string) {
|
|||||||
errorMsg.value = message
|
errorMsg.value = message
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFirstPage(): Promise<void> {
|
function handleYearChange(year: string) {
|
||||||
try {
|
selectedYear.value = year
|
||||||
loading.value = true
|
}
|
||||||
errorMsg.value = ''
|
|
||||||
|
|
||||||
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
|
const data = res.data
|
||||||
|
|
||||||
columnInfo.name = data.name || '七十二家房客'
|
columnInfo.name = data.name || '七十二家房客'
|
||||||
columnInfo.coverUrl = data.coverUrl || ''
|
|
||||||
updatedAt.value = data.updatedAt || null
|
updatedAt.value = data.updatedAt || null
|
||||||
total.value = data.total || 0
|
years.value = Array.isArray(data.years) ? [...data.years].sort((a, b) => b - a) : []
|
||||||
currentPage.value = data.page || 1
|
allEpisodeList.value = Array.isArray(data.list) ? data.list : []
|
||||||
hasMore.value = !!data.hasMore
|
|
||||||
episodeList.value = Array.isArray(data.list) ? data.list : []
|
|
||||||
|
|
||||||
if (episodeList.value.length > 0) {
|
if (allEpisodeList.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 {
|
|
||||||
errorMsg.value = '暂无节目数据'
|
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) {
|
} catch (error: any) {
|
||||||
console.error('fetchFirstPage error:', error)
|
console.error('fetchAllEpisodes error:', error)
|
||||||
errorMsg.value = error?.message || '读取数据失败'
|
errorMsg.value = error?.message || '读取数据失败'
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMoreEpisodes(): Promise<void> {
|
async function handleRefresh() {
|
||||||
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> {
|
|
||||||
try {
|
try {
|
||||||
await refreshQishierCache()
|
await refreshQishierCache()
|
||||||
await fetchFirstPage()
|
await fetchAllEpisodes()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('handleRefresh error:', error)
|
console.error('handleRefresh error:', error)
|
||||||
errorMsg.value = error?.message || '刷新失败'
|
errorMsg.value = error?.message || '刷新失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playEpisode(item: EpisodeData): Promise<void> {
|
async function playEpisode(item: EpisodeData) {
|
||||||
currentEpisode.value = item
|
currentEpisode.value = item
|
||||||
|
selectedYear.value = getEpisodeYear(item)
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
saveCurrentEpisodeId(item.id)
|
saveCurrentEpisodeId(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playPrevEpisode(): Promise<void> {
|
async function playPrevEpisode() {
|
||||||
if (!hasPrevEpisode.value) return
|
if (!hasPrevEpisode.value) return
|
||||||
const prev = displayEpisodeList.value[currentDisplayIndex.value - 1]
|
const prev = displayEpisodeList.value[currentDisplayIndex.value - 1]
|
||||||
if (prev) {
|
if (prev) {
|
||||||
@ -212,27 +228,18 @@ async function playPrevEpisode(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playNextEpisode(): Promise<void> {
|
async function playNextEpisode() {
|
||||||
if (hasNextEpisode.value) {
|
if (!hasNextEpisode.value) return
|
||||||
const next = displayEpisodeList.value[currentDisplayIndex.value + 1]
|
const next = displayEpisodeList.value[currentDisplayIndex.value + 1]
|
||||||
if (next) {
|
if (next) {
|
||||||
await playEpisode(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 handleEndedNext() {
|
async function handleEndedNext() {
|
||||||
await playNextEpisode()
|
if (autoNext.value) {
|
||||||
|
await playNextEpisode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(timestamp: number): string {
|
function formatDate(timestamp: number): string {
|
||||||
@ -252,131 +259,62 @@ function formatDuration(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void fetchFirstPage()
|
void fetchAllEpisodes()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.qishier-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
background:
|
||||||
background: #111;
|
radial-gradient(circle at top left, rgba(255, 193, 7, 0.12), transparent 28%),
|
||||||
color: #fff;
|
radial-gradient(circle at top right, rgba(13, 110, 253, 0.1), transparent 24%),
|
||||||
|
linear-gradient(180deg, #f8f9fa 0%, #eef1f5 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.top-card,
|
||||||
margin-bottom: 16px;
|
.toolbar-card,
|
||||||
display: flex;
|
.content-card {
|
||||||
justify-content: space-between;
|
border-radius: 18px;
|
||||||
align-items: flex-start;
|
background: rgba(255, 255, 255, 0.92);
|
||||||
gap: 16px;
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.page-title {
|
||||||
margin: 0 0 8px;
|
font-weight: 700;
|
||||||
|
color: #212529;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header p {
|
.page-subtitle {
|
||||||
margin: 0 0 6px;
|
color: #6c757d;
|
||||||
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;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-select {
|
.mode-select {
|
||||||
padding: 10px 12px;
|
width: 140px;
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.episode-info {
|
||||||
padding: 10px 16px;
|
padding: 2px 2px 6px;
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.episode-badge {
|
||||||
background: #444;
|
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 {
|
.episode-title {
|
||||||
opacity: 0.6;
|
color: #212529;
|
||||||
cursor: not-allowed;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.small-btn {
|
.episode-meta {
|
||||||
padding: 8px 14px;
|
color: #6c757d;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
padding: 0 16px 16px;
|
|
||||||
color: #ff7875;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user