refactor: 移除 Pinia 和相关无用文件

- 删除 stores/useStore.js (Pinia)
- 删除 components/ConfigPanel.vue
- 删除 Layout/ 子组件 (GroupList, ChannelList, DateList, ProgramList)
This commit is contained in:
李岩岩 2026-02-09 00:27:38 +08:00
parent d85823cc8d
commit 5f8165b236
9 changed files with 17 additions and 1160 deletions

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "esbenp.prettier-vscode"]
}

20
.vscode/settings.json vendored
View File

@ -1,10 +1,5 @@
{
"cSpell.words": [
"IPTV",
"iptv",
"liyanyan",
"tauri"
],
"cSpell.words": ["IPTV", "iptv", "liyanyan", "tauri"],
"cSpell.ignorePaths": [
"package-lock.json",
"public/api",
@ -14,5 +9,16 @@
".git/{index,*refs,*HEAD}",
".vscode",
".vscode-insiders"
]
],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": false,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

View File

@ -1,483 +0,0 @@
<template>
<div class="config-panel" v-if="show">
<div class="panel-overlay" @click="$emit('close')"></div>
<div class="panel-content">
<div class="panel-header">
<h2>应用设置</h2>
<button class="close-btn" @click="$emit('close')"></button>
</div>
<!-- 标签页 -->
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-btn"
:class="{ active: currentTab === tab.id }"
@click="currentTab = tab.id"
>
{{ tab.name }}
</button>
</div>
<div class="panel-body">
<!-- 数据源 -->
<div v-if="currentTab === 'source'" class="tab-content">
<div class="setting-group">
<label>数据源类型</label>
<select v-model="config.apiType">
<option value="local">📁 本地文件</option>
<option value="guovin">🌐 在线接口</option>
<option value="custom"> 自定义</option>
</select>
</div>
<div v-if="config.apiType === 'local'" class="setting-group">
<label>选择文件</label>
<select v-model="config.localFile">
<option value="/api/result.txt">result.txt完整</option>
<option value="/api/ipv4/result.txt">IPv4 专线</option>
<option value="/api/ipv6/result.txt">IPv6 专线</option>
</select>
</div>
<div v-if="config.apiType === 'custom'" class="setting-group">
<label>接口地址</label>
<input v-model="config.apiUrl" placeholder="http://..." />
</div>
<div class="setting-group">
<label>EPG 数据源</label>
<select v-model="config.epgType">
<option value="local">📁 本地文件</option>
<option value="custom"> 自定义</option>
</select>
</div>
</div>
<!-- 播放设置 -->
<div v-if="currentTab === 'playback'" class="tab-content">
<div class="setting-group">
<label>自动播放</label>
<div class="toggle-switch">
<input type="checkbox" v-model="config.autoPlay" id="autoplay">
<label for="autoplay"></label>
</div>
</div>
<div class="setting-group">
<label>默认音量 {{ Math.round(config.defaultVolume * 100) }}%</label>
<input
type="range"
min="0"
max="1"
step="0.1"
v-model="config.defaultVolume"
class="slider"
/>
</div>
<div class="setting-group">
<label>检测超时 {{ config.checkTimeout / 1000 }}s</label>
<input
type="range"
min="1000"
max="10000"
step="500"
v-model.number="config.checkTimeout"
class="slider"
/>
</div>
<div class="setting-group">
<label>缓存过期 {{ Math.round(config.cacheExpire / 1000 / 60 / 60) }}h</label>
<input
type="range"
min="3600000"
max="168000000"
step="3600000"
v-model.number="config.cacheExpire"
class="slider"
/>
</div>
</div>
<!-- 外观 -->
<div v-if="currentTab === 'appearance'" class="tab-content">
<div class="setting-group">
<label>主题</label>
<div class="theme-options">
<div
class="theme-card"
:class="{ active: config.theme === 'dark' }"
@click="config.theme = 'dark'"
>
<div class="theme-icon">🌙</div>
<span>深色</span>
</div>
<div
class="theme-card"
:class="{ active: config.theme === 'light' }"
@click="config.theme = 'light'"
>
<div class="theme-icon"></div>
<span>浅色</span>
</div>
<div
class="theme-card"
:class="{ active: config.theme === 'system' }"
@click="config.theme = 'system'"
>
<div class="theme-icon">💻</div>
<span>跟随系统</span>
</div>
</div>
</div>
<div class="shortcuts">
<h4>快捷键</h4>
<p>S 打开设置 | M 菜单 | I 信息 | F 收藏</p>
<p> 切换线路 | 切换频道</p>
</div>
</div>
</div>
<div class="panel-footer">
<button class="btn-primary" @click="saveAndClose">
完成
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useStore } from '../stores/useStore.js'
const props = defineProps({
show: Boolean
})
const emit = defineEmits(['close', 'reload'])
const store = useStore()
const tabs = [
{ id: 'source', name: '数据源' },
{ id: 'playback', name: '播放设置' },
{ id: 'appearance', name: '外观' }
]
const currentTab = ref('source')
const config = ref({
apiType: localStorage.getItem('iptv_api_type') || 'local',
apiUrl: localStorage.getItem('iptv_api_url') || '',
localFile: localStorage.getItem('iptv_local_file') || '/api/result.txt',
epgType: localStorage.getItem('iptv_epg_type') || 'local',
autoPlay: store.settings.autoPlay,
defaultVolume: store.settings.defaultVolume,
checkTimeout: store.settings.checkTimeout || 2000,
cacheExpire: parseInt(localStorage.getItem('iptv_cache_expire')) || (24 * 60 * 60 * 1000),
theme: localStorage.getItem('iptv_theme') || 'dark'
})
watch(config, (newVal) => {
localStorage.setItem('iptv_api_type', newVal.apiType)
localStorage.setItem('iptv_api_url', newVal.apiUrl)
localStorage.setItem('iptv_local_file', newVal.localFile)
localStorage.setItem('iptv_epg_type', newVal.epgType)
localStorage.setItem('iptv_cache_expire', newVal.cacheExpire)
localStorage.setItem('iptv_theme', newVal.theme)
store.updateSetting('autoPlay', newVal.autoPlay)
store.updateSetting('defaultVolume', newVal.defaultVolume)
store.updateSetting('checkTimeout', newVal.checkTimeout)
}, { deep: true })
function saveAndClose() {
emit('reload')
emit('close')
}
</script>
<style scoped>
.config-panel {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
.panel-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
}
.panel-content {
position: relative;
background: #1a1a1a;
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
}
.panel-header h2 {
font-size: 18px;
font-weight: 500;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: #888;
font-size: 18px;
cursor: pointer;
}
.close-btn:hover {
background: #2a2a2a;
color: #fff;
}
/* 标签页 */
.tabs {
display: flex;
padding: 0 20px;
gap: 8px;
border-bottom: 1px solid #2a2a2a;
}
.tab-btn {
flex: 1;
padding: 12px;
background: transparent;
border: none;
color: #888;
font-size: 14px;
cursor: pointer;
border-radius: 8px 8px 0 0;
transition: all 0.2s;
}
.tab-btn:hover {
color: #fff;
background: #2a2a2a;
}
.tab-btn.active {
color: #fff;
background: #2a2a2a;
}
/* 内容区 */
.panel-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.tab-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.setting-group label {
font-size: 13px;
color: #888;
}
select, input {
width: 100%;
padding: 12px;
border: 1px solid #333;
border-radius: 10px;
background: #252525;
color: #fff;
font-size: 14px;
}
select:focus, input:focus {
outline: none;
border-color: #555;
}
/* 开关 */
.toggle-switch {
position: relative;
width: 50px;
height: 28px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch label {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #333;
border-radius: 28px;
transition: 0.3s;
}
.toggle-switch label:before {
position: absolute;
content: "";
height: 22px;
width: 22px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: 0.3s;
}
.toggle-switch input:checked + label {
background: #00d4ff;
}
.toggle-switch input:checked + label:before {
transform: translateX(22px);
}
/* 滑块 */
.slider {
-webkit-appearance: none;
height: 6px;
background: #333;
border-radius: 3px;
padding: 0;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
/* 主题选项 */
.theme-options {
display: flex;
gap: 12px;
}
.theme-card {
flex: 1;
padding: 20px;
background: #252525;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.theme-card:hover {
background: #2a2a2a;
}
.theme-card.active {
border-color: #fff;
background: #2a2a2a;
}
.theme-icon {
font-size: 24px;
}
.theme-card span {
font-size: 13px;
color: #aaa;
}
.theme-card.active span {
color: #fff;
}
/* 快捷键 */
.shortcuts {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #2a2a2a;
}
.shortcuts h4 {
font-size: 13px;
color: #888;
margin-bottom: 12px;
font-weight: normal;
}
.shortcuts p {
font-size: 12px;
color: #666;
line-height: 1.8;
}
/* 底部按钮 */
.panel-footer {
padding: 20px;
border-top: 1px solid #2a2a2a;
}
.btn-primary {
width: 100%;
padding: 14px;
background: #fff;
color: #000;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover {
background: #e0e0e0;
}
</style>

View File

@ -1,160 +0,0 @@
<template>
<div
class="channel-list"
:class="{ 'is-active': isActive }"
>
<div
v-for="channel in channels"
:key="channel.id"
class="channel-item"
:class="{ active: modelValue?.id === channel.id }"
@click="selectChannel(channel)"
>
<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>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Object,
default: null
},
channels: {
type: Array,
default: () => []
},
isActive: {
type: Boolean,
default: false
},
favorites: {
type: Set,
default: () => new Set()
},
validityMap: {
type: Map,
default: () => new Map()
}
});
const emit = defineEmits(['update:modelValue', 'select']);
const platform = import.meta.env.VITE_PLATFORM || 'web';
const isTV = platform === 'tv';
// LOGO
function getChannelLogo(name) {
return name.slice(0, 2);
}
//
function isFavorite(channelId) {
return props.favorites.has(channelId);
}
// 线
function getValidCount(channel) {
if (!channel.urls) return 0;
return channel.urls.filter(url => {
const validity = props.validityMap.get(url);
return validity && validity.status === 'online';
}).length;
}
//
function selectChannel(channel) {
emit('update:modelValue', channel);
emit('select', channel);
}
</script>
<style scoped>
.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.1);
}
.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;
}
/* TV 模式焦点样式 */
.is-active .channel-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,108 +0,0 @@
<template>
<div
class="date-list"
:class="{ 'is-active': isActive }"
>
<div
v-for="date in dates"
:key="date.value"
class="date-item"
:class="{ active: modelValue === date.value }"
@click="selectDate(date.value)"
>
<div class="date-day">{{ date.day }}</div>
<div class="date-label">{{ date.label }}</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
isActive: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'select']);
// ...
const dates = computed(() => {
const list = [];
const today = new Date();
const labels = ['今天', '明天', '后天'];
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 ? labels[i] : `${date.getMonth() + 1}${date.getDate()}`;
list.push({ value, day, label });
}
return list;
});
//
function selectDate(dateValue) {
emit('update:modelValue', dateValue);
emit('select', dateValue);
}
</script>
<style scoped>
.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.1);
}
.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);
}
/* TV 模式焦点样式 */
.is-active .date-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,169 +0,0 @@
<template>
<div
class="group-list"
:class="{ 'is-active': isActive }"
>
<!-- 置顶分组 -->
<div class="group-section pinned">
<div
v-for="group in pinnedGroups"
:key="group.id"
class="group-item"
:class="{ active: modelValue === group.id }"
@click="selectGroup(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 class="divider"></div>
<!-- 普通分组 -->
<div class="group-section normal">
<div
v-for="group in normalGroups"
:key="group.id"
class="group-item"
:class="{ active: modelValue === group.id }"
@click="selectGroup(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>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
groups: {
type: Array,
default: () => []
},
isActive: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'select']);
//
const pinnedGroups = computed(() => {
return [
{ id: 'recent', name: '最近播放', icon: '⏱', count: 0 }, // TODO:
{ id: 'favorite', name: '收藏', icon: '❤️', count: 0 }, // TODO:
];
});
//
const normalGroups = computed(() => {
return props.groups.map(group => ({
id: group,
name: group,
icon: getGroupIcon(group),
count: 0 // TODO:
}));
});
//
function getGroupIcon(groupName) {
if (groupName.includes('央视')) return '📺';
if (groupName.includes('卫视')) return '📡';
if (groupName.includes('体育')) return '⚽';
if (groupName.includes('电影')) return '🎬';
if (groupName.includes('少儿')) return '👶';
return '📺';
}
//
function selectGroup(groupId) {
emit('update:modelValue', groupId);
emit('select', groupId);
}
</script>
<style scoped>
.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.1);
}
.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);
}
/* TV 模式焦点样式 */
.is-active .group-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,112 +0,0 @@
<template>
<div
class="program-list"
:class="{ 'is-active': isActive }"
>
<div
v-for="program in programs"
:key="program.id"
class="program-item"
:class="{
active: modelValue?.id === program.id,
current: program.isCurrent
}"
@click="selectProgram(program)"
>
<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>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Object,
default: null
},
programs: {
type: Array,
default: () => []
},
isActive: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'select']);
//
function selectProgram(program) {
emit('update:modelValue', program);
emit('select', program);
}
</script>
<style scoped>
.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.1);
}
.program-item.active {
background: rgba(255, 255, 255, 0.2);
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 .program-item:focus {
outline: 2px solid #fff;
outline-offset: -2px;
}
</style>

View File

@ -1,7 +1,5 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

View File

@ -1,118 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 收藏 & 历史记录 状态管理
export const useStore = defineStore('iptv', () => {
// ============ 收藏 ============
const favorites = ref(new Set())
const loadFavorites = () => {
try {
const saved = localStorage.getItem('iptv_favorites')
if (saved) {
favorites.value = new Set(JSON.parse(saved))
}
} catch (e) {
console.error('加载收藏失败:', e)
}
}
const saveFavorites = () => {
localStorage.setItem('iptv_favorites', JSON.stringify([...favorites.value]))
}
const toggleFavorite = (channelId) => {
if (favorites.value.has(channelId)) {
favorites.value.delete(channelId)
} else {
favorites.value.add(channelId)
}
saveFavorites()
}
const isFavorite = (channelId) => favorites.value.has(channelId)
// ============ 播放历史 ============
const history = ref([])
const MAX_HISTORY = 10
const loadHistory = () => {
try {
const saved = localStorage.getItem('iptv_history')
if (saved) {
history.value = JSON.parse(saved)
}
} catch (e) {
console.error('加载历史失败:', e)
}
}
const saveHistory = () => {
localStorage.setItem('iptv_history', JSON.stringify(history.value.slice(0, MAX_HISTORY)))
}
const addToHistory = (channel) => {
// 移除重复项
history.value = history.value.filter(h => h.id !== channel.id)
// 添加到开头
history.value.unshift({
...channel,
playedAt: Date.now()
})
saveHistory()
}
const clearHistory = () => {
history.value = []
saveHistory()
}
// ============ 设置 ============
const settings = ref({
autoPlay: true,
defaultVolume: 0.8,
showEpg: true,
theme: 'dark',
checkTimeout: 2000, // 检测超时时间(ms)
checkConcurrency: 5 // 并发数
})
const loadSettings = () => {
try {
const saved = localStorage.getItem('iptv_settings')
if (saved) {
settings.value = { ...settings.value, ...JSON.parse(saved) }
}
} catch (e) {
console.error('加载设置失败:', e)
}
}
const saveSettings = () => {
localStorage.setItem('iptv_settings', JSON.stringify(settings.value))
}
const updateSetting = (key, value) => {
settings.value[key] = value
saveSettings()
}
// 初始化
loadFavorites()
loadHistory()
loadSettings()
return {
// 收藏
favorites,
toggleFavorite,
isFavorite,
// 历史
history,
addToHistory,
clearHistory,
// 设置
settings,
updateSetting
}
})