refactor: 重构组件使用新 hooks

- LeftPanel: 整合子组件,使用 useChannels/useGroups/useFavorites
- BottomPanel: 使用 useUI 替代 props
- VideoPlayer: 优化播放器逻辑
- DebugPanel: 移动到 Layout 目录
- InputPanel: 新增遥控输入组件
- useUI: 优化 UI 状态管理
This commit is contained in:
李岩岩 2026-02-09 00:27:55 +08:00
parent bc4434c93d
commit f7a8e3524c
5 changed files with 637 additions and 202 deletions

View File

@ -1,10 +1,10 @@
<template>
<Transition name="slide-up">
<div
v-show="visible"
v-show="bottomPanelVisible"
class="bottom-panel"
@mouseenter="onInteraction"
@mousemove="onInteraction"
@mouseenter="showBottomPanel"
@mousemove="showBottomPanel"
>
<!-- 左侧频道信息 -->
<div class="panel-left">
@ -33,7 +33,7 @@
class="action-btn"
:class="{ active: isFavorite }"
@click="handleFavorite"
@mouseenter="onInteraction"
@mouseenter="showBottomPanel"
>
<span class="icon"></span>
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span>
@ -42,7 +42,7 @@
<button
class="action-btn"
@click="handleSwitchSource"
@mouseenter="onInteraction"
@mouseenter="showBottomPanel"
>
<span class="icon"></span>
<span>切换线路</span>
@ -51,7 +51,7 @@
<button
class="action-btn"
@click="handleSettings"
@mouseenter="onInteraction"
@mouseenter="showBottomPanel"
>
<span class="icon"></span>
<span>设置</span>
@ -63,10 +63,6 @@
<script setup>
const props = defineProps({
visible: {
type: Boolean,
default: false
},
channel: {
type: Object,
default: null
@ -97,22 +93,20 @@ const props = defineProps({
}
});
const emit = defineEmits(['favorite', 'switch-source', 'settings', 'interaction']);
const emit = defineEmits(['favorite', 'switch-source', 'settings']);
import { useUI } from "../../composables/useUI.js";
const { bottomPanelVisible, showBottomPanel } = useUI();
// LOGO
function getChannelLogo(name) {
return name ? name.slice(0, 2) : '--';
}
//
function onInteraction() {
emit('interaction');
}
//
function handleFavorite() {
emit('favorite');
onInteraction();
showBottomPanel();
}
// 线

View File

@ -0,0 +1,68 @@
<template>
<div class="debug-panel" v-if="showDebug">
<div class="debug-header">
<span>调试信息</span>
<button @click="handleClosePanel">×</button>
</div>
<div class="debug-content">
<p>调试功能开发中...</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useEvent, useKeyEvent } from "../../composables/useEvent.js";
const input = ref("");
const showDebug = ref(false);
const handleClosePanel = () => {
showDebug.value = false;
input.value = "";
};
onMounted(() => {
useEvent("keyup", (e) => {
input.value += e.key;
if (input.value.includes("bug")) {
showDebug.value = true;
}
});
useKeyEvent("Escape", () => {
if (showDebug.value) {
handleClosePanel();
}
});
});
</script>
<style scoped>
.debug-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1a1a1a;
border: 2px solid #ff6b6b;
border-radius: 12px;
padding: 16px;
z-index: 9999;
min-width: 300px;
}
.debug-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.debug-header button {
background: #333;
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<Transition name="fade">
<div v-if="visible" class="remote-input-panel">
<span class="input-text">{{ displayValue }}</span>
</div>
</Transition>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useEvent } from "../../composables/useEvent.js";
import { debounce } from "../../utils/common.js";
const emit = defineEmits(["complete"]);
//
const visible = ref(false);
const inputValue = ref([]);
//
const displayValue = computed(() => {
return inputValue.value.join("");
});
//
const closePanel = debounce(() => {
visible.value = false;
inputValue.value = [];
emit("complete", displayValue.value);
}, 3000);
//
const handleDigit = (digit) => {
//
visible.value = true;
if (inputValue.value.length == 3) {
// 3
inputValue.value.shift();
}
//
inputValue.value.push(digit);
//
closePanel();
};
onMounted(() => {
// 0-9
useEvent("keyup", (e) => {
if (e.key >= "0" && e.key <= "9") {
handleDigit(e.key);
}
});
});
</script>
<style scoped>
.remote-input-panel {
position: fixed;
top: 20px;
right: 20px;
min-width: 60px;
height: 44px;
padding: 0 16px;
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
z-index: 160;
backdrop-filter: blur(4px);
}
.input-text {
font-size: 24px;
font-weight: bold;
color: #fff;
font-family: monospace;
letter-spacing: 4px;
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -1,188 +1,245 @@
<template>
<Transition name="slide-left">
<div
v-show="visible"
v-show="leftPanelVisible"
class="left-panel"
:class="{ 'tv-mode': isTV }"
>
<!-- 第一栏分组列表 -->
<div class="column column-1" :class="{ active: activeColumn === 0 }">
<div class="group-list" :class="{ 'is-active': activeColumn === 0 }">
<!-- 置顶分组 -->
<div class="group-section pinned">
<div
class="column column-1"
:class="{ active: activeColumn === 0 }"
v-for="group in pinnedGroups"
:key="group.id"
class="group-item"
:class="{ active: selectedGroup === group.id }"
@click="onGroupSelect(group.id)"
>
<GroupList
v-model="selectedGroup"
:groups="groups"
:is-active="activeColumn === 0"
@select="onGroupSelect"
/>
<span class="group-icon">{{ group.icon }}</span>
<div class="group-info">
<span class="group-name">{{ group.name }}</span>
<span class="group-count">{{ group.count }}</span>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="divider"></div>
<!-- 普通分组 -->
<div class="group-section normal">
<div
v-for="group in normalGroups"
:key="group.id"
class="group-item"
:class="{ active: selectedGroup === group.id }"
@click="onGroupSelect(group.id)"
>
<span class="group-icon">{{ group.icon }}</span>
<div class="group-info">
<span class="group-name">{{ group.name }}</span>
<span class="group-count">{{ group.count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 第二栏频道列表 -->
<div class="column column-2" :class="{ active: activeColumn === 1 }">
<div class="channel-list" :class="{ 'is-active': activeColumn === 1 }">
<div
class="column column-2"
:class="{ active: activeColumn === 1 }"
v-for="channel in filteredChannels"
:key="channel.id"
class="channel-item"
:class="{ active: selectedChannel?.id === channel.id }"
@click="onChannelSelect(channel)"
>
<ChannelList
v-model="selectedChannel"
:channels="filteredChannels"
:is-active="activeColumn === 1"
:favorites="favorites"
:validity-map="validityMap"
@select="onChannelSelect"
/>
<div class="channel-logo">
{{ getChannelLogo(channel.name) }}
</div>
<div class="channel-info">
<div class="channel-name">{{ channel.name }}</div>
<div class="channel-meta">
<span class="source-count">
{{ getValidCount(channel) }}/{{
channel.urls?.length || 0
}}线路
</span>
<span v-if="isFavorite(channel.id)" class="favorite-icon"
></span
>
</div>
</div>
</div>
</div>
</div>
<!-- 第三栏日期列表 -->
<div class="column column-3" :class="{ active: activeColumn === 2 }">
<div class="date-list" :class="{ 'is-active': activeColumn === 2 }">
<div
class="column column-3"
:class="{ active: activeColumn === 2 }"
v-for="date in dates"
:key="date.value"
class="date-item"
:class="{ active: selectedDate === date.value }"
@click="onDateSelect(date.value)"
>
<DateList
v-model="selectedDate"
:is-active="activeColumn === 2"
@select="onDateSelect"
/>
<div class="date-day">{{ date.day }}</div>
<div class="date-label">{{ date.label }}</div>
</div>
</div>
</div>
<!-- 第四栏节目单列表 -->
<div class="column column-4" :class="{ active: activeColumn === 3 }">
<div class="program-list" :class="{ 'is-active': activeColumn === 3 }">
<div
class="column column-4"
:class="{ active: activeColumn === 3 }"
v-for="program in programs"
:key="program.id"
class="program-item"
:class="{
active: selectedProgram?.id === program.id,
current: program.isCurrent,
}"
@click="onProgramSelect(program)"
>
<ProgramList
v-model="selectedProgram"
:programs="programs"
:is-active="activeColumn === 3"
@select="onProgramSelect"
/>
<div class="program-time">{{ program.time }}</div>
<div class="program-title">
{{ program.title }}
<span v-if="program.isCurrent" class="current-badge">当前</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import GroupList from './GroupList.vue';
import ChannelList from './ChannelList.vue';
import DateList from './DateList.vue';
import ProgramList from './ProgramList.vue';
import { useUI } from '../../composables/useUI.js';
import { computed, watch } from "vue";
import { useUI } from "../../composables/useUI.js";
import { useKeyEvent } from "../../composables/useEvent.js";
import { useGroups } from "../../composables/useGroups.js";
import { useChannelFilter } from "../../composables/useChannelFilter.js";
import { useDates } from "../../composables/useDates.js";
import { usePrograms } from "../../composables/usePrograms.js";
import { useFavorites } from "../../composables/useFavorites.js";
import { useChannels } from "../../composables/useChannels.js";
const props = defineProps({
visible: {
type: Boolean,
default: false
},
channels: {
type: Array,
default: () => []
},
groups: {
type: Array,
default: () => []
},
currentChannel: {
type: Object,
default: null
},
favorites: {
type: Set,
default: () => new Set()
default: null,
},
validityMap: {
type: Map,
default: () => new Map()
}
default: () => new Map(),
},
});
const emit = defineEmits(['close', 'play', 'lookback']);
const emit = defineEmits(["play"]);
const { isTV, currentActiveColumn, moveColumn } = useUI();
const { isTV, leftPanelVisible, hideLeftPanel, currentActiveColumn } = useUI();
const activeColumn = computed(() => currentActiveColumn.value);
//
const selectedGroup = ref('');
const selectedChannel = ref(null);
const selectedDate = ref('');
const selectedProgram = ref(null);
// hooks
const { favorites } = useFavorites();
const { channels, groups } = useChannels();
//
const filteredChannels = computed(() => {
if (!selectedGroup.value) return props.channels;
// ===== =====
const {
selectedGroup,
pinnedGroups,
normalGroups,
selectGroup,
setGroups,
initSelectedGroup,
} = useGroups();
//
if (selectedGroup.value === 'recent') {
// TODO:
return [];
}
if (selectedGroup.value === 'favorite') {
// TODO:
return props.channels.filter(c => props.favorites.has(c.id));
}
// ===== =====
const {
selectedChannel,
filteredChannels,
selectChannel,
setChannels,
setSelectedGroup: setChannelFilterGroup,
setFavorites,
setValidityMap,
getFirstChannel,
getChannelLogo,
isFavorite,
getValidCount,
} = useChannelFilter();
return props.channels.filter(c => c.group === selectedGroup.value);
// ===== =====
const { selectedDate, dates, selectDate, initToday } = useDates();
// ===== =====
const { selectedProgram, programs, selectProgram, loadProgramsByDate } =
usePrograms();
// hooks
watch(groups, (g) => setGroups(g), { immediate: true });
watch(channels, (c) => setChannels(c), { immediate: true });
//
watch(favorites, (favs) => setFavorites(favs), { immediate: true });
watch(
() => props.validityMap,
(map) => setValidityMap(map),
{ immediate: true },
);
//
watch(selectedGroup, (group) => {
setChannelFilterGroup(group);
});
// TODO: EPG
const programs = ref([
{ id: 1, time: '08:00', title: '朝闻天下', isCurrent: false },
{ id: 2, time: '09:00', title: '今日说法', isCurrent: false },
{ id: 3, time: '10:00', title: '电视剧:繁花', isCurrent: true },
{ id: 4, time: '12:00', title: '新闻30分', isCurrent: false },
]);
//
watch(() => props.visible, (val) => {
watch(leftPanelVisible, (val) => {
if (val && props.currentChannel) {
// ///
selectedGroup.value = props.currentChannel.group || props.groups[0] || '';
selectedChannel.value = props.currentChannel;
selectedDate.value = new Date().toISOString().split('T')[0];
// TODO:
initSelectedGroup(props.currentChannel, groups.value[0]);
selectChannel(props.currentChannel);
initToday();
}
});
//
function onGroupSelect(groupId) {
selectGroup(groupId);
//
const firstChannel = filteredChannels.value[0];
const firstChannel = getFirstChannel();
if (firstChannel) {
selectedChannel.value = firstChannel;
selectChannel(firstChannel);
}
}
//
function onChannelSelect(channel) {
//
emit('play', channel);
//
emit('close');
selectChannel(channel);
emit("play", channel);
hideLeftPanel();
}
//
function onDateSelect(dateValue) {
//
// TODO:
selectDate(dateValue);
loadProgramsByDate(dateValue);
}
//
function onProgramSelect(program) {
// TODO:
emit('lookback', program);
//
emit('close');
selectProgram(program);
// TODO:
hideLeftPanel();
}
// TV
watch(activeColumn, (val) => {
//
});
//
defineExpose({
moveColumn,
});
useKeyEvent("Escape", hideLeftPanel);
</script>
<style scoped>
@ -234,4 +291,231 @@ defineExpose({
.slide-left-leave-to {
transform: translateX(-100%);
}
/* ===== 分组列表样式 ===== */
.group-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.9);
padding: 8px 0;
}
.group-section {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px;
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 8px;
}
.group-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.group-item:hover,
.group-item.active {
background: rgba(255, 255, 255, 0.2);
}
.group-icon {
font-size: 20px;
width: 24px;
text-align: center;
}
.group-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-name {
font-size: 14px;
color: #fff;
}
.group-count {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
/* ===== 频道列表样式 ===== */
.channel-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.85);
padding: 8px;
}
.channel-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
}
.channel-item:hover,
.channel-item.active {
background: rgba(255, 255, 255, 0.2);
}
.channel-logo {
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
font-weight: bold;
}
.channel-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.channel-name {
font-size: 14px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.favorite-icon {
color: #ffd700;
}
/* ===== 日期列表样式 ===== */
.date-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.8);
padding: 8px;
}
.date-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 8px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
}
.date-item:hover,
.date-item.active {
background: rgba(255, 255, 255, 0.2);
}
.date-day {
font-size: 16px;
font-weight: bold;
color: #fff;
}
.date-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
/* ===== 节目单列表样式 ===== */
.program-list {
width: 100%;
height: 100%;
overflow-y: auto;
background: rgba(0, 0, 0, 0.75);
padding: 8px;
}
.program-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
border-left: 3px solid transparent;
}
.program-item:hover,
.program-item.active {
background: rgba(255, 255, 255, 0.2);
}
.program-item.active {
border-left-color: #fff;
}
.program-item.current {
border-left-color: #ff6b6b;
}
.program-time {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
font-family: monospace;
}
.program-title {
font-size: 14px;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.current-badge {
font-size: 10px;
background: #ff6b6b;
color: #fff;
padding: 2px 6px;
border-radius: 4px;
}
/* TV 模式焦点样式 */
.is-active .group-item:focus,
.is-active .channel-item:focus,
.is-active .date-item:focus,
.is-active .program-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,12 +1,6 @@
<template>
<div class="video-player">
<video
ref="videoRef"
class="video-element"
controls
autoplay
playsinline
></video>
<video ref="videoRef" class="video-element" autoplay playsinline></video>
<!-- 错误提示 -->
<div v-if="error" class="error-overlay">
@ -18,90 +12,92 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import Hls from 'hls.js'
import { ref, onMounted, onUnmounted, watch } from "vue";
import Hls from "hls.js";
const props = defineProps({
url: String,
title: String
})
});
const emit = defineEmits(['error', 'ready'])
const emit = defineEmits(["error", "ready"]);
const videoRef = ref(null)
const error = ref(null)
let hls = null
const videoRef = ref(null);
const error = ref(null);
let hls = null;
const initPlayer = () => {
if (!props.url) return
if (!props.url) return;
error.value = null
const video = videoRef.value
error.value = null;
const video = videoRef.value;
// HLS
if (hls) {
hls.destroy()
hls = null
hls.destroy();
hls = null;
}
const isHLS = props.url.includes('.m3u8')
const isHLS = props.url.includes(".m3u8");
if (isHLS && Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
maxBufferLength: 30,
})
});
hls.loadSource(props.url)
hls.attachMedia(video)
hls.loadSource(props.url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(e => console.log('播放被阻止:', e))
})
video.play().catch((e) => console.log("播放被阻止:", e));
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
error.value = data.type
emit('error', { type: data.type, fatal: true })
error.value = data.type;
emit("error", { type: data.type, fatal: true });
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = props.url
video.play()
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = props.url;
video.play();
} else {
video.src = props.url
video.src = props.url;
}
video.onerror = () => {
error.value = 'networkError'
emit('error', { type: 'networkError', fatal: true })
}
}
error.value = "networkError";
emit("error", { type: "networkError", fatal: true });
};
};
watch(() => props.url, (newUrl) => {
if (newUrl) initPlayer()
})
watch(
() => props.url,
(newUrl) => {
if (newUrl) initPlayer();
},
);
onMounted(() => {
if (props.url) initPlayer()
})
if (props.url) initPlayer();
});
onUnmounted(() => {
if (hls) {
hls.destroy()
hls = null
hls.destroy();
hls = null;
}
})
});
//
defineExpose({
play: (url) => {
if (url) {
props.url = url
initPlayer()
// props.url = url
initPlayer();
}
}
})
},
});
</script>
<style scoped>