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> <template>
<Transition name="slide-up"> <Transition name="slide-up">
<div <div
v-show="visible" v-show="bottomPanelVisible"
class="bottom-panel" class="bottom-panel"
@mouseenter="onInteraction" @mouseenter="showBottomPanel"
@mousemove="onInteraction" @mousemove="showBottomPanel"
> >
<!-- 左侧频道信息 --> <!-- 左侧频道信息 -->
<div class="panel-left"> <div class="panel-left">
@ -33,7 +33,7 @@
class="action-btn" class="action-btn"
:class="{ active: isFavorite }" :class="{ active: isFavorite }"
@click="handleFavorite" @click="handleFavorite"
@mouseenter="onInteraction" @mouseenter="showBottomPanel"
> >
<span class="icon"></span> <span class="icon"></span>
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span> <span>{{ isFavorite ? '已收藏' : '收藏' }}</span>
@ -42,7 +42,7 @@
<button <button
class="action-btn" class="action-btn"
@click="handleSwitchSource" @click="handleSwitchSource"
@mouseenter="onInteraction" @mouseenter="showBottomPanel"
> >
<span class="icon"></span> <span class="icon"></span>
<span>切换线路</span> <span>切换线路</span>
@ -51,7 +51,7 @@
<button <button
class="action-btn" class="action-btn"
@click="handleSettings" @click="handleSettings"
@mouseenter="onInteraction" @mouseenter="showBottomPanel"
> >
<span class="icon"></span> <span class="icon"></span>
<span>设置</span> <span>设置</span>
@ -63,10 +63,6 @@
<script setup> <script setup>
const props = defineProps({ const props = defineProps({
visible: {
type: Boolean,
default: false
},
channel: { channel: {
type: Object, type: Object,
default: null 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 // LOGO
function getChannelLogo(name) { function getChannelLogo(name) {
return name ? name.slice(0, 2) : '--'; return name ? name.slice(0, 2) : '--';
} }
//
function onInteraction() {
emit('interaction');
}
// //
function handleFavorite() { function handleFavorite() {
emit('favorite'); 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> <template>
<Transition name="slide-left"> <Transition name="slide-left">
<div <div
v-show="visible" v-show="leftPanelVisible"
class="left-panel" class="left-panel"
:class="{ 'tv-mode': isTV }" :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 <div
class="column column-1" v-for="group in pinnedGroups"
:class="{ active: activeColumn === 0 }" :key="group.id"
class="group-item"
:class="{ active: selectedGroup === group.id }"
@click="onGroupSelect(group.id)"
> >
<GroupList <span class="group-icon">{{ group.icon }}</span>
v-model="selectedGroup" <div class="group-info">
:groups="groups" <span class="group-name">{{ group.name }}</span>
:is-active="activeColumn === 0" <span class="group-count">{{ group.count }}</span>
@select="onGroupSelect" </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>
<!-- 第二栏频道列表 --> <!-- 第二栏频道列表 -->
<div class="column column-2" :class="{ active: activeColumn === 1 }">
<div class="channel-list" :class="{ 'is-active': activeColumn === 1 }">
<div <div
class="column column-2" v-for="channel in filteredChannels"
:class="{ active: activeColumn === 1 }" :key="channel.id"
class="channel-item"
:class="{ active: selectedChannel?.id === channel.id }"
@click="onChannelSelect(channel)"
> >
<ChannelList <div class="channel-logo">
v-model="selectedChannel" {{ getChannelLogo(channel.name) }}
:channels="filteredChannels" </div>
:is-active="activeColumn === 1" <div class="channel-info">
:favorites="favorites" <div class="channel-name">{{ channel.name }}</div>
:validity-map="validityMap" <div class="channel-meta">
@select="onChannelSelect" <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>
<!-- 第三栏日期列表 --> <!-- 第三栏日期列表 -->
<div class="column column-3" :class="{ active: activeColumn === 2 }">
<div class="date-list" :class="{ 'is-active': activeColumn === 2 }">
<div <div
class="column column-3" v-for="date in dates"
:class="{ active: activeColumn === 2 }" :key="date.value"
class="date-item"
:class="{ active: selectedDate === date.value }"
@click="onDateSelect(date.value)"
> >
<DateList <div class="date-day">{{ date.day }}</div>
v-model="selectedDate" <div class="date-label">{{ date.label }}</div>
:is-active="activeColumn === 2" </div>
@select="onDateSelect" </div>
/>
</div> </div>
<!-- 第四栏节目单列表 --> <!-- 第四栏节目单列表 -->
<div class="column column-4" :class="{ active: activeColumn === 3 }">
<div class="program-list" :class="{ 'is-active': activeColumn === 3 }">
<div <div
class="column column-4" v-for="program in programs"
:class="{ active: activeColumn === 3 }" :key="program.id"
class="program-item"
:class="{
active: selectedProgram?.id === program.id,
current: program.isCurrent,
}"
@click="onProgramSelect(program)"
> >
<ProgramList <div class="program-time">{{ program.time }}</div>
v-model="selectedProgram" <div class="program-title">
:programs="programs" {{ program.title }}
:is-active="activeColumn === 3" <span v-if="program.isCurrent" class="current-badge">当前</span>
@select="onProgramSelect" </div>
/> </div>
</div>
</div> </div>
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { computed, watch } from "vue";
import GroupList from './GroupList.vue'; import { useUI } from "../../composables/useUI.js";
import ChannelList from './ChannelList.vue'; import { useKeyEvent } from "../../composables/useEvent.js";
import DateList from './DateList.vue'; import { useGroups } from "../../composables/useGroups.js";
import ProgramList from './ProgramList.vue'; import { useChannelFilter } from "../../composables/useChannelFilter.js";
import { useUI } from '../../composables/useUI.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({ const props = defineProps({
visible: {
type: Boolean,
default: false
},
channels: {
type: Array,
default: () => []
},
groups: {
type: Array,
default: () => []
},
currentChannel: { currentChannel: {
type: Object, type: Object,
default: null default: null,
},
favorites: {
type: Set,
default: () => new Set()
}, },
validityMap: { validityMap: {
type: Map, 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 activeColumn = computed(() => currentActiveColumn.value);
// // hooks
const selectedGroup = ref(''); const { favorites } = useFavorites();
const selectedChannel = ref(null); const { channels, groups } = useChannels();
const selectedDate = ref('');
const selectedProgram = ref(null);
// // ===== =====
const filteredChannels = computed(() => { const {
if (!selectedGroup.value) return props.channels; selectedGroup,
pinnedGroups,
normalGroups,
selectGroup,
setGroups,
initSelectedGroup,
} = useGroups();
// // ===== =====
if (selectedGroup.value === 'recent') { const {
// TODO: selectedChannel,
return []; filteredChannels,
} selectChannel,
if (selectedGroup.value === 'favorite') { setChannels,
// TODO: setSelectedGroup: setChannelFilterGroup,
return props.channels.filter(c => props.favorites.has(c.id)); 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) { if (val && props.currentChannel) {
// /// initSelectedGroup(props.currentChannel, groups.value[0]);
selectedGroup.value = props.currentChannel.group || props.groups[0] || ''; selectChannel(props.currentChannel);
selectedChannel.value = props.currentChannel; initToday();
selectedDate.value = new Date().toISOString().split('T')[0];
// TODO:
} }
}); });
// //
function onGroupSelect(groupId) { function onGroupSelect(groupId) {
selectGroup(groupId);
// //
const firstChannel = filteredChannels.value[0]; const firstChannel = getFirstChannel();
if (firstChannel) { if (firstChannel) {
selectedChannel.value = firstChannel; selectChannel(firstChannel);
} }
} }
// //
function onChannelSelect(channel) { function onChannelSelect(channel) {
// selectChannel(channel);
emit('play', channel); emit("play", channel);
// hideLeftPanel();
emit('close');
} }
// //
function onDateSelect(dateValue) { function onDateSelect(dateValue) {
// selectDate(dateValue);
// TODO: loadProgramsByDate(dateValue);
} }
// //
function onProgramSelect(program) { function onProgramSelect(program) {
// TODO: selectProgram(program);
emit('lookback', program); // TODO:
// hideLeftPanel();
emit('close');
} }
// TV useKeyEvent("Escape", hideLeftPanel);
watch(activeColumn, (val) => {
//
});
//
defineExpose({
moveColumn,
});
</script> </script>
<style scoped> <style scoped>
@ -234,4 +291,231 @@ defineExpose({
.slide-left-leave-to { .slide-left-leave-to {
transform: translateX(-100%); 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> </style>

View File

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