From d85823cc8d33c9716d4e3dbd06e8b98e90a0b8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Thu, 5 Feb 2026 18:31:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=9A=84=E5=9B=9B=E6=A0=8F=E5=B8=83=E5=B1=80=E5=92=8C?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 composables/useUI.js - UI 状态管理和防抖隐藏 - 新增 composables/useStorage.js - Storage 封装 - 新增 Layout 组件 - LeftPanel(四栏)/BottomPanel - 新增 Modals 组件 - SourceModal/SettingsModal - 新增 DebugPanel 组件 - 重写 App.vue - 完整播放控制逻辑 - 渐进式频道加载 - HTTP HEAD 测速排序 - 自动选线/失败重试 - 收藏/最近播放持久化 - 更新 VideoPlayer - 错误事件通知 - 更新 SettingsModal - 缓存管理功能 - 新增 TODO.md --- .gitignore | 3 - TODO.md | 22 + ui/src/App.vue | 1506 ++++++-------------- ui/src/components/DebugPanel.vue | 43 + ui/src/components/Layout/BottomPanel.vue | 273 ++++ ui/src/components/Layout/ChannelList.vue | 160 +++ ui/src/components/Layout/DateList.vue | 108 ++ ui/src/components/Layout/GroupList.vue | 169 +++ ui/src/components/Layout/LeftPanel.vue | 237 +++ ui/src/components/Layout/ProgramList.vue | 112 ++ ui/src/components/Modals/SettingsModal.vue | 366 +++++ ui/src/components/Modals/SourceModal.vue | 142 ++ ui/src/components/VideoPlayer.vue | 4 + ui/src/composables/useStorage.js | 169 +++ ui/src/composables/useUI.js | 137 ++ 15 files changed, 2361 insertions(+), 1090 deletions(-) create mode 100644 TODO.md create mode 100644 ui/src/components/DebugPanel.vue create mode 100644 ui/src/components/Layout/BottomPanel.vue create mode 100644 ui/src/components/Layout/ChannelList.vue create mode 100644 ui/src/components/Layout/DateList.vue create mode 100644 ui/src/components/Layout/GroupList.vue create mode 100644 ui/src/components/Layout/LeftPanel.vue create mode 100644 ui/src/components/Layout/ProgramList.vue create mode 100644 ui/src/components/Modals/SettingsModal.vue create mode 100644 ui/src/components/Modals/SourceModal.vue create mode 100644 ui/src/composables/useStorage.js create mode 100644 ui/src/composables/useUI.js diff --git a/.gitignore b/.gitignore index 00cb619..bea0c3a 100644 --- a/.gitignore +++ b/.gitignore @@ -90,11 +90,8 @@ lerna-debug.log* # ================================================ # Environment # ================================================ -.env .env.local .env.*.local -.env.development -.env.test # ================================================ # Testing diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..edb0688 --- /dev/null +++ b/TODO.md @@ -0,0 +1,22 @@ +# TODO 列表 + +## 高优先级 + +- [x] 多平台打包配置 +- [x] Storage API 抽象层 +- [x] UI 布局改造(四栏布局) +- [x] 频道加载与测速排序 +- [x] 播放控制(自动选线、失败重试) +- [x] 缓存管理(清除功能) + +## 中优先级 + +- [ ] 最近播放逻辑完善(数据持久化) +- [ ] 收藏功能完善(实时更新收藏状态) +- [ ] EPG 节目单数据获取与解析 + +## 低优先级(后排) + +- [ ] TV 遥控器导航(四栏之间方向键移动焦点) +- [ ] 时移播放(回看功能) +- [ ] 搜索功能(Web/Desktop) diff --git a/ui/src/App.vue b/ui/src/App.vue index d2e0aa5..1b169d2 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -1,618 +1,477 @@ diff --git a/ui/src/components/DebugPanel.vue b/ui/src/components/DebugPanel.vue new file mode 100644 index 0000000..7d68926 --- /dev/null +++ b/ui/src/components/DebugPanel.vue @@ -0,0 +1,43 @@ + + + diff --git a/ui/src/components/Layout/BottomPanel.vue b/ui/src/components/Layout/BottomPanel.vue new file mode 100644 index 0000000..b4c5490 --- /dev/null +++ b/ui/src/components/Layout/BottomPanel.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/ui/src/components/Layout/ChannelList.vue b/ui/src/components/Layout/ChannelList.vue new file mode 100644 index 0000000..f6eb179 --- /dev/null +++ b/ui/src/components/Layout/ChannelList.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/ui/src/components/Layout/DateList.vue b/ui/src/components/Layout/DateList.vue new file mode 100644 index 0000000..42f6780 --- /dev/null +++ b/ui/src/components/Layout/DateList.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/ui/src/components/Layout/GroupList.vue b/ui/src/components/Layout/GroupList.vue new file mode 100644 index 0000000..2fb49f5 --- /dev/null +++ b/ui/src/components/Layout/GroupList.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/ui/src/components/Layout/LeftPanel.vue b/ui/src/components/Layout/LeftPanel.vue new file mode 100644 index 0000000..a4f0088 --- /dev/null +++ b/ui/src/components/Layout/LeftPanel.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/ui/src/components/Layout/ProgramList.vue b/ui/src/components/Layout/ProgramList.vue new file mode 100644 index 0000000..040eb45 --- /dev/null +++ b/ui/src/components/Layout/ProgramList.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/ui/src/components/Modals/SettingsModal.vue b/ui/src/components/Modals/SettingsModal.vue new file mode 100644 index 0000000..6a068ba --- /dev/null +++ b/ui/src/components/Modals/SettingsModal.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/ui/src/components/Modals/SourceModal.vue b/ui/src/components/Modals/SourceModal.vue new file mode 100644 index 0000000..dffb872 --- /dev/null +++ b/ui/src/components/Modals/SourceModal.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/ui/src/components/VideoPlayer.vue b/ui/src/components/VideoPlayer.vue index 7386690..78cec8b 100644 --- a/ui/src/components/VideoPlayer.vue +++ b/ui/src/components/VideoPlayer.vue @@ -26,6 +26,8 @@ const props = defineProps({ title: String }) +const emit = defineEmits(['error', 'ready']) + const videoRef = ref(null) const error = ref(null) let hls = null @@ -60,6 +62,7 @@ const initPlayer = () => { hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { error.value = data.type + emit('error', { type: data.type, fatal: true }) } }) } else if (video.canPlayType('application/vnd.apple.mpegurl')) { @@ -71,6 +74,7 @@ const initPlayer = () => { video.onerror = () => { error.value = 'networkError' + emit('error', { type: 'networkError', fatal: true }) } } diff --git a/ui/src/composables/useStorage.js b/ui/src/composables/useStorage.js new file mode 100644 index 0000000..1e1246a --- /dev/null +++ b/ui/src/composables/useStorage.js @@ -0,0 +1,169 @@ +import { ref, onMounted } from 'vue'; +import createStorage, { Subscription, Channel, SourceValidity, Preferences, PlayHistory, CacheMeta } from '../storage/index.js'; + +const storage = createStorage(); +const isReady = ref(false); + +export { Subscription, Channel, SourceValidity, Preferences, PlayHistory, CacheMeta }; + +export function useStorage() { + onMounted(async () => { + if (storage.init) { + await storage.init(); + } + isReady.value = true; + }); + + // 基础操作 + const get = async (key) => { + return await storage.get(key); + }; + + const set = async (key, value) => { + return await storage.set(key, value); + }; + + const remove = async (key) => { + return await storage.remove(key); + }; + + // 频道数据 + const getChannels = async () => { + const channels = await storage.getChannels(); + return channels.map(c => new Channel(c)); + }; + + const setChannels = async (channels) => { + return await storage.setChannels(channels); + }; + + const getGroups = async () => { + return await storage.getGroups(); + }; + + // 检查列表缓存是否有效 + const isListCacheValid = async () => { + const prefs = await storage.getPreferences(); + const ttl = prefs.listCacheTTL || 24 * 60 * 60 * 1000; + return await storage.isCacheValid('channels', ttl); + }; + + // 线路有效性 + const getValidity = async (url) => { + const v = await storage.getValidity(url); + return v ? new SourceValidity(v) : null; + }; + + const setValidity = async (url, validity) => { + return await storage.setValidity(url, validity); + }; + + const getAllValidity = async () => { + const list = await storage.getAllValidity(); + return list.map(v => new SourceValidity(v)); + }; + + // 检查有效性缓存是否有效 + const isValidityCacheValid = async (url) => { + const prefs = await storage.getPreferences(); + const ttl = prefs.validityCacheTTL || 12 * 60 * 60 * 1000; + const validity = await storage.getValidity(url); + if (!validity || !validity.checkedAt) return false; + return Date.now() - validity.checkedAt < ttl; + }; + + // 用户偏好 + const getPreferences = async () => { + const prefs = await storage.getPreferences(); + return new Preferences(prefs); + }; + + const setPreferences = async (prefs) => { + return await storage.setPreferences(prefs); + }; + + // 播放历史 + const getHistory = async (limit = 50) => { + const list = await storage.getHistory(limit); + return list.map(h => new PlayHistory(h)); + }; + + const addHistory = async (item) => { + return await storage.addHistory(item); + }; + + const clearHistory = async () => { + return await storage.clearHistory(); + }; + + // 订阅源 + const getSubscriptions = async () => { + const list = await storage.getSubscriptions(); + return list.map(s => new Subscription(s)); + }; + + const setSubscriptions = async (subs) => { + return await storage.setSubscriptions(subs); + }; + + // 缓存元数据 + const getCacheMeta = async (key) => { + const meta = await storage.getCacheMeta(key); + return meta ? new CacheMeta(meta) : null; + }; + + const setCacheMeta = async (key, meta) => { + return await storage.setCacheMeta(key, meta); + }; + + const isCacheValid = async (key, ttl) => { + return await storage.isCacheValid(key, ttl); + }; + + // 清空所有数据 + const clear = async () => { + return await storage.clear(); + }; + + return { + isReady, + // 基础 + get, + set, + remove, + clear, + // 频道 + getChannels, + setChannels, + getGroups, + isListCacheValid, + // 有效性 + getValidity, + setValidity, + getAllValidity, + isValidityCacheValid, + // 偏好 + getPreferences, + setPreferences, + // 历史 + getHistory, + addHistory, + clearHistory, + // 订阅 + getSubscriptions, + setSubscriptions, + // 元数据 + getCacheMeta, + setCacheMeta, + isCacheValid, + }; +} + +// 创建单例 +let storageInstance = null; +export function useStorageSingleton() { + if (!storageInstance) { + storageInstance = useStorage(); + } + return storageInstance; +} diff --git a/ui/src/composables/useUI.js b/ui/src/composables/useUI.js new file mode 100644 index 0000000..5dbe6d0 --- /dev/null +++ b/ui/src/composables/useUI.js @@ -0,0 +1,137 @@ +import { ref, computed } from 'vue'; + +// 防抖工具函数 +function debounce(fn, delay) { + let timer = null; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; +} + +// 当前激活的栏索引(用于TV导航) +const activeColumnIndex = ref(0); +const leftPanelVisible = ref(false); +const bottomPanelVisible = ref(false); + +// 防抖隐藏底部栏 +const debouncedHideBottomPanel = debounce(() => { + bottomPanelVisible.value = false; +}, 3000); + +// 当前选中的分组/频道/日期/节目 +const selectedGroup = ref(''); +const selectedChannel = ref(null); +const selectedDate = ref(''); +const selectedProgram = ref(null); + +export function useUI() { + const platform = import.meta.env.VITE_PLATFORM || 'web'; + const isTV = platform === 'tv'; + + // 显示左侧面板 + const showLeftPanel = () => { + leftPanelVisible.value = true; + bottomPanelVisible.value = false; + activeColumnIndex.value = 0; + }; + + // 隐藏左侧面板 + const hideLeftPanel = () => { + leftPanelVisible.value = false; + activeColumnIndex.value = 0; + }; + + // 切换左侧面板 + const toggleLeftPanel = () => { + if (leftPanelVisible.value) { + hideLeftPanel(); + } else { + showLeftPanel(); + } + }; + + // 显示底部栏(启动防抖隐藏) + const showBottomPanel = () => { + bottomPanelVisible.value = true; + debouncedHideBottomPanel(); + }; + + // 隐藏底部栏 + const hideBottomPanel = () => { + bottomPanelVisible.value = false; + }; + + // 底部栏交互(重置防抖) + const onBottomInteraction = () => { + debouncedHideBottomPanel(); + }; + + // 设置选中项 + const setSelectedGroup = (group) => { + selectedGroup.value = group; + }; + + const setSelectedChannel = (channel) => { + selectedChannel.value = channel; + }; + + const setSelectedDate = (date) => { + selectedDate.value = date; + }; + + const setSelectedProgram = (program) => { + selectedProgram.value = program; + }; + + // TV 导航:切换栏 + const moveColumn = (direction) => { + if (!leftPanelVisible.value) return; + + const maxIndex = 3; // 0-3 四栏 + if (direction === 'right') { + activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex); + } else if (direction === 'left') { + activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0); + } + }; + + // TV 导航:获取当前激活的栏索引 + const currentActiveColumn = computed(() => activeColumnIndex.value); + + return { + // 状态 + leftPanelVisible, + bottomPanelVisible, + selectedGroup, + selectedChannel, + selectedDate, + selectedProgram, + + // 计算属性 + isTV, + currentActiveColumn, + + // 方法 + showLeftPanel, + hideLeftPanel, + toggleLeftPanel, + showBottomPanel, + hideBottomPanel, + onBottomInteraction, + setSelectedGroup, + setSelectedChannel, + setSelectedDate, + setSelectedProgram, + moveColumn, + }; +} + +// 创建单例 +let uiInstance = null; +export function useUISingleton() { + if (!uiInstance) { + uiInstance = useUI(); + } + return uiInstance; +}