feat: 新增业务逻辑 composables

- useFavorites: 收藏管理
- useHistory: 播放历史
- useSettings: 用户设置
- useChannels: 频道数据获取和解析
- useGroups: 分组管理
- useChannelFilter: 频道过滤
- useDates: 日期列表
- usePrograms: 节目单管理
- useEvent: 键盘事件
This commit is contained in:
李岩岩 2026-02-09 00:27:46 +08:00
parent 5f8165b236
commit bc4434c93d
11 changed files with 702 additions and 42 deletions

View File

@ -0,0 +1,94 @@
import { ref, computed } from "vue";
export function useChannelFilter() {
const selectedChannel = ref(null);
const allChannels = ref([]);
const selectedGroup = ref("");
const favorites = ref(new Set());
const validityMap = ref(new Map());
// 过滤后的频道
const filteredChannels = computed(() => {
if (!selectedGroup.value) return allChannels.value;
// 置顶分组特殊处理
if (selectedGroup.value === "recent") {
// TODO: 返回最近播放
return [];
}
if (selectedGroup.value === "favorite") {
return allChannels.value.filter((c) => favorites.value.has(c.id));
}
return allChannels.value.filter((c) => c.group === selectedGroup.value);
});
// 获取频道 LOGO取前两个字符
function getChannelLogo(name) {
return name?.slice(0, 2) || "--";
}
// 检查是否收藏
function isFavorite(channelId) {
return favorites.value.has(channelId);
}
// 获取有效线路数
function getValidCount(channel) {
if (!channel?.urls) return 0;
return channel.urls.filter((url) => {
const validity = validityMap.value.get(url);
return validity?.status === "online";
}).length;
}
// 选择频道
function selectChannel(channel) {
selectedChannel.value = channel;
return channel;
}
// 设置数据源
function setChannels(channels) {
allChannels.value = channels;
}
function setSelectedGroup(group) {
selectedGroup.value = group;
}
function setFavorites(favSet) {
favorites.value = favSet;
}
function setValidityMap(map) {
validityMap.value = map;
}
// 根据当前频道初始化
function initSelectedChannel(channel) {
if (channel) {
selectedChannel.value = channel;
}
}
// 获取第一个频道(用于分组切换时自动选中)
function getFirstChannel() {
return filteredChannels.value[0] || null;
}
return {
selectedChannel,
filteredChannels,
selectChannel,
setChannels,
setSelectedGroup,
setFavorites,
setValidityMap,
initSelectedChannel,
getFirstChannel,
getChannelLogo,
isFavorite,
getValidCount,
};
}

View File

@ -0,0 +1,151 @@
import { ref, computed, onMounted } from "vue";
import { useStorage } from "./useStorage.js";
// 全局状态
const channels = ref([]);
const loading = ref(false);
let initialized = false;
export function useChannels() {
const storage = useStorage();
// 分组列表(基于频道计算)
const groups = computed(() => {
return [...new Set(channels.value.map((c) => c.group))];
});
// 加载频道数据
const loadChannels = async (force = false) => {
if (loading.value) return;
loading.value = true;
try {
// 检查缓存
if (!force) {
const isValid = await storage.isListCacheValid();
if (isValid) {
const cached = await storage.getChannels();
if (cached && cached.length > 0) {
channels.value = cached;
loading.value = false;
// 后台刷新
refreshChannelsInBackground();
return;
}
}
}
// 从网络加载
const text = await fetchChannelData();
if (text && !text.startsWith("ERROR")) {
const parsed = parseChannelData(text);
channels.value = parsed;
await storage.setChannels(parsed);
}
} finally {
loading.value = false;
}
};
// 后台刷新频道
const refreshChannelsInBackground = async () => {
try {
const text = await fetchChannelData();
if (text && !text.startsWith("ERROR")) {
const parsed = parseChannelData(text);
if (parsed.length > 0) {
channels.value = parsed;
await storage.setChannels(parsed);
}
}
} catch (e) {
console.error("后台刷新失败:", e);
}
};
// 获取频道数据(网络)
const fetchChannelData = async () => {
const platform = import.meta.env.VITE_PLATFORM || "web";
// Android/TV 使用原生接口
if ((platform === "android" || platform === "tv") && window.AndroidAsset) {
try {
return window.AndroidAsset.readChannelData();
} catch (e) {
console.error("读取本地数据失败:", e);
}
}
// Web/Desktop 从网络加载
try {
const response = await fetch(
"https://cdn.jsdelivr.net/gh/Guovin/iptv-api@gd/output/result.txt"
);
if (response.ok) {
return await response.text();
}
} catch (e) {
console.error("网络加载失败:", e);
}
return null;
};
// 解析频道数据
const parseChannelData = (text) => {
const result = [];
const lines = text.split("\n");
let currentGroup = "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.includes("#genre#")) {
currentGroup = trimmed.split(",")[0];
} else if (trimmed.includes(",")) {
const [name, url] = trimmed.split(",").map((s) => s.trim());
if (name && url) {
const existing = result.find(
(c) => c.name === name && c.group === currentGroup
);
if (existing) {
existing.urls.push(url);
} else {
result.push({
id: `${currentGroup}_${name}`,
name,
group: currentGroup,
urls: [url],
logo: "",
epgId: "",
});
}
}
}
}
return result;
};
// 根据索引获取频道
const getChannelByIndex = (index) => {
return channels.value[index] || null;
};
// 初始化加载
onMounted(() => {
if (!initialized) {
loadChannels();
initialized = true;
}
});
return {
channels,
groups,
loading,
loadChannels,
getChannelByIndex,
};
}

View File

@ -0,0 +1,47 @@
import { ref, computed } from "vue";
// 日期标签
const DAY_LABELS = ["今天", "明天", "后天"];
export function useDates() {
const selectedDate = ref("");
// 生成日期列表(今天、明天、后天...
const dates = computed(() => {
const list = [];
const today = new Date();
for (let i = 0; i < 7; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
const value = date.toISOString().split("T")[0];
const day = `${date.getMonth() + 1}/${date.getDate()}`;
const label =
i < 3
? DAY_LABELS[i]
: `${date.getMonth() + 1}${date.getDate()}`;
list.push({ value, day, label });
}
return list;
});
// 选择日期
function selectDate(dateValue) {
selectedDate.value = dateValue;
}
// 初始化为今天
function initToday() {
selectedDate.value = new Date().toISOString().split("T")[0];
}
return {
selectedDate,
dates,
selectDate,
initToday,
};
}

View File

@ -0,0 +1,19 @@
import { onBeforeUnmount } from "vue";
export const useEvent = (eventName, callback) => {
window.addEventListener(eventName, callback);
onBeforeUnmount(() => {
window.removeEventListener(eventName, callback);
});
};
export const useKeyEvent = (key, callback) => {
const handler = (e) => {
console.log("🚀 ~ handler ~ e:", e.key);
if (e.key === key) {
callback(e);
}
};
useEvent("keyup", handler);
};

View File

@ -0,0 +1,76 @@
import { ref, onMounted } from "vue";
import { useStorage } from "./useStorage.js";
const STORAGE_KEY = "favorites_data";
// 全局状态
const favorites = ref(new Set());
let initialized = false;
let loadPromise = null;
export function useFavorites() {
const storage = useStorage();
// 加载收藏(从 useStorage
const loadFavorites = async () => {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const saved = await storage.get(STORAGE_KEY);
if (saved) {
favorites.value = new Set(saved);
}
} catch (e) {
console.error("加载收藏失败:", e);
}
initialized = true;
})();
return loadPromise;
};
// 保存收藏(到 useStorage
const saveFavorites = async () => {
try {
await storage.set(STORAGE_KEY, [...favorites.value]);
} catch (e) {
console.error("保存收藏失败:", e);
}
};
// 切换收藏状态
const toggleFavorite = async (channelId) => {
if (!channelId) return;
if (favorites.value.has(channelId)) {
favorites.value.delete(channelId);
} else {
favorites.value.add(channelId);
}
await saveFavorites();
};
// 检查是否已收藏
const isFavorite = (channelId) => {
return favorites.value.has(channelId);
};
// 获取所有收藏
const getFavorites = () => {
return [...favorites.value];
};
// 初始化加载
onMounted(() => {
if (!initialized) {
loadFavorites();
}
});
return {
favorites,
toggleFavorite,
isFavorite,
getFavorites,
};
}

View File

@ -0,0 +1,82 @@
import { ref, computed } from "vue";
// 置顶分组配置
const PINNED_GROUPS = [
{ id: "recent", name: "最近播放", icon: "⏱" },
{ id: "favorite", name: "收藏", icon: "❤️" },
];
// 分组图标映射
const GROUP_ICONS = {
央视: "📺",
卫视: "📡",
体育: "⚽",
电影: "🎬",
少儿: "👶",
};
export function useGroups() {
const selectedGroup = ref("");
const rawGroups = ref([]);
// 计算置顶分组(带数量)
const pinnedGroups = computed(() => {
return PINNED_GROUPS.map((g) => ({
...g,
count: 0, // TODO: 实际数量从外部传入
}));
});
// 计算普通分组(带图标和数量)
const normalGroups = computed(() => {
return rawGroups.value.map((group) => ({
id: group,
name: group,
icon: getGroupIcon(group),
count: 0, // TODO: 实际数量从外部传入
}));
});
// 所有分组
const allGroups = computed(() => [...pinnedGroups.value, ...normalGroups.value]);
// 获取分组图标
function getGroupIcon(groupName) {
for (const [key, icon] of Object.entries(GROUP_ICONS)) {
if (groupName.includes(key)) return icon;
}
return "📺";
}
// 选择分组
function selectGroup(groupId) {
selectedGroup.value = groupId;
}
// 设置原始分组数据
function setGroups(groups) {
rawGroups.value = groups;
}
// 根据当前频道初始化选中分组
function initSelectedGroup(channel, defaultGroup = "") {
if (channel?.group) {
selectedGroup.value = channel.group;
} else if (defaultGroup) {
selectedGroup.value = defaultGroup;
} else if (rawGroups.value.length > 0) {
selectedGroup.value = rawGroups.value[0];
}
}
return {
selectedGroup,
pinnedGroups,
normalGroups,
allGroups,
selectGroup,
setGroups,
initSelectedGroup,
getGroupIcon,
};
}

View File

@ -0,0 +1,84 @@
import { ref, onMounted } from "vue";
import { useStorage } from "./useStorage.js";
const STORAGE_KEY = "play_history";
const MAX_HISTORY = 20;
// 全局状态
const history = ref([]);
let initialized = false;
let loadPromise = null;
export function useHistory() {
const storage = useStorage();
// 加载历史(从 useStorage
const loadHistory = async () => {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const saved = await storage.get(STORAGE_KEY);
if (saved) {
history.value = saved;
}
} catch (e) {
console.error("加载历史失败:", e);
}
initialized = true;
})();
return loadPromise;
};
// 保存历史(到 useStorage
const saveHistory = async () => {
try {
await storage.set(STORAGE_KEY, [...history.value]);
} catch (e) {
console.error("保存历史失败:", e);
}
};
// 添加到历史
const addToHistory = async (channel) => {
if (!channel) return;
// 移除重复项
history.value = history.value.filter((h) => h.id !== channel.id);
// 添加到开头
history.value.unshift({
...channel,
playedAt: Date.now(),
});
// 限制数量
if (history.value.length > MAX_HISTORY) {
history.value = history.value.slice(0, MAX_HISTORY);
}
await saveHistory();
};
// 清空历史
const clearHistory = async () => {
history.value = [];
await saveHistory();
};
// 获取历史列表
const getHistory = () => {
return [...history.value];
};
// 初始化加载
onMounted(() => {
if (!initialized) {
loadHistory();
}
});
return {
history,
addToHistory,
clearHistory,
getHistory,
};
}

View File

@ -0,0 +1,42 @@
import { ref, computed } from "vue";
// 模拟节目单数据TODO: 实际从 EPG 获取)
const MOCK_PROGRAMS = [
{ 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 },
{ id: 5, time: "13:00", title: "电视剧:西游记", isCurrent: false },
{ id: 6, time: "15:00", title: "动画剧场", isCurrent: false },
{ id: 7, time: "19:00", title: "新闻联播", isCurrent: false },
];
export function usePrograms() {
const selectedProgram = ref(null);
const programs = ref([...MOCK_PROGRAMS]);
// 选择节目
function selectProgram(program) {
selectedProgram.value = program;
return program;
}
// 加载指定日期的节目TODO: 实际从 EPG 获取)
function loadProgramsByDate(dateValue) {
// TODO: 根据日期加载节目单
console.log("加载节目单:", dateValue);
}
// 更新节目单
function setPrograms(newPrograms) {
programs.value = newPrograms;
}
return {
selectedProgram,
programs,
selectProgram,
loadProgramsByDate,
setPrograms,
};
}

View File

@ -0,0 +1,85 @@
import { ref, onMounted } from "vue";
import { useStorage } from "./useStorage.js";
const STORAGE_KEY = "user_settings";
// 默认设置
const defaultSettings = {
autoPlay: true,
defaultVolume: 0.8,
showEpg: true,
theme: "dark",
checkTimeout: 2000, // 检测超时时间(ms)
checkConcurrency: 5, // 并发数
};
// 全局状态
const settings = ref({ ...defaultSettings });
let initialized = false;
let loadPromise = null;
export function useSettings() {
const storage = useStorage();
// 加载设置(从 useStorage
const loadSettings = async () => {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const saved = await storage.get(STORAGE_KEY);
if (saved) {
settings.value = { ...defaultSettings, ...saved };
}
} catch (e) {
console.error("加载设置失败:", e);
}
initialized = true;
})();
return loadPromise;
};
// 保存设置(到 useStorage
const saveSettings = async () => {
try {
await storage.set(STORAGE_KEY, settings.value);
} catch (e) {
console.error("保存设置失败:", e);
}
};
// 更新单个设置
const updateSetting = async (key, value) => {
if (key in settings.value) {
settings.value[key] = value;
await saveSettings();
}
};
// 更新多个设置
const updateSettings = async (newSettings) => {
settings.value = { ...settings.value, ...newSettings };
await saveSettings();
};
// 重置设置为默认值
const resetSettings = async () => {
settings.value = { ...defaultSettings };
await saveSettings();
};
// 初始化加载
onMounted(() => {
if (!initialized) {
loadSettings();
}
});
return {
settings,
updateSetting,
updateSettings,
resetSettings,
};
}

View File

@ -1,33 +1,20 @@
import { ref, computed } from 'vue'; import { ref, computed } from "vue";
import { debounce } from "../utils/common.js";
// 防抖工具函数
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 当前激活的栏索引用于TV导航 // 当前激活的栏索引用于TV导航
const activeColumnIndex = ref(0); const activeColumnIndex = ref(0);
const leftPanelVisible = ref(false); const leftPanelVisible = ref(false);
const bottomPanelVisible = ref(false); const bottomPanelVisible = ref(false);
// 防抖隐藏底部栏
const debouncedHideBottomPanel = debounce(() => {
bottomPanelVisible.value = false;
}, 3000);
// 当前选中的分组/频道/日期/节目 // 当前选中的分组/频道/日期/节目
const selectedGroup = ref(''); const selectedGroup = ref("");
const selectedChannel = ref(null); const selectedChannel = ref(null);
const selectedDate = ref(''); const selectedDate = ref("");
const selectedProgram = ref(null); const selectedProgram = ref(null);
export function useUI() { export function useUI() {
const platform = import.meta.env.VITE_PLATFORM || 'web'; const platform = import.meta.env.VITE_PLATFORM || "web";
const isTV = platform === 'tv'; const isTV = platform === "tv";
// 显示左侧面板 // 显示左侧面板
const showLeftPanel = () => { const showLeftPanel = () => {
@ -54,18 +41,13 @@ export function useUI() {
// 显示底部栏(启动防抖隐藏) // 显示底部栏(启动防抖隐藏)
const showBottomPanel = () => { const showBottomPanel = () => {
bottomPanelVisible.value = true; bottomPanelVisible.value = true;
debouncedHideBottomPanel(); hideBottomPanel();
}; };
// 隐藏底部栏 // 隐藏底部栏
const hideBottomPanel = () => { const hideBottomPanel = debounce(() => {
bottomPanelVisible.value = false; bottomPanelVisible.value = false;
}; }, 3000);
// 底部栏交互(重置防抖)
const onBottomInteraction = () => {
debouncedHideBottomPanel();
};
// 设置选中项 // 设置选中项
const setSelectedGroup = (group) => { const setSelectedGroup = (group) => {
@ -89,9 +71,9 @@ export function useUI() {
if (!leftPanelVisible.value) return; if (!leftPanelVisible.value) return;
const maxIndex = 3; // 0-3 四栏 const maxIndex = 3; // 0-3 四栏
if (direction === 'right') { if (direction === "right") {
activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex); activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex);
} else if (direction === 'left') { } else if (direction === "left") {
activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0); activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0);
} }
}; };
@ -118,7 +100,6 @@ export function useUI() {
toggleLeftPanel, toggleLeftPanel,
showBottomPanel, showBottomPanel,
hideBottomPanel, hideBottomPanel,
onBottomInteraction,
setSelectedGroup, setSelectedGroup,
setSelectedChannel, setSelectedChannel,
setSelectedDate, setSelectedDate,
@ -126,12 +107,3 @@ export function useUI() {
moveColumn, moveColumn,
}; };
} }
// 创建单例
let uiInstance = null;
export function useUISingleton() {
if (!uiInstance) {
uiInstance = useUI();
}
return uiInstance;
}

8
ui/src/utils/common.js Normal file
View File

@ -0,0 +1,8 @@
// 防抖工具函数
export const debounce = (fn, delay) => {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
};