Compare commits

..

No commits in common. "main" and "72Player" have entirely different histories.

15 changed files with 364 additions and 1107 deletions

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
.DS_Store
.pw-user-data
*.zip
node_modules/
dist/
npm-debug.log*

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"year": 2024,
"updatedAt": "2026-03-25T01:35:17.796Z",
"updatedAt": "2026-03-10T21:35:04.482Z",
"items": [
{
"id": "7f012566d7827b5046de9f92a4d7e159",

View File

@ -1,6 +1,6 @@
{
"year": 2025,
"updatedAt": "2026-03-25T01:34:35.851Z",
"updatedAt": "2026-03-10T21:34:22.356Z",
"items": [
{
"id": "b2b4f80b846360647d13e297c029be8d",

View File

@ -1,6 +1,6 @@
{
"year": 2026,
"updatedAt": "2026-03-25T01:33:45.340Z",
"updatedAt": "2026-03-10T21:33:31.594Z",
"items": [
{
"id": "5abbc88ea530b5db6438a4e584e80281",

View File

@ -1,5 +1,5 @@
{
"running": false,
"lastMessage": "自动采集完成",
"updatedAt": "2026-03-25T01:35:31.093Z"
"updatedAt": "2026-03-10T21:35:26.989Z"
}

View File

@ -13,7 +13,6 @@
"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",

View File

@ -9,18 +9,25 @@ export interface EpisodeData {
videoUrl: string
}
export interface QishierAllResponse {
export interface QishierListResponse {
updatedAt: string | null
name: string
coverUrl: string
displayType: number
total: number
years: number[]
page: number
pageSize: number
hasMore: boolean
list: EpisodeData[]
}
export function getAllQishierList() {
return axios.get<QishierAllResponse>('/api/qishier/all', {
export function getQishierList(page = 1, pageSize = 50) {
return axios.get<QishierListResponse>('/api/qishier/list', {
params: {
page,
pageSize
},
timeout: 15000
})
}

View File

@ -1,115 +1,45 @@
<template>
<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 ref="listPanelRef" class="card-body pt-2 episode-panel">
<button
v-for="item in pagedEpisodes"
<div ref="listPanelRef" class="list-panel">
<div
v-for="item in episodes"
: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)"
class="episode-item"
:class="{ active: currentEpisodeId === item.id }"
@click="$emit('select', item)"
>
<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">
当前筛选没有数据
<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>
</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">
上一页
<div class="load-more-wrap" v-if="hasMore">
<button class="load-more-btn" :disabled="loadingMore" @click="$emit('loadMore')">
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
<button class="btn btn-outline-secondary" :disabled="currentPage >= totalPages" @click="nextPage">
下一页
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { nextTick, ref, watch } from 'vue'
import type { EpisodeData } from '@/api/qishier'
const props = defineProps<{
episodes: EpisodeData[]
currentEpisodeId: string
years: number[]
selectedYear: string
hasMore: boolean
loadingMore: boolean
}>()
const emit = defineEmits<{
defineEmits<{
select: [episode: EpisodeData]
updateYear: [year: string]
loadMore: []
}>()
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 '--'
@ -120,29 +50,11 @@ function formatDate(timestamp: number): string {
return `${y}-${m}-${day}`
}
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
async function scrollToActiveEpisode() {
await nextTick()
}
const panel = listPanelRef.value
if (!panel) return
if (!panel || !props.currentEpisodeId) return
const activeEl = panel.querySelector(
`[data-episode-id="${props.currentEpisodeId}"]`
@ -150,31 +62,24 @@ async function locateCurrentEpisode() {
if (!activeEl) return
//
if (isMobileDevice()) {
const panelTop = panel.scrollTop
const itemTop = activeEl.offsetTop
const itemHeight = activeEl.offsetHeight
const panelHeight = panel.clientHeight
const panelRect = panel.getBoundingClientRect()
const itemRect = activeEl.getBoundingClientRect()
panel.scrollTo({
top: itemTop - panelHeight / 2 + itemHeight / 2,
behavior: 'smooth'
})
return
}
const isAbove = itemRect.top < panelRect.top
const isBelow = itemRect.bottom > panelRect.bottom
if (isAbove || isBelow) {
activeEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
block: 'center'
})
}
}
watch(
() => props.currentEpisodeId,
async () => {
await locateCurrentEpisode()
await scrollToActiveEpisode()
},
{ immediate: true }
)
@ -182,62 +87,92 @@ watch(
watch(
() => props.episodes.length,
async () => {
await locateCurrentEpisode()
}
)
watch(
() => props.selectedYear,
async () => {
await locateCurrentEpisode()
await scrollToActiveEpisode()
}
)
</script>
<style scoped>
.episode-card {
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
}
.episode-panel {
max-height: 72vh;
.list-panel {
max-height: 80vh;
overflow-y: auto;
padding: 12px;
background: #1b1b1b;
border-radius: 12px;
scroll-behavior: smooth;
}
.episode-row {
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.06);
padding: 12px 14px;
transition: all 0.2s ease;
.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:hover {
transform: translateY(-1px);
.episode-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.current-row {
box-shadow: 0 8px 20px rgba(255, 193, 7, 0.22);
.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;
}
.episode-date {
font-size: 12px;
color: #6c757d;
}
.episode-title {
line-height: 1.45;
font-size: 14px;
font-weight: 600;
white-space: normal;
}
.year-select {
.episode-item img {
width: 120px;
height: 68px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
}
.empty-tip {
color: #6c757d;
.episode-text {
flex: 1;
min-width: 0;
}
.title {
font-size: 15px;
line-height: 1.5;
margin-bottom: 8px;
font-weight: 600;
color: #fff;
}
.episode-item.active .title {
color: #ffd666;
}
.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;
}
</style>

View File

@ -1,155 +0,0 @@
<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>

View File

@ -1,362 +0,0 @@
<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>

View File

@ -11,8 +11,6 @@ import Hls from 'hls.js'
import type { EpisodeData } from '@/api/qishier'
import { getEpisodeProgress, saveEpisodeProgress } from '@/utils/playHistory'
type ArtWithHls = Artplayer & {
hls?: Hls
}
@ -29,18 +27,8 @@ 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
@ -56,16 +44,6 @@ function destroyPlayer() {
}
}
function safeAutoPlay() {
clearPlayRetryTimer()
playRetryTimer = window.setTimeout(() => {
void art?.video?.play().catch(() => {
//
})
}, 180)
}
function buildPlayer(episode: EpisodeData) {
if (!playerRef.value) return
@ -83,53 +61,17 @@ function buildPlayer(episode: EpisodeData) {
mutex: true,
theme: '#f5c542',
moreVideoAttr: {
playsInline: true,
preload: 'auto'
playsInline: true
} as any,
customType: {
m3u8(video: HTMLVideoElement, url: string, artInstance: Artplayer) {
const artplayer = artInstance as ArtWithHls
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 30,
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxBufferHole: 1,
highBufferWatchdogPeriod: 2,
nudgeOffset: 0.1,
nudgeMaxRetry: 5
})
const hls = new Hls()
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 {
@ -141,7 +83,7 @@ function buildPlayer(episode: EpisodeData) {
art.type = 'm3u8'
const restoreProgress = () => {
const restore = () => {
const progress = getEpisodeProgress(episode.id)
if (progress > 0 && art?.video) {
try {
@ -150,21 +92,9 @@ function buildPlayer(episode: EpisodeData) {
// ignore
}
}
safeAutoPlay()
}
art.on('video:loadedmetadata', restoreProgress)
art.on('video:canplay', () => {
// loadedmetadata canplay
safeAutoPlay()
})
art.on('video:waiting', () => {
//
console.warn('视频缓冲中...')
})
art.on('video:loadedmetadata', restore)
art.on('video:timeupdate', () => {
if (art?.video) {
@ -202,7 +132,7 @@ watch(
watch(
() => props.autoNext,
() => {
//
// ended autoNext
}
)
@ -219,8 +149,6 @@ onBeforeUnmount(() => {
.player-wrap {
width: 100%;
background: #000;
border-radius: 12px;
overflow: hidden;
}
.artplayer-container {
@ -233,8 +161,4 @@ onBeforeUnmount(() => {
width: 100%;
height: 100%;
}
:deep(.art-video-player) {
background: #000;
}
</style>

View File

@ -1,8 +1,5 @@
import './style.css'
import { createApp } from 'vue'
import App from './App.vue'
import 'bootstrap/dist/css/bootstrap.min.css'
import './style.css'
createApp(App).mount('#app')

View File

@ -1,97 +1,42 @@
<template>
<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-start gap-3">
<div class="page-main-info">
<template v-if="currentEpisode">
<div class="episode-badges mb-2">
<span class="info-badge playing">当前播放</span>
<span class="meta-pill">发布时间{{ formatDate(currentEpisode.releasedAt) }}</span>
<span class="meta-pill">时长{{ formatDuration(currentEpisode.timeLength) }}</span>
<div class="page">
<div class="header">
<div>
<h1>{{ columnInfo.name || '七十二家房客播放器' }}</h1>
<p> {{ total }} 当前显示 {{ episodeList.length }} </p>
<p class="sub">最后更新{{ updatedAtText }}</p>
</div>
<h2 class="mb-2 page-title">{{ currentEpisode.title }}</h2>
</template>
<template v-else>
<h2 class="mb-2 page-title">{{ columnInfo.name || '七十二家房客播放器' }}</h2>
<div class="episode-meta mb-2">正在加载节目数据...</div>
</template>
<div class="page-subtitle">
{{ allEpisodeList.length }}
<span class="mx-2"></span>
最后更新{{ updatedAtText }}
</div>
</div>
<div class="header-actions d-flex flex-wrap gap-2">
<button class="btn btn-dark btn-sm px-3" @click="handleRefresh">
刷新数据
</button>
<button class="btn btn-outline-dark btn-sm px-3" @click="openSource">
数据来源
</button>
<button class="btn btn-outline-primary btn-sm px-3" @click="openGitRepo">
Git仓库v163
<div class="header-actions">
<button class="action-btn" @click="handleRefresh">刷新数据</button>
<button class="action-btn source-btn" @click="openSource">
数据来源荔枝网
</button>
</div>
</div>
</div>
</div>
<!-- 控制区 -->
<div class="col-12">
<div class="control-card card border-0 shadow-sm">
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-3">
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-outline-dark btn-sm" @click="playPrevEpisode" :disabled="!hasPrevEpisode">
<div class="toolbar">
<button class="action-btn small-btn" @click="playPrevEpisode" :disabled="!hasPrevEpisode">
上一集
</button>
<button class="btn btn-outline-dark btn-sm" @click="playNextEpisode" :disabled="!hasNextEpisode">
<button class="action-btn small-btn" @click="playNextEpisode" :disabled="!hasNextEpisode && !hasMore">
下一集
</button>
<div class="form-check form-switch ms-1">
<input id="autoNext" v-model="autoNext" class="form-check-input" type="checkbox" />
<label class="form-check-label small" for="autoNext">自动下一集</label>
</div>
</div>
<label class="switch-item">
<input v-model="autoNext" type="checkbox" />
<span>自动下一集</span>
</label>
<div class="d-flex flex-wrap gap-2 align-items-center">
<select v-model="playMode" class="form-select form-select-sm mode-select">
<option value="asc">顺序播放</option>
<option value="desc">倒序播放</option>
</select>
<select v-model="selectedYear" class="form-select form-select-sm filter-select">
<option value="all">全部年份</option>
<option v-for="year in years" :key="year" :value="String(year)">
{{ year }}
</option>
</select>
<select v-model="selectedMonth" class="form-select form-select-sm filter-select">
<option value="all">全部月份</option>
<option v-for="month in months" :key="month" :value="String(month)">
{{ month }}
</option>
<select v-model="playMode" class="mode-select">
<option value="asc">顺序</option>
<option value="desc">倒序</option>
</select>
</div>
</div>
</div>
</div>
<!-- 左侧播放器 -->
<div class="col-xl-8 col-lg-7">
<div class="content-card card border-0 shadow-sm">
<div class="card-body">
<div class="layout">
<div class="player-panel">
<QishierVideoPlayer
:episode="currentEpisode"
:auto-next="autoNext"
@ -99,58 +44,54 @@
@ended-next="handleEndedNext"
/>
<div v-if="errorMsg" class="alert alert-danger mt-3 mb-0">
<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>
<QishierShowcase
v-if="showcaseEpisodes.length > 0"
:episodes="showcaseEpisodes"
@select="playEpisode"
/>
</div>
</div>
</div>
<!-- 右侧列表 -->
<div class="col-xl-4 col-lg-5">
<QishierEpisodeList
:episodes="displayEpisodeList"
:current-episode-id="currentEpisode?.id || ''"
:years="years"
:months="months"
:selected-year="selectedYear"
:selected-month="selectedMonth"
:has-more="hasMore"
:loading-more="loadingMore"
@select="playEpisode"
@updateYear="handleYearChange"
@updateMonth="handleMonthChange"
@load-more="loadMoreEpisodes"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { getAllQishierList, refreshQishierCache, type EpisodeData } from '@/api/qishier'
import { computed, onMounted, reactive, ref } from 'vue'
import { getQishierList, 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'
const columnInfo = reactive({
name: '',
coverUrl: ''
})
const allEpisodeList = ref<EpisodeData[]>([])
const episodeList = ref<EpisodeData[]>([])
const currentEpisode = ref<EpisodeData | null>(null)
const errorMsg = ref('')
const updatedAt = ref<string | null>(null)
const years = ref<number[]>([])
const selectedYear = ref('all')
const months = ref<number[]>([])
const selectedMonth = ref('all')
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 autoNext = ref(true)
const playMode = ref<'asc' | 'desc'>('asc')
@ -161,28 +102,10 @@ const updatedAtText = computed(() => {
})
const displayEpisodeList = computed(() => {
let list = [...allEpisodeList.value]
if (selectedYear.value !== 'all') {
const year = Number(selectedYear.value)
list = list.filter(item => new Date(item.releasedAt).getFullYear() === year)
}
if (selectedMonth.value !== 'all') {
const month = Number(selectedMonth.value)
list = list.filter(item => new Date(item.releasedAt).getMonth() + 1 === month)
}
if (playMode.value === 'desc') {
list.reverse()
return [...episodeList.value].reverse()
}
return list
})
const showcaseEpisodes = computed(() => {
const list = displayEpisodeList.value.length > 0 ? displayEpisodeList.value : allEpisodeList.value
return list.slice(0, 8)
return episodeList.value
})
const currentDisplayIndex = computed(() => {
@ -199,147 +122,89 @@ function openSource() {
window.open('https://www1.gdtv.cn/', '_blank')
}
function openGitRepo() {
window.open('https://git.a-hxin.cn/ahxin/72QishierPlayer.git', '_blank')
}
function getEpisodeIdFromUrl() {
const path = window.location.pathname
const match = path.match(/^\/episode\/([^/]+)$/)
if (match?.[1]) {
return decodeURIComponent(match[1])
}
const queryId = new URLSearchParams(window.location.search).get('episode')
return queryId || ''
}
function updateUrlByEpisodeId(id: string) {
const nextUrl = `/episode/${encodeURIComponent(id)}`
if (window.location.pathname !== nextUrl) {
window.history.replaceState({}, '', nextUrl)
}
}
function handlePlayerError(message: string) {
errorMsg.value = message
}
function handleYearChange(year: string) {
selectedYear.value = year
}
function handleMonthChange(month: string) {
selectedMonth.value = month
}
function getEpisodeYear(item: EpisodeData) {
return String(new Date(item.releasedAt).getFullYear())
}
function getEpisodeMonth(item: EpisodeData) {
return String(new Date(item.releasedAt).getMonth() + 1)
}
function rebuildMonthsByYear() {
let source = [...allEpisodeList.value]
if (selectedYear.value !== 'all') {
const year = Number(selectedYear.value)
source = source.filter(item => new Date(item.releasedAt).getFullYear() === year)
}
months.value = [
...new Set(source.map(item => new Date(item.releasedAt).getMonth() + 1))
].sort((a, b) => a - b)
if (selectedMonth.value !== 'all') {
const currentMonth = Number(selectedMonth.value)
if (!months.value.includes(currentMonth)) {
selectedMonth.value = 'all'
}
}
}
async function fetchAllEpisodes() {
async function fetchFirstPage(): Promise<void> {
try {
loading.value = true
errorMsg.value = ''
const res = await getAllQishierList()
const res = await getQishierList(1, pageSize.value)
const data = res.data
columnInfo.name = data.name || '七十二家房客'
columnInfo.coverUrl = data.coverUrl || ''
updatedAt.value = data.updatedAt || null
years.value = Array.isArray(data.years) ? [...data.years].sort((a, b) => b - a) : []
allEpisodeList.value = Array.isArray(data.list) ? data.list : []
if (allEpisodeList.value.length === 0) {
errorMsg.value = '暂无节目数据'
return
}
rebuildMonthsByYear()
const urlEpisodeId = getEpisodeIdFromUrl()
const urlEpisode = allEpisodeList.value.find(item => item.id === urlEpisodeId)
if (urlEpisode) {
selectedYear.value = getEpisodeYear(urlEpisode)
rebuildMonthsByYear()
selectedMonth.value = getEpisodeMonth(urlEpisode)
currentEpisode.value = urlEpisode
saveCurrentEpisodeId(urlEpisode.id)
updateUrlByEpisodeId(urlEpisode.id)
return
}
total.value = data.total || 0
currentPage.value = data.page || 1
hasMore.value = !!data.hasMore
episodeList.value = Array.isArray(data.list) ? data.list : []
if (episodeList.value.length > 0) {
const savedEpisodeId = getCurrentEpisodeId()
const savedEpisode = allEpisodeList.value.find(item => item.id === savedEpisodeId)
const savedEpisode = episodeList.value.find(item => item.id === savedEpisodeId)
const firstEpisode = displayEpisodeList.value[0]
if (savedEpisode) {
selectedYear.value = getEpisodeYear(savedEpisode)
rebuildMonthsByYear()
selectedMonth.value = getEpisodeMonth(savedEpisode)
currentEpisode.value = savedEpisode
updateUrlByEpisodeId(savedEpisode.id)
return
await playEpisode(savedEpisode)
} else if (firstEpisode) {
await playEpisode(firstEpisode)
}
const firstEpisode = allEpisodeList.value[0]
if (firstEpisode) {
selectedYear.value = getEpisodeYear(firstEpisode)
rebuildMonthsByYear()
selectedMonth.value = getEpisodeMonth(firstEpisode)
currentEpisode.value = firstEpisode
saveCurrentEpisodeId(firstEpisode.id)
updateUrlByEpisodeId(firstEpisode.id)
} else {
errorMsg.value = '暂无节目数据'
}
} catch (error: any) {
console.error('fetchAllEpisodes error:', error)
console.error('fetchFirstPage error:', error)
errorMsg.value = error?.message || '读取数据失败'
} finally {
loading.value = false
}
}
async function handleRefresh() {
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> {
try {
await refreshQishierCache()
await fetchAllEpisodes()
await fetchFirstPage()
} catch (error: any) {
console.error('handleRefresh error:', error)
errorMsg.value = error?.message || '刷新失败'
}
}
async function playEpisode(item: EpisodeData) {
async function playEpisode(item: EpisodeData): Promise<void> {
currentEpisode.value = item
selectedYear.value = getEpisodeYear(item)
rebuildMonthsByYear()
selectedMonth.value = getEpisodeMonth(item)
errorMsg.value = ''
saveCurrentEpisodeId(item.id)
updateUrlByEpisodeId(item.id)
}
async function playPrevEpisode() {
async function playPrevEpisode(): Promise<void> {
if (!hasPrevEpisode.value) return
const prev = displayEpisodeList.value[currentDisplayIndex.value - 1]
if (prev) {
@ -347,18 +212,27 @@ async function playPrevEpisode() {
}
}
async function playNextEpisode() {
if (!hasNextEpisode.value) return
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 handleEndedNext() {
if (autoNext.value) {
await playNextEpisode()
}
}
function formatDate(timestamp: number): string {
@ -377,94 +251,132 @@ function formatDuration(seconds: number): string {
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
watch(selectedYear, () => {
rebuildMonthsByYear()
})
onMounted(() => {
void fetchAllEpisodes()
void fetchFirstPage()
})
</script>
<style scoped>
.qishier-page {
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(99, 102, 241, 0.10), transparent 28%),
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 24%),
linear-gradient(180deg, #f7f8fb 0%, #eef2f7 100%);
padding: 20px;
background: #111;
color: #fff;
}
.top-card,
.control-card,
.content-card {
border-radius: 18px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
.header {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.page-main-info {
min-width: 0;
.header h1 {
margin: 0 0 8px;
}
.page-title {
font-weight: 800;
color: #1f2937;
letter-spacing: -0.02em;
line-height: 1.35;
.header p {
margin: 0 0 6px;
color: #999;
}
.page-subtitle {
color: #6b7280;
font-size: 14px;
.sub {
font-size: 13px;
}
.header-actions {
flex-shrink: 0;
}
.mode-select {
width: 132px;
}
.filter-select {
width: 120px;
}
.episode-badges {
display: flex;
gap: 10px;
flex-wrap: wrap;
gap: 8px;
}
.info-badge,
.meta-pill {
display: inline-flex;
.toolbar {
display: flex;
gap: 10px;
align-items: center;
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
flex-wrap: wrap;
margin-bottom: 20px;
}
.info-badge.playing {
background: rgba(59, 130, 246, 0.12);
color: #2563eb;
}
.meta-pill {
background: rgba(15, 23, 42, 0.06);
color: #475569;
}
.episode-meta {
color: #6b7280;
.switch-item {
display: flex;
align-items: center;
gap: 6px;
color: #ddd;
font-size: 14px;
}
@media (max-width: 991px) {
.header-actions {
width: 100%;
.mode-select {
padding: 10px 12px;
border-radius: 8px;
border: none;
background: #333;
color: #fff;
}
.action-btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
background: #333;
color: #fff;
cursor: pointer;
}
.action-btn:hover {
background: #444;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.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;
font-size: 14px;
}
.error {
padding: 0 16px 16px;
color: #ff7875;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
}
}
</style>

View File

@ -17,8 +17,8 @@ export default defineConfig(({ command }) => ({
open: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:23822/',
//target: 'http://8.134.120.132:23822/',
//target: 'http://127.0.0.1:23822/',
target: 'http://8.134.120.132:23822/',
changeOrigin: true
}