Compare commits
4 Commits
abdcd06ae4
...
d85823cc8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d85823cc8d | ||
|
|
380f4ab4d6 | ||
|
|
2cab50db31 | ||
|
|
a143c9023c |
10
.gitignore
vendored
10
.gitignore
vendored
@ -68,13 +68,6 @@ android-tv/app/src/main/assets/www/
|
|||||||
!android-tv/app/src/main/assets/error.html
|
!android-tv/app/src/main/assets/error.html
|
||||||
!android-tv/app/src/main/assets/test.html
|
!android-tv/app/src/main/assets/test.html
|
||||||
|
|
||||||
# ================================================
|
|
||||||
# HarmonyOS
|
|
||||||
# ================================================
|
|
||||||
harmonyos/.idea/
|
|
||||||
harmonyos/build/
|
|
||||||
harmonyos/entry/build/
|
|
||||||
|
|
||||||
# ================================================
|
# ================================================
|
||||||
# Docker
|
# Docker
|
||||||
# ================================================
|
# ================================================
|
||||||
@ -97,11 +90,8 @@ lerna-debug.log*
|
|||||||
# ================================================
|
# ================================================
|
||||||
# Environment
|
# Environment
|
||||||
# ================================================
|
# ================================================
|
||||||
.env
|
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.env.development
|
|
||||||
.env.test
|
|
||||||
|
|
||||||
# ================================================
|
# ================================================
|
||||||
# Testing
|
# Testing
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 📺 IPTV 跨平台应用
|
# 📺 IPTV 跨平台应用
|
||||||
|
|
||||||
基于 Web 技术的跨平台 IPTV 播放器,一套代码支持 Windows、Mac、Android、Android TV、HarmonyOS。
|
基于 Web 技术的跨平台 IPTV 播放器,一套代码支持 Windows、Mac、Android、Android TV、Web。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ iptv-app/
|
|||||||
├── desktop/ # 🖥️ Tauri 桌面端 (Win/Mac)
|
├── desktop/ # 🖥️ Tauri 桌面端 (Win/Mac)
|
||||||
├── android/ # 📱 Android WebView 壳
|
├── android/ # 📱 Android WebView 壳
|
||||||
├── android-tv/ # 📺 Android TV WebView 壳
|
├── android-tv/ # 📺 Android TV WebView 壳
|
||||||
└── harmonyos/ # 🔶 HarmonyOS WebView 壳
|
└── web/ # 🐳 Docker Web 服务端
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
@ -29,7 +29,7 @@ iptv-app/
|
|||||||
| Desktop | Tauri (Rust) | 轻量 (~5MB),高性能 |
|
| Desktop | Tauri (Rust) | 轻量 (~5MB),高性能 |
|
||||||
| Android | WebView + ExoPlayer | 手机/平板 Web 壳 |
|
| Android | WebView + ExoPlayer | 手机/平板 Web 壳 |
|
||||||
| Android TV | WebView + Leanback | TV 专用,支持遥控器导航 |
|
| Android TV | WebView + Leanback | TV 专用,支持遥控器导航 |
|
||||||
| HarmonyOS | Web 组件 + Video 组件 | Web 壳 + 原生播放器 |
|
| Web | Node.js + Docker | 网页端 + 代理服务 |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
22
TODO.md
Normal file
22
TODO.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# TODO 列表
|
||||||
|
|
||||||
|
## 高优先级
|
||||||
|
|
||||||
|
- [x] 多平台打包配置
|
||||||
|
- [x] Storage API 抽象层
|
||||||
|
- [x] UI 布局改造(四栏布局)
|
||||||
|
- [x] 频道加载与测速排序
|
||||||
|
- [x] 播放控制(自动选线、失败重试)
|
||||||
|
- [x] 缓存管理(清除功能)
|
||||||
|
|
||||||
|
## 中优先级
|
||||||
|
|
||||||
|
- [ ] 最近播放逻辑完善(数据持久化)
|
||||||
|
- [ ] 收藏功能完善(实时更新收藏状态)
|
||||||
|
- [ ] EPG 节目单数据获取与解析
|
||||||
|
|
||||||
|
## 低优先级(后排)
|
||||||
|
|
||||||
|
- [ ] TV 遥控器导航(四栏之间方向键移动焦点)
|
||||||
|
- [ ] 时移播放(回看功能)
|
||||||
|
- [ ] 搜索功能(Web/Desktop)
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package com.iptv.tv;
|
package com.iptv.tv;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
import android.webkit.JavascriptInterface;
|
import android.webkit.JavascriptInterface;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -9,9 +10,12 @@ import java.io.InputStreamReader;
|
|||||||
|
|
||||||
public class AssetReader {
|
public class AssetReader {
|
||||||
private Context context;
|
private Context context;
|
||||||
|
private SharedPreferences prefs;
|
||||||
|
private static final String PREFS_NAME = "IPTVData";
|
||||||
|
|
||||||
public AssetReader(Context context) {
|
public AssetReader(Context context) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
@ -45,4 +49,31 @@ public class AssetReader {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SharedPreferences 存储接口
|
||||||
|
@JavascriptInterface
|
||||||
|
public String getItem(String key) {
|
||||||
|
return prefs.getString(key, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public void setItem(String key, String value) {
|
||||||
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
|
editor.putString(key, value);
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public void removeItem(String key) {
|
||||||
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
|
editor.remove(key);
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public void clear() {
|
||||||
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
|
editor.clear();
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,16 +7,16 @@ set -e
|
|||||||
echo "=== IPTV Android TV 构建 ==="
|
echo "=== IPTV Android TV 构建 ==="
|
||||||
|
|
||||||
# 检查 UI 构建产物
|
# 检查 UI 构建产物
|
||||||
if [ ! -d "../ui/dist-web" ]; then
|
if [ ! -d "../ui/dist/tv" ]; then
|
||||||
echo "错误: 未找到 ../ui/dist-web 目录"
|
echo "错误: 未找到 ../ui/dist/tv 目录"
|
||||||
echo "请先构建 Web UI: cd ../ui && npm run build"
|
echo "请先构建 Web UI: cd ../ui && npm run build:tv"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 复制 Web 资源到 Android assets
|
# 复制 Web 资源到 Android assets
|
||||||
echo "复制 Web 资源..."
|
echo "复制 Web 资源..."
|
||||||
mkdir -p app/src/main/assets/www
|
mkdir -p app/src/main/assets/www
|
||||||
cp -r ../ui/dist-web/* app/src/main/assets/www/
|
cp -r ../ui/dist/tv/* app/src/main/assets/www/
|
||||||
|
|
||||||
# 统计文件
|
# 统计文件
|
||||||
echo "已复制文件数量:"
|
echo "已复制文件数量:"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.iptv.app;
|
package com.iptv.app;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
import android.webkit.JavascriptInterface;
|
import android.webkit.JavascriptInterface;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -9,15 +10,17 @@ import java.io.InputStreamReader;
|
|||||||
|
|
||||||
public class AssetReader {
|
public class AssetReader {
|
||||||
private Context context;
|
private Context context;
|
||||||
|
private SharedPreferences prefs;
|
||||||
|
private static final String PREFS_NAME = "IPTVData";
|
||||||
|
|
||||||
public AssetReader(Context context) {
|
public AssetReader(Context context) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
public String readFile(String path) {
|
public String readFile(String path) {
|
||||||
try {
|
try {
|
||||||
// path 如: "www/api/result.txt"
|
|
||||||
InputStream is = context.getAssets().open(path);
|
InputStream is = context.getAssets().open(path);
|
||||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
|
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
@ -46,4 +49,31 @@ public class AssetReader {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SharedPreferences 存储接口
|
||||||
|
@JavascriptInterface
|
||||||
|
public String getItem(String key) {
|
||||||
|
return prefs.getString(key, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public void setItem(String key, String value) {
|
||||||
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
|
editor.putString(key, value);
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public void removeItem(String key) {
|
||||||
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
|
editor.remove(key);
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public void clear() {
|
||||||
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
|
editor.clear();
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,16 +7,16 @@ set -e
|
|||||||
echo "=== IPTV Android 构建 ==="
|
echo "=== IPTV Android 构建 ==="
|
||||||
|
|
||||||
# 检查 UI 构建产物
|
# 检查 UI 构建产物
|
||||||
if [ ! -d "../ui/dist-web" ]; then
|
if [ ! -d "../ui/dist/android" ]; then
|
||||||
echo "错误: 未找到 ../ui/dist-web 目录"
|
echo "错误: 未找到 ../ui/dist/android 目录"
|
||||||
echo "请先构建 Web UI: cd ../ui && npm run build"
|
echo "请先构建 Web UI: cd ../ui && npm run build:android"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 复制 Web 资源到 Android assets
|
# 复制 Web 资源到 Android assets
|
||||||
echo "复制 Web 资源..."
|
echo "复制 Web 资源..."
|
||||||
mkdir -p app/src/main/assets/www
|
mkdir -p app/src/main/assets/www
|
||||||
cp -r ../ui/dist-web/* app/src/main/assets/www/
|
cp -r ../ui/dist/android/* app/src/main/assets/www/
|
||||||
|
|
||||||
# 统计文件
|
# 统计文件
|
||||||
echo "已复制文件数量:"
|
echo "已复制文件数量:"
|
||||||
|
|||||||
@ -17,8 +17,8 @@ echo "✓ Rust 已安装 ($(cargo --version))"
|
|||||||
echo "📦 步骤 1/4: 构建 ui 应用..."
|
echo "📦 步骤 1/4: 构建 ui 应用..."
|
||||||
cd ui
|
cd ui
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build:desktop
|
||||||
cp -r public/api dist-web/
|
cp -r public/api dist/desktop/
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# 步骤2: 安装 Tauri 依赖
|
# 步骤2: 安装 Tauri 依赖
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"beforeBuildCommand": "",
|
"beforeBuildCommand": "",
|
||||||
"beforeDevCommand": "",
|
"beforeDevCommand": "",
|
||||||
"devPath": "../../ui/dist-web",
|
"devPath": "../../ui/dist/desktop",
|
||||||
"distDir": "../../ui/dist-web",
|
"distDir": "../../ui/dist/desktop",
|
||||||
"withGlobalTauri": false
|
"withGlobalTauri": false
|
||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
|
|||||||
4
ui/.env.android
Normal file
4
ui/.env.android
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
VITE_PLATFORM=android
|
||||||
|
VITE_API_BASE=/api
|
||||||
|
VITE_STORAGE_TYPE=android
|
||||||
|
VITE_ANDROID_ASSET=true
|
||||||
3
ui/.env.desktop
Normal file
3
ui/.env.desktop
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
VITE_PLATFORM=desktop
|
||||||
|
VITE_API_BASE=/api
|
||||||
|
VITE_STORAGE_TYPE=indexeddb
|
||||||
5
ui/.env.tv
Normal file
5
ui/.env.tv
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
VITE_PLATFORM=tv
|
||||||
|
VITE_API_BASE=/api
|
||||||
|
VITE_STORAGE_TYPE=android
|
||||||
|
VITE_ANDROID_ASSET=true
|
||||||
|
VITE_TV_MODE=true
|
||||||
3
ui/.env.web
Normal file
3
ui/.env.web
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
VITE_PLATFORM=web
|
||||||
|
VITE_API_BASE=/api
|
||||||
|
VITE_STORAGE_TYPE=indexeddb
|
||||||
11
ui/package-lock.json
generated
11
ui/package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.2.0",
|
"@vueuse/core": "^14.2.0",
|
||||||
"hls.js": "^1.5.0",
|
"hls.js": "^1.5.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
@ -351,6 +352,11 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/idb": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="
|
||||||
|
},
|
||||||
"node_modules/is-what": {
|
"node_modules/is-what": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
||||||
@ -845,6 +851,11 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
||||||
},
|
},
|
||||||
|
"idb": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="
|
||||||
|
},
|
||||||
"is-what": {
|
"is-what": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
||||||
|
|||||||
@ -4,12 +4,17 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build --mode production",
|
||||||
|
"build:web": "vite build --mode web",
|
||||||
|
"build:desktop": "vite build --mode desktop",
|
||||||
|
"build:android": "vite build --mode android",
|
||||||
|
"build:tv": "vite build --mode tv",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.2.0",
|
"@vueuse/core": "^14.2.0",
|
||||||
"hls.js": "^1.5.0",
|
"hls.js": "^1.5.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
|
|||||||
1506
ui/src/App.vue
1506
ui/src/App.vue
File diff suppressed because it is too large
Load Diff
43
ui/src/components/DebugPanel.vue
Normal file
43
ui/src/components/DebugPanel.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="debug-panel">
|
||||||
|
<div class="debug-header">
|
||||||
|
<span>调试信息</span>
|
||||||
|
<button @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="debug-content">
|
||||||
|
<p>调试功能开发中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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>
|
||||||
273
ui/src/components/Layout/BottomPanel.vue
Normal file
273
ui/src/components/Layout/BottomPanel.vue
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="slide-up">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="bottom-panel"
|
||||||
|
@mouseenter="onInteraction"
|
||||||
|
@mousemove="onInteraction"
|
||||||
|
>
|
||||||
|
<!-- 左侧:频道信息 -->
|
||||||
|
<div class="panel-left">
|
||||||
|
<div class="channel-logo">
|
||||||
|
{{ getChannelLogo(channel?.name) }}
|
||||||
|
</div>
|
||||||
|
<div class="channel-info">
|
||||||
|
<div class="channel-name-row">
|
||||||
|
<span class="name">{{ channel?.name || '未选择频道' }}</span>
|
||||||
|
<span class="source-tag">线路 {{ currentSourceIndex + 1 }}/{{ channel?.urls?.length || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="program-info">
|
||||||
|
<span class="live-dot">●</span>
|
||||||
|
<span class="program-title">{{ currentProgram || '精彩节目' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
|
||||||
|
<span class="time-label">{{ currentTime }} / {{ totalTime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:操作按钮 -->
|
||||||
|
<div class="panel-right">
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
:class="{ active: isFavorite }"
|
||||||
|
@click="handleFavorite"
|
||||||
|
@mouseenter="onInteraction"
|
||||||
|
>
|
||||||
|
<span class="icon">★</span>
|
||||||
|
<span>{{ isFavorite ? '已收藏' : '收藏' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
@click="handleSwitchSource"
|
||||||
|
@mouseenter="onInteraction"
|
||||||
|
>
|
||||||
|
<span class="icon">↻</span>
|
||||||
|
<span>切换线路</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
@click="handleSettings"
|
||||||
|
@mouseenter="onInteraction"
|
||||||
|
>
|
||||||
|
<span class="icon">⚙</span>
|
||||||
|
<span>设置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
currentSourceIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
currentProgram: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
currentTime: {
|
||||||
|
type: String,
|
||||||
|
default: '--:--'
|
||||||
|
},
|
||||||
|
totalTime: {
|
||||||
|
type: String,
|
||||||
|
default: '--:--'
|
||||||
|
},
|
||||||
|
isFavorite: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['favorite', 'switch-source', 'settings', 'interaction']);
|
||||||
|
|
||||||
|
// 获取频道 LOGO
|
||||||
|
function getChannelLogo(name) {
|
||||||
|
return name ? name.slice(0, 2) : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交互时触发防抖重置
|
||||||
|
function onInteraction() {
|
||||||
|
emit('interaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏
|
||||||
|
function handleFavorite() {
|
||||||
|
emit('favorite');
|
||||||
|
onInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换线路
|
||||||
|
function handleSwitchSource() {
|
||||||
|
emit('switch-source');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置
|
||||||
|
function handleSettings() {
|
||||||
|
emit('settings');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bottom-panel {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 80px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧信息 */
|
||||||
|
.panel-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name-row .name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
color: #ff4444;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-label {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: -16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧按钮 */
|
||||||
|
.panel-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.active {
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn .icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滑入动画 */
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
160
ui/src/components/Layout/ChannelList.vue
Normal file
160
ui/src/components/Layout/ChannelList.vue
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<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>
|
||||||
108
ui/src/components/Layout/DateList.vue
Normal file
108
ui/src/components/Layout/DateList.vue
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<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>
|
||||||
169
ui/src/components/Layout/GroupList.vue
Normal file
169
ui/src/components/Layout/GroupList.vue
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<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>
|
||||||
237
ui/src/components/Layout/LeftPanel.vue
Normal file
237
ui/src/components/Layout/LeftPanel.vue
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="slide-left">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="left-panel"
|
||||||
|
:class="{ 'tv-mode': isTV }"
|
||||||
|
>
|
||||||
|
<!-- 第一栏:分组列表 -->
|
||||||
|
<div
|
||||||
|
class="column column-1"
|
||||||
|
:class="{ active: activeColumn === 0 }"
|
||||||
|
>
|
||||||
|
<GroupList
|
||||||
|
v-model="selectedGroup"
|
||||||
|
:groups="groups"
|
||||||
|
:is-active="activeColumn === 0"
|
||||||
|
@select="onGroupSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二栏:频道列表 -->
|
||||||
|
<div
|
||||||
|
class="column column-2"
|
||||||
|
:class="{ active: activeColumn === 1 }"
|
||||||
|
>
|
||||||
|
<ChannelList
|
||||||
|
v-model="selectedChannel"
|
||||||
|
:channels="filteredChannels"
|
||||||
|
:is-active="activeColumn === 1"
|
||||||
|
:favorites="favorites"
|
||||||
|
:validity-map="validityMap"
|
||||||
|
@select="onChannelSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第三栏:日期列表 -->
|
||||||
|
<div
|
||||||
|
class="column column-3"
|
||||||
|
:class="{ active: activeColumn === 2 }"
|
||||||
|
>
|
||||||
|
<DateList
|
||||||
|
v-model="selectedDate"
|
||||||
|
:is-active="activeColumn === 2"
|
||||||
|
@select="onDateSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第四栏:节目单列表 -->
|
||||||
|
<div
|
||||||
|
class="column column-4"
|
||||||
|
:class="{ active: activeColumn === 3 }"
|
||||||
|
>
|
||||||
|
<ProgramList
|
||||||
|
v-model="selectedProgram"
|
||||||
|
:programs="programs"
|
||||||
|
:is-active="activeColumn === 3"
|
||||||
|
@select="onProgramSelect"
|
||||||
|
/>
|
||||||
|
</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';
|
||||||
|
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
validityMap: {
|
||||||
|
type: Map,
|
||||||
|
default: () => new Map()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'play', 'lookback']);
|
||||||
|
|
||||||
|
const { isTV, currentActiveColumn, moveColumn } = useUI();
|
||||||
|
const activeColumn = computed(() => currentActiveColumn.value);
|
||||||
|
|
||||||
|
// 选中的状态
|
||||||
|
const selectedGroup = ref('');
|
||||||
|
const selectedChannel = ref(null);
|
||||||
|
const selectedDate = ref('');
|
||||||
|
const selectedProgram = ref(null);
|
||||||
|
|
||||||
|
// 过滤后的频道
|
||||||
|
const filteredChannels = computed(() => {
|
||||||
|
if (!selectedGroup.value) return props.channels;
|
||||||
|
|
||||||
|
// 置顶分组特殊处理
|
||||||
|
if (selectedGroup.value === 'recent') {
|
||||||
|
// TODO: 返回最近播放
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (selectedGroup.value === 'favorite') {
|
||||||
|
// TODO: 返回收藏
|
||||||
|
return props.channels.filter(c => props.favorites.has(c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.channels.filter(c => c.group === selectedGroup.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 节目单数据(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) => {
|
||||||
|
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: 根据当前时间选中当前节目
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分组选择
|
||||||
|
function onGroupSelect(groupId) {
|
||||||
|
// 第二栏自动切换到该分组第一个频道
|
||||||
|
const firstChannel = filteredChannels.value[0];
|
||||||
|
if (firstChannel) {
|
||||||
|
selectedChannel.value = firstChannel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 频道选择
|
||||||
|
function onChannelSelect(channel) {
|
||||||
|
// 播放频道
|
||||||
|
emit('play', channel);
|
||||||
|
// 关闭面板
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期选择
|
||||||
|
function onDateSelect(dateValue) {
|
||||||
|
// 第四栏自动切换到该日期第一个节目
|
||||||
|
// TODO: 加载对应日期的节目单
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节目选择
|
||||||
|
function onProgramSelect(program) {
|
||||||
|
// TODO: 回看功能
|
||||||
|
emit('lookback', program);
|
||||||
|
// 关闭面板
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 TV 导航(由父组件传入)
|
||||||
|
watch(activeColumn, (val) => {
|
||||||
|
// 栏切换时的处理
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导出方法供父组件调用
|
||||||
|
defineExpose({
|
||||||
|
moveColumn,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.left-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 900px;
|
||||||
|
display: flex;
|
||||||
|
z-index: 100;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-1 {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-2 {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-3 {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-4 {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TV 模式下当前激活栏的高亮 */
|
||||||
|
.tv-mode .column.active {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滑入动画 */
|
||||||
|
.slide-left-enter-active,
|
||||||
|
.slide-left-leave-active {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter-from,
|
||||||
|
.slide-left-leave-to {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
112
ui/src/components/Layout/ProgramList.vue
Normal file
112
ui/src/components/Layout/ProgramList.vue
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<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>
|
||||||
366
ui/src/components/Modals/SettingsModal.vue
Normal file
366
ui/src/components/Modals/SettingsModal.vue
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modal-overlay" @click="$emit('close')">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>设置</h3>
|
||||||
|
<button class="close-btn" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-list">
|
||||||
|
<!-- 自动播放 -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">自动播放</span>
|
||||||
|
<span class="setting-desc">切换频道后自动开始播放</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="settings.autoPlay" @change="saveSettings" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 首选画质 -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">首选画质</span>
|
||||||
|
<span class="setting-desc">优先选择指定清晰度的线路</span>
|
||||||
|
</div>
|
||||||
|
<select v-model="settings.preferredQuality" @change="saveSettings">
|
||||||
|
<option value="auto">自动</option>
|
||||||
|
<option value="hd">高清</option>
|
||||||
|
<option value="sd">标清</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 缓存有效期 -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">频道列表缓存</span>
|
||||||
|
<span class="setting-desc">频道数据缓存有效期</span>
|
||||||
|
</div>
|
||||||
|
<select v-model="listCacheHours" @change="updateCacheTTL">
|
||||||
|
<option :value="6">6小时</option>
|
||||||
|
<option :value="12">12小时</option>
|
||||||
|
<option :value="24">1天</option>
|
||||||
|
<option :value="72">3天</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">线路检测缓存</span>
|
||||||
|
<span class="setting-desc">线路可用性检测缓存有效期</span>
|
||||||
|
</div>
|
||||||
|
<select v-model="validityCacheHours" @change="updateCacheTTL">
|
||||||
|
<option :value="1">1小时</option>
|
||||||
|
<option :value="6">6小时</option>
|
||||||
|
<option :value="12">12小时</option>
|
||||||
|
<option :value="24">1天</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- 缓存管理 -->
|
||||||
|
<div class="setting-item cache-actions">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">缓存管理</span>
|
||||||
|
<span class="setting-desc">清除本地缓存数据</span>
|
||||||
|
</div>
|
||||||
|
<div class="cache-buttons">
|
||||||
|
<button class="action-btn" @click="clearChannelCache">
|
||||||
|
清除频道缓存
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" @click="clearValidityCache">
|
||||||
|
清除检测缓存
|
||||||
|
</button>
|
||||||
|
<button class="action-btn danger" @click="clearAllCache">
|
||||||
|
清除全部缓存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- 数据刷新 -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">刷新数据</span>
|
||||||
|
<span class="setting-desc">立即从订阅源重新加载频道</span>
|
||||||
|
</div>
|
||||||
|
<button class="action-btn primary" @click="reloadChannels" :disabled="reloading">
|
||||||
|
{{ reloading ? '刷新中...' : '立即刷新' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useStorage } from '../../composables/useStorage.js';
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'reload']);
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const settings = ref({
|
||||||
|
autoPlay: true,
|
||||||
|
preferredQuality: 'auto',
|
||||||
|
listCacheTTL: 24 * 60 * 60 * 1000,
|
||||||
|
validityCacheTTL: 12 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listCacheHours = ref(24);
|
||||||
|
const validityCacheHours = ref(12);
|
||||||
|
const reloading = ref(false);
|
||||||
|
|
||||||
|
// 加载设置
|
||||||
|
onMounted(async () => {
|
||||||
|
const prefs = await storage.getPreferences();
|
||||||
|
settings.value = { ...settings.value, ...prefs };
|
||||||
|
listCacheHours.value = Math.floor(prefs.listCacheTTL / (60 * 60 * 1000)) || 24;
|
||||||
|
validityCacheHours.value = Math.floor(prefs.validityCacheTTL / (60 * 60 * 1000)) || 12;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存设置
|
||||||
|
async function saveSettings() {
|
||||||
|
await storage.setPreferences(settings.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缓存时间
|
||||||
|
async function updateCacheTTL() {
|
||||||
|
settings.value.listCacheTTL = listCacheHours.value * 60 * 60 * 1000;
|
||||||
|
settings.value.validityCacheTTL = validityCacheHours.value * 60 * 60 * 1000;
|
||||||
|
await saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除频道缓存
|
||||||
|
async function clearChannelCache() {
|
||||||
|
if (!confirm('确定要清除频道列表缓存吗?')) return;
|
||||||
|
await storage.setChannels([]);
|
||||||
|
await storage.setCacheMeta('channels', null);
|
||||||
|
alert('频道缓存已清除');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除检测缓存
|
||||||
|
async function clearValidityCache() {
|
||||||
|
if (!confirm('确定要清除线路检测缓存吗?')) return;
|
||||||
|
const all = await storage.getAllValidity();
|
||||||
|
for (const v of all) {
|
||||||
|
await storage.remove(`validity_${v.url}`);
|
||||||
|
}
|
||||||
|
alert('检测缓存已清除');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除全部缓存
|
||||||
|
async function clearAllCache() {
|
||||||
|
if (!confirm('确定要清除全部缓存数据吗?包括收藏和历史记录?')) return;
|
||||||
|
await storage.clear();
|
||||||
|
alert('全部缓存已清除');
|
||||||
|
emit('reload');
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新频道
|
||||||
|
async function reloadChannels() {
|
||||||
|
reloading.value = true;
|
||||||
|
emit('reload');
|
||||||
|
setTimeout(() => {
|
||||||
|
reloading.value = false;
|
||||||
|
emit('close');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-list {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item.cache-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch 开关 */
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 24px;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select 下拉框 */
|
||||||
|
select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮 */
|
||||||
|
.cache-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary {
|
||||||
|
background: #00ff88;
|
||||||
|
color: #000;
|
||||||
|
border-color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary:hover {
|
||||||
|
background: #00cc6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.danger {
|
||||||
|
background: rgba(255, 68, 68, 0.2);
|
||||||
|
border-color: rgba(255, 68, 68, 0.5);
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.danger:hover {
|
||||||
|
background: rgba(255, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
142
ui/src/components/Modals/SourceModal.vue
Normal file
142
ui/src/components/Modals/SourceModal.vue
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modal-overlay" @click="$emit('close')">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<h3>切换线路</h3>
|
||||||
|
<div class="source-list">
|
||||||
|
<div
|
||||||
|
v-for="(url, index) in channel?.urls"
|
||||||
|
:key="index"
|
||||||
|
class="source-item"
|
||||||
|
:class="{
|
||||||
|
active: currentUrl === url,
|
||||||
|
online: getValidity(url)?.status === 'online',
|
||||||
|
offline: getValidity(url)?.status === 'offline'
|
||||||
|
}"
|
||||||
|
@click="$emit('switch', { url }, index)"
|
||||||
|
>
|
||||||
|
<span class="source-name">线路 {{ index + 1 }}</span>
|
||||||
|
<span class="source-status" :class="getValidity(url)?.status">
|
||||||
|
{{ getStatusText(url) }}
|
||||||
|
</span>
|
||||||
|
<span class="source-latency" v-if="getValidity(url)?.latency > 0">
|
||||||
|
{{ getValidity(url).latency }}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
channel: Object,
|
||||||
|
currentUrl: String,
|
||||||
|
validityMap: {
|
||||||
|
type: Map,
|
||||||
|
default: () => new Map()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['switch', 'close']);
|
||||||
|
|
||||||
|
function getValidity(url) {
|
||||||
|
return props.validityMap.get(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(url) {
|
||||||
|
const validity = getValidity(url);
|
||||||
|
if (!validity) return '未检测';
|
||||||
|
if (validity.status === 'online') return '在线';
|
||||||
|
if (validity.status === 'offline') return '离线';
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item:hover,
|
||||||
|
.source-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item.active {
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item.online {
|
||||||
|
border-left-color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item.offline {
|
||||||
|
border-left-color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status.online {
|
||||||
|
background: rgba(0, 255, 136, 0.2);
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status.offline {
|
||||||
|
background: rgba(255, 68, 68, 0.2);
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-latency {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -26,6 +26,8 @@ const props = defineProps({
|
|||||||
title: String
|
title: String
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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
|
||||||
@ -60,6 +62,7 @@ const initPlayer = () => {
|
|||||||
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 })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
@ -71,6 +74,7 @@ const initPlayer = () => {
|
|||||||
|
|
||||||
video.onerror = () => {
|
video.onerror = () => {
|
||||||
error.value = 'networkError'
|
error.value = 'networkError'
|
||||||
|
emit('error', { type: 'networkError', fatal: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
169
ui/src/composables/useStorage.js
Normal file
169
ui/src/composables/useStorage.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
137
ui/src/composables/useUI.js
Normal file
137
ui/src/composables/useUI.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
8
ui/src/storage/adapters/README.md
Normal file
8
ui/src/storage/adapters/README.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Storage Adapters
|
||||||
|
|
||||||
|
存储适配器目录,按平台分离实现:
|
||||||
|
|
||||||
|
- `indexeddb.js` - Web/Desktop 端(使用 IndexedDB)
|
||||||
|
- `native.js` - Android/TV 端(通过 JS Bridge 调用原生存储)
|
||||||
|
|
||||||
|
目前所有实现统一在 `../index.js` 中,如需拆分可在此目录创建单独文件。
|
||||||
467
ui/src/storage/index.js
Normal file
467
ui/src/storage/index.js
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
import { openDB } from 'idb';
|
||||||
|
import {
|
||||||
|
Subscription, Channel, SourceValidity,
|
||||||
|
Preferences, PlayHistory, CacheMeta
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const DB_NAME = 'IPTVStorage';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽象存储接口
|
||||||
|
*/
|
||||||
|
class IStorage {
|
||||||
|
// 基础操作
|
||||||
|
async get(key) { throw new Error('Not implemented'); }
|
||||||
|
async set(key, value) { throw new Error('Not implemented'); }
|
||||||
|
async remove(key) { throw new Error('Not implemented'); }
|
||||||
|
async clear() { throw new Error('Not implemented'); }
|
||||||
|
|
||||||
|
// 频道数据
|
||||||
|
async getChannels() { throw new Error('Not implemented'); }
|
||||||
|
async setChannels(channels) { throw new Error('Not implemented'); }
|
||||||
|
async getGroups() { throw new Error('Not implemented'); }
|
||||||
|
|
||||||
|
// 订阅源
|
||||||
|
async getSubscriptions() { throw new Error('Not implemented'); }
|
||||||
|
async setSubscriptions(subs) { throw new Error('Not implemented'); }
|
||||||
|
|
||||||
|
// 线路有效性
|
||||||
|
async getValidity(url) { throw new Error('Not implemented'); }
|
||||||
|
async setValidity(url, validity) { throw new Error('Not implemented'); }
|
||||||
|
async getAllValidity() { throw new Error('Not implemented'); }
|
||||||
|
|
||||||
|
// 用户偏好
|
||||||
|
async getPreferences() { throw new Error('Not implemented'); }
|
||||||
|
async setPreferences(prefs) { throw new Error('Not implemented'); }
|
||||||
|
|
||||||
|
// 播放历史
|
||||||
|
async getHistory(limit = 50) { throw new Error('Not implemented'); }
|
||||||
|
async addHistory(item) { throw new Error('Not implemented'); }
|
||||||
|
async clearHistory() { throw new Error('Not implemented'); }
|
||||||
|
|
||||||
|
// 缓存元数据
|
||||||
|
async getCacheMeta(key) { throw new Error('Not implemented'); }
|
||||||
|
async setCacheMeta(key, meta) { throw new Error('Not implemented'); }
|
||||||
|
async isCacheValid(key, ttl) { throw new Error('Not implemented'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IndexedDB 实现 (Web/Desktop)
|
||||||
|
*/
|
||||||
|
class IndexedDBStorage extends IStorage {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.db) return;
|
||||||
|
|
||||||
|
this.db = await openDB(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
// 存储对象定义
|
||||||
|
if (!db.objectStoreNames.contains('channels')) {
|
||||||
|
const channelStore = db.createObjectStore('channels', { keyPath: 'id' });
|
||||||
|
channelStore.createIndex('group', 'group');
|
||||||
|
channelStore.createIndex('name', 'name');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('subscriptions')) {
|
||||||
|
db.createObjectStore('subscriptions', { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('validity')) {
|
||||||
|
db.createObjectStore('validity', { keyPath: 'url' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('preferences')) {
|
||||||
|
db.createObjectStore('preferences');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('history')) {
|
||||||
|
const historyStore = db.createObjectStore('history', {
|
||||||
|
keyPath: 'timestamp',
|
||||||
|
autoIncrement: true
|
||||||
|
});
|
||||||
|
historyStore.createIndex('channelId', 'channelId');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('cacheMeta')) {
|
||||||
|
db.createObjectStore('cacheMeta', { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('generic')) {
|
||||||
|
db.createObjectStore('generic');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础操作
|
||||||
|
async get(key) {
|
||||||
|
await this.init();
|
||||||
|
return await this.db.get('generic', key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key, value) {
|
||||||
|
await this.init();
|
||||||
|
await this.db.put('generic', value, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key) {
|
||||||
|
await this.init();
|
||||||
|
await this.db.delete('generic', key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
await this.init();
|
||||||
|
const stores = ['channels', 'subscriptions', 'validity', 'history', 'cacheMeta', 'generic'];
|
||||||
|
for (const store of stores) {
|
||||||
|
await this.db.clear(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 频道数据
|
||||||
|
async getChannels() {
|
||||||
|
await this.init();
|
||||||
|
return await this.db.getAll('channels');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setChannels(channels) {
|
||||||
|
await this.init();
|
||||||
|
const tx = this.db.transaction('channels', 'readwrite');
|
||||||
|
await tx.store.clear();
|
||||||
|
for (const channel of channels) {
|
||||||
|
await tx.store.put(channel);
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
|
||||||
|
// 更新缓存元数据
|
||||||
|
await this.setCacheMeta('channels', new CacheMeta({
|
||||||
|
key: 'channels',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
size: channels.length
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroups() {
|
||||||
|
const channels = await this.getChannels();
|
||||||
|
const groups = new Set(channels.map(c => c.group));
|
||||||
|
return Array.from(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅源
|
||||||
|
async getSubscriptions() {
|
||||||
|
await this.init();
|
||||||
|
return await this.db.getAll('subscriptions');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSubscriptions(subs) {
|
||||||
|
await this.init();
|
||||||
|
const tx = this.db.transaction('subscriptions', 'readwrite');
|
||||||
|
await tx.store.clear();
|
||||||
|
for (const sub of subs) {
|
||||||
|
await tx.store.put(sub);
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线路有效性
|
||||||
|
async getValidity(url) {
|
||||||
|
await this.init();
|
||||||
|
return await this.db.get('validity', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setValidity(url, validity) {
|
||||||
|
await this.init();
|
||||||
|
await this.db.put('validity', { ...validity, url });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllValidity() {
|
||||||
|
await this.init();
|
||||||
|
return await this.db.getAll('validity');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户偏好
|
||||||
|
async getPreferences() {
|
||||||
|
await this.init();
|
||||||
|
const prefs = await this.db.get('preferences', 'user');
|
||||||
|
return prefs || new Preferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPreferences(prefs) {
|
||||||
|
await this.init();
|
||||||
|
await this.db.put('preferences', prefs, 'user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放历史
|
||||||
|
async getHistory(limit = 50) {
|
||||||
|
await this.init();
|
||||||
|
const all = await this.db.getAll('history');
|
||||||
|
return all.slice(-limit).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addHistory(item) {
|
||||||
|
await this.init();
|
||||||
|
await this.db.add('history', item);
|
||||||
|
|
||||||
|
// 清理旧记录(保留最近 500 条)
|
||||||
|
const count = await this.db.count('history');
|
||||||
|
if (count > 500) {
|
||||||
|
const toDelete = count - 500;
|
||||||
|
const keys = await this.db.getAllKeys('history', null, toDelete);
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.db.delete('history', key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearHistory() {
|
||||||
|
await this.init();
|
||||||
|
await this.db.clear('history');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存元数据
|
||||||
|
async getCacheMeta(key) {
|
||||||
|
await this.init();
|
||||||
|
return await this.db.get('cacheMeta', key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCacheMeta(key, meta) {
|
||||||
|
await this.init();
|
||||||
|
await this.db.put('cacheMeta', { ...meta, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
async isCacheValid(key, ttl) {
|
||||||
|
const meta = await this.getCacheMeta(key);
|
||||||
|
if (!meta || !meta.updatedAt) return false;
|
||||||
|
return Date.now() - meta.updatedAt < ttl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native 存储实现 (Android/TV)
|
||||||
|
* 通过 AndroidAsset 接口调用原生 SQLite/SharedPreferences
|
||||||
|
*/
|
||||||
|
class NativeStorage extends IStorage {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.memoryCache = new Map(); // 内存缓存
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Android 接口是否可用
|
||||||
|
isAvailable() {
|
||||||
|
return typeof window.AndroidAsset !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用原生接口
|
||||||
|
async callNative(method, ...args) {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
throw new Error('AndroidAsset not available');
|
||||||
|
}
|
||||||
|
return window.AndroidAsset[method](...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础操作 (使用 localStorage 作为后备)
|
||||||
|
async get(key) {
|
||||||
|
try {
|
||||||
|
const value = await this.callNative('getItem', key);
|
||||||
|
return value ? JSON.parse(value) : null;
|
||||||
|
} catch {
|
||||||
|
return this.memoryCache.get(key) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key, value) {
|
||||||
|
this.memoryCache.set(key, value);
|
||||||
|
try {
|
||||||
|
await this.callNative('setItem', key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// 忽略原生存储失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key) {
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
try {
|
||||||
|
await this.callNative('removeItem', key);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
this.memoryCache.clear();
|
||||||
|
try {
|
||||||
|
await this.callNative('clear');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 频道数据 (使用专用接口)
|
||||||
|
async getChannels() {
|
||||||
|
try {
|
||||||
|
const data = await this.callNative('readChannelData');
|
||||||
|
if (data && !data.startsWith('ERROR:')) {
|
||||||
|
// 解析频道数据并缓存
|
||||||
|
const channels = this.parseChannelData(data);
|
||||||
|
this.memoryCache.set('channels', channels);
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return this.memoryCache.get('channels') || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async setChannels(channels) {
|
||||||
|
this.memoryCache.set('channels', channels);
|
||||||
|
// 原生端通过文件存储,这里只更新内存缓存
|
||||||
|
await this.setCacheMeta('channels', new CacheMeta({
|
||||||
|
key: 'channels',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
size: channels.length
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
parseChannelData(text) {
|
||||||
|
// 简单的 TXT 格式解析
|
||||||
|
const channels = [];
|
||||||
|
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 = channels.find(c => c.name === name && c.group === currentGroup);
|
||||||
|
if (existing) {
|
||||||
|
existing.urls.push(url);
|
||||||
|
} else {
|
||||||
|
channels.push(new Channel({
|
||||||
|
id: `${currentGroup}_${name}`,
|
||||||
|
name,
|
||||||
|
group: currentGroup,
|
||||||
|
urls: [url]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroups() {
|
||||||
|
const channels = await this.getChannels();
|
||||||
|
const groups = new Set(channels.map(c => c.group));
|
||||||
|
return Array.from(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅源
|
||||||
|
async getSubscriptions() {
|
||||||
|
return await this.get('subscriptions') || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSubscriptions(subs) {
|
||||||
|
await this.set('subscriptions', subs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线路有效性 (使用 SharedPreferences)
|
||||||
|
async getValidity(url) {
|
||||||
|
const all = await this.getAllValidity();
|
||||||
|
return all.find(v => v.url === url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setValidity(url, validity) {
|
||||||
|
const all = await this.getAllValidity();
|
||||||
|
const index = all.findIndex(v => v.url === url);
|
||||||
|
if (index >= 0) {
|
||||||
|
all[index] = { ...validity, url };
|
||||||
|
} else {
|
||||||
|
all.push({ ...validity, url });
|
||||||
|
}
|
||||||
|
await this.set('validity', all);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllValidity() {
|
||||||
|
return await this.get('validity') || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户偏好
|
||||||
|
async getPreferences() {
|
||||||
|
return await this.get('preferences') || new Preferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPreferences(prefs) {
|
||||||
|
await this.set('preferences', prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放历史
|
||||||
|
async getHistory(limit = 50) {
|
||||||
|
const all = await this.get('history') || [];
|
||||||
|
return all.slice(-limit).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addHistory(item) {
|
||||||
|
const all = await this.get('history') || [];
|
||||||
|
all.push(item);
|
||||||
|
// 保留最近 500 条
|
||||||
|
if (all.length > 500) {
|
||||||
|
all.splice(0, all.length - 500);
|
||||||
|
}
|
||||||
|
await this.set('history', all);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearHistory() {
|
||||||
|
await this.remove('history');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存元数据
|
||||||
|
async getCacheMeta(key) {
|
||||||
|
const all = await this.get('cacheMeta') || {};
|
||||||
|
return all[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCacheMeta(key, meta) {
|
||||||
|
const all = await this.get('cacheMeta') || {};
|
||||||
|
all[key] = { ...meta, key };
|
||||||
|
await this.set('cacheMeta', all);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isCacheValid(key, ttl) {
|
||||||
|
const meta = await this.getCacheMeta(key);
|
||||||
|
if (!meta || !meta.updatedAt) return false;
|
||||||
|
return Date.now() - meta.updatedAt < ttl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储工厂 - 根据平台创建对应的存储实例
|
||||||
|
*/
|
||||||
|
export function createStorage() {
|
||||||
|
const platform = import.meta.env.VITE_PLATFORM || 'web';
|
||||||
|
|
||||||
|
if (platform === 'android' || platform === 'tv') {
|
||||||
|
return new NativeStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IndexedDBStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出类型和类
|
||||||
|
export {
|
||||||
|
IStorage,
|
||||||
|
IndexedDBStorage,
|
||||||
|
NativeStorage,
|
||||||
|
Subscription,
|
||||||
|
Channel,
|
||||||
|
SourceValidity,
|
||||||
|
Preferences,
|
||||||
|
PlayHistory,
|
||||||
|
CacheMeta
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认导出
|
||||||
|
export default createStorage;
|
||||||
72
ui/src/storage/types.js
Normal file
72
ui/src/storage/types.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 存储类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 订阅源
|
||||||
|
export class Subscription {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id || crypto.randomUUID();
|
||||||
|
this.name = data.name || '';
|
||||||
|
this.url = data.url || '';
|
||||||
|
this.type = data.type || 'm3u'; // 'm3u' | 'txt'
|
||||||
|
this.enabled = data.enabled !== false;
|
||||||
|
this.lastUpdated = data.lastUpdated || 0;
|
||||||
|
this.etag = data.etag || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 频道
|
||||||
|
export class Channel {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.id = data.id || '';
|
||||||
|
this.name = data.name || '';
|
||||||
|
this.group = data.group || '';
|
||||||
|
this.urls = data.urls || []; // string[]
|
||||||
|
this.logo = data.logo || '';
|
||||||
|
this.epgId = data.epgId || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线路有效性
|
||||||
|
export class SourceValidity {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.url = data.url || '';
|
||||||
|
this.status = data.status || 'unknown'; // 'online' | 'offline' | 'unknown'
|
||||||
|
this.checkedAt = data.checkedAt || 0;
|
||||||
|
this.latency = data.latency || 0;
|
||||||
|
this.failCount = data.failCount || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户偏好
|
||||||
|
export class Preferences {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.defaultGroup = data.defaultGroup || '';
|
||||||
|
this.autoPlay = data.autoPlay !== false;
|
||||||
|
this.preferredQuality = data.preferredQuality || 'auto'; // 'auto' | 'hd' | 'sd'
|
||||||
|
this.volume = data.volume ?? 1.0;
|
||||||
|
this.listCacheTTL = data.listCacheTTL || 24 * 60 * 60 * 1000; // 1天
|
||||||
|
this.validityCacheTTL = data.validityCacheTTL || 12 * 60 * 60 * 1000; // 12小时
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放历史
|
||||||
|
export class PlayHistory {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.channelId = data.channelId || '';
|
||||||
|
this.channelName = data.channelName || '';
|
||||||
|
this.sourceUrl = data.sourceUrl || '';
|
||||||
|
this.timestamp = data.timestamp || Date.now();
|
||||||
|
this.duration = data.duration || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存元数据
|
||||||
|
export class CacheMeta {
|
||||||
|
constructor(data = {}) {
|
||||||
|
this.key = data.key || '';
|
||||||
|
this.updatedAt = data.updatedAt || 0;
|
||||||
|
this.size = data.size || 0;
|
||||||
|
this.version = data.version || 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,26 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [vue()],
|
// 加载对应的环境变量
|
||||||
base: './',
|
const env = loadEnv(mode, process.cwd(), 'VITE_')
|
||||||
build: {
|
const platform = env.VITE_PLATFORM || 'web'
|
||||||
outDir: 'dist-web'
|
|
||||||
|
return {
|
||||||
|
plugins: [vue()],
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: `dist/${platform}`,
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__PLATFORM__: JSON.stringify(platform),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -8,7 +8,7 @@ WORKDIR /app
|
|||||||
# 复制前端代码
|
# 复制前端代码
|
||||||
COPY ../ui ./ui
|
COPY ../ui ./ui
|
||||||
WORKDIR /app/ui
|
WORKDIR /app/ui
|
||||||
RUN npm install && npm run build
|
RUN npm install && npm run build:web
|
||||||
|
|
||||||
# 阶段2:运行服务端
|
# 阶段2:运行服务端
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
@ -23,7 +23,7 @@ RUN npm install --production
|
|||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
# 复制构建好的前端
|
# 复制构建好的前端
|
||||||
COPY --from=builder /app/ui/dist-web ./public
|
COPY --from=builder /app/ui/dist/web ./public
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user