- 新增alist源聚合播放

- 修改部分ui
- 修改部分seo
This commit is contained in:
lei 2024-10-27 17:11:48 +08:00
parent dab30640d4
commit 5d4619b2be
19 changed files with 806 additions and 170 deletions

View File

@ -8,7 +8,8 @@
👉 [爱盼-网盘资源搜索](https://www.aipan.me)
### 🔥更新日志
- tv播放
- 新增Alist源聚合播放
- 新增批量删除功能
- 新增博客功能 (分支:[feat-admin-panel](https://github.com/unilei/aipan-netdisk-search/tree/feat-add-admin-panel)
- 新增批量上传数据 [csv示例](/assets//readme//demo/demo-multi.csv) [xlsx 示例](/assets/readme/demo/demo-multi.xls)
@ -97,6 +98,8 @@ yarn dev
### 4. 在浏览器打开 [http://localhost:3001](http://localhost:3001)
![success_deploy.jpg](/assets/readme/screen-6.png)
![success_deploy.jpg](/assets/readme/screen-5.png)
![success_deploy.jpg](/assets/readme/screen-1.png)
![success_deploy.jpg](/assets/readme/screen-2.png)
![success_deploy.jpg](/assets/readme/screen-3.png)

BIN
assets/readme/screen-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

BIN
assets/readme/screen-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -1,7 +1,7 @@
<template>
<div class="py-[20px] dark:bg-slate-800">
<p class="text-[12px] text-center text-gray-400">
爱盼-网盘资源搜索
爱盼 - 资源随心娱乐无限
</p>
</div>
</template>

View File

@ -5,25 +5,23 @@ export default defineNuxtConfig({
// head
pageTransition: { name: 'page', mode: 'out-in' },
head: {
title: '爱盼-网盘资源搜索',
title: '爱盼:资源随心,娱乐无限',
meta: [
{
name: 'description',
content: '爱盼-网盘资源搜索, 一个开源免费的网盘资源搜索程序,仅供学习使用,不支持商业用途。'
},
{ name: 'keywords', content: '爱盼, 开源, 免费资源搜索, 网盘搜索, 音乐下载, TVBox数据接口, 电视直播, 博客发布, 影视资源, 教学工具, 非商业用途' },
{ hid: 'description', name: 'description', content: '爱盼是一个开源免费的资源搜索平台提供网盘资源搜索、音乐下载、TV直播、TVBox接口地址以及博客发布等多项功能打造丰富的影视音聚合体验供学习与探索使用不支持商业用途。' },
{ name: 'format-detection', content: 'telephone=no' },
{ property: 'og:title', content: '爱盼:资源随心,音乐下载与影视聚合平台' },
{ property: 'og:description', content: '爱盼是一个开源免费的资源搜索平台,提供网盘、音乐、影视等多种资源,一站式服务,供学习使用。' },
{ property: 'og:image', content: 'https://aipan.me/logo.png' },
{ property: 'og:url', content: 'https://aipan.me' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: '爱盼:资源随心,音乐下载与影视聚合平台' },
{ name: 'twitter:description', content: '免费开源的资源搜索平台,涵盖音乐、网盘、影视等内容,学习探索好去处!' },
{ name: 'twitter:image', content: 'https://aipan.me/logo.png' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
name: 'referrer',
content: 'no-referrer'
},
{
name: 'referrer',
content: 'always'
},
{
name: 'referrer',
content: 'strict-origin-when-cross-origin'
}
{ name: 'referrer', content: 'no-referrer' },
{ name: 'referrer', content: 'always' },
{ name: 'referrer', content: 'strict-origin-when-cross-origin' }
],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
script: [
@ -137,9 +135,9 @@ export default defineNuxtConfig({
]
},
site: {
name: '爱盼-网盘资源搜索',
name: '爱盼 - 资源随心,娱乐无限',
url: 'https://www.aipan.me',
description: '爱盼-网盘资源搜索 一个开源免费的网盘资源搜索程序,仅供学习使用,不支持商业用途。'
description: '爱盼 - 资源随心,娱乐无限 一个开源免费的网盘资源搜索程序,仅供学习使用,不支持商业用途。'
},
compatibilityDate: '2024-09-12'
})

165
pages/admin/alist.vue Normal file
View File

@ -0,0 +1,165 @@
<script setup>
definePageMeta({
middleware: ['auth']
})
const alistDialogShow = ref(false)
const handleAddAlist = () => {
alistDialogShow.value = true
}
const form = reactive({
name: '',
link: '',
})
const formRef = ref()
const handleSubmitAdd = () => {
formRef.value.validate((valid) => {
if (!valid) {
return
}
if (form.id) {
$fetch(`/api/admin/alist/${form.id}`, {
method: 'PUT',
body: {
id: form.id,
name: form.name,
link: form.link
},
headers: {
"authorization": "Bearer " + useCookie('token').value
}
})
} else {
$fetch('/api/admin/alist/post', {
method: 'POST',
body: {
name: form.name,
link: form.link
},
headers: {
"authorization": "Bearer " + useCookie('token').value
}
})
}
setTimeout(() => {
getAlists()
}, 3000);
alistDialogShow.value = false
})
}
const alistsData = ref([])
const page = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
const getAlists = async () => {
const res = await $fetch('/api/admin/alist/get', {
method: 'GET',
query: {
page: page.value,
pageSize: pageSize.value
},
headers: {
"authorization": "Bearer " + useCookie('token').value
}
})
// console.log(res)
alistsData.value = res.alists;
totalCount.value = res.totalCount;
}
const handleCurrentChange = (val) => {
page.value = val
getAlists()
}
const handleSizeChange = (val) => {
pageSize.value = val
getAlists()
}
const handleEditClouddrive = (row) => {
// console.log(row)
form.id = row.id
form.name = row.name
form.link = row.link
alistDialogShow.value = true
}
const handleDeleteAlist = (row) => {
// console.log(row)
$fetch(`/api/admin/alist/${row.id}`, {
method: 'DELETE',
headers: {
"authorization": "Bearer " + useCookie('token').value
}
})
getAlists()
}
onMounted(() => {
getAlists()
})
</script>
<template>
<div>
<div class="p-10 max-w-[1240px] mx-auto ">
<h1 class="text-xl text-bold space-x-2">
<nuxt-link to="/admin/dashboard">后台管理面板</nuxt-link>
<span>/</span>
<nuxt-link to="/admin/alist">Alist源管理</nuxt-link>
</h1>
<div class="h-[1px] bg-slate-300 mt-6"></div>
<div class="mt-6 grid grid-cols-4 gap-4">
<el-button type="primary" @click="handleAddAlist()">添加数据</el-button>
</div>
<client-only>
<div class="mt-6">
<el-table ref="multipleTableRef" :data="alistsData">
<el-table-column prop="name" label="名字"></el-table-column>
<el-table-column prop="link" label="源链接"></el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button type="primary"
@click="handleEditClouddrive(scope.row, scope.$index)">编辑</el-button>
<el-button type="danger"
@click="handleDeleteAlist(scope.row, scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-6 flex items-center justify-center">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[100, 200, 300, 400]" background
layout="total, sizes, prev, pager, next, jumper" :total="totalCount"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</div>
</client-only>
</div>
<el-dialog v-model="alistDialogShow" :title="form.id ? '编辑资源' : '添加资源'">
<main>
<el-form ref="formRef" :model="form" label-width="auto">
<el-form-item label="名字" prop="name" :rules="{
required: true,
message: '名字不能为空',
trigger: 'blur'
}">
<el-input v-model="form.name" type="textarea"></el-input>
</el-form-item>
<el-form-item label="源链接" prop="link"
:rules="{ required: true, message: '链接不能为空', trigger: 'blur' }">
<el-input v-model="form.link" type="textarea"></el-input>
</el-form-item>
</el-form>
</main>
<template #footer>
<span class="dialog-footer">
<el-button @click="alistDialogShow = false">取消</el-button>
<el-button type="primary" @click="handleSubmitAdd()"> 确认 </el-button>
</span>
</template>
</el-dialog>
</div>
</template>

View File

@ -14,6 +14,9 @@ definePageMeta({
<NuxtLink class="bg-slate-500 p-3 text-white rounded-lg hover:bg-black" to="/admin/blog">
博客管理
</NuxtLink>
<NuxtLink class="bg-slate-500 p-3 text-white rounded-lg hover:bg-black" to="/admin/alist">
Alist源管理
</NuxtLink>
</div>
</div>
</template>

View File

@ -48,7 +48,7 @@ onMounted(async () => {
<div class="bg-[#ffffff] dark:bg-gray-800 min-h-screen py-[60px]">
<div class="flex flex-row items-center justify-center gap-3 mt-[80px]">
<img class="w-[40px] h-[40px] sm:w-[60px] sm:h-[60px]" src="@/assets/my-logo.png" alt="logo">
<h1 class="text-[18px] sm:text-[22px] font-bold dark:text-white ">爱盼-网盘资源搜索</h1>
<h1 class="text-[18px] sm:text-[22px] font-bold dark:text-white ">爱盼 - 资源随心娱乐无限</h1>
</div>
<div class="max-w-[1240px] mx-auto mt-[20px]">
<div class="w-[80%] md:w-[700px] mx-auto flex flex-row items-center gap-2 relative">

View File

@ -1,10 +1,35 @@
<script setup>
useHead({
title: '爱盼 - 电视直播与 Alist 数据源聚合播放',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1, shrink-to-fit=no' },
{ name: 'keywords', content: '爱盼, 电视直播, Alist 数据源, 聚合播放, 在线电视' },
{ hid: 'description', name: 'description', content: '爱盼提供最新的电视直播和 Alist 数据源聚合播放,轻松享受精彩内容!' },
{ name: 'author', content: '爱盼团队' },
{ name: 'robots', content: 'index, follow' },
{ name: 'format-detection', content: 'telephone=no' },
{ property: 'og:title', content: '爱盼 - 电视直播与 Alist 数据源聚合播放' },
{ property: 'og:description', content: '爱盼提供最新的电视直播和 Alist 数据源聚合播放,轻松享受精彩内容!' },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: "https://aipan.me/tv" }, // URL
{ property: 'og:image', content: '/logo.png' }, //
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: '爱盼 - 电视直播与 Alist 数据源聚合播放' },
{ name: 'twitter:description', content: '爱盼提供最新的电视直播和 Alist 数据源聚合播放,轻松享受精彩内容!' },
{ name: 'twitter:image', content: '/logo.png' } // Twitter
]
});
import Hls from "hls.js";
import videojs from "video.js";
import "video.js/dist/video-js.css";
import bgImage from '~/assets/tv-bg-1.jpg';
import { sourcesAipan } from "~/assets/vod/tv";
import { useTvStore } from "~/stores/tv";
definePageMeta({
layout: 'custom',
});
const tvStore = useTvStore();
const sourceIndex = ref(0);
const tvSources = ref([]);
const videoPlayer = ref(null);
@ -14,11 +39,20 @@ const videoPlayStatus = ref(false);
const videoLoading = ref(false);
const videoMuted = ref(false);
let player = null;
let hls = null; // HLS
let currentEffectIndex = 0;
const tvPassword = ref("");
const tvPasswordInputShow = ref(false);
const alistData = ref([])
const alistPath = ref([''])
const currentIsDir = ref(true) //
const alistUrl = ref("")
const alistSettingData = ref([])
const alistSettingShow = ref(false)
const alistCurrentPlayIndex = ref(0)
// m3u8
const isM3u8 = (url) => /\.m3u8(\?.*)?$/.test(url);
//
const getTvSources = async () => {
try {
@ -32,92 +66,100 @@ const getTvSources = async () => {
console.error('Error fetching TV sources:', error);
}
};
// HLS
const loadHLS = (url) => {
// m3u8
if (url.endsWith('.m3u8')) {
if (!hls && Hls.isSupported()) {
//
const showLoadingSpinner = () => {
videoLoading.value = true;
};
//
const hideLoadingSpinner = () => {
videoLoading.value = false;
videoPlayStatus.value = true;
};
//
let type = '';
if (url.includes('.mp4')) {
type = 'video/mp4';
} else if (url.includes('.mkv')) {
type = 'video/webm';
} else if (url.includes('.ts')) {
type = 'video/mp2t';
} else if (isM3u8(url)) {
type = 'application/x-mpegURL'; // HLS/M3U8
}
if (!player) {
//
const options = {
liveui: true,
html5: {
hls: {
enableLowInitialPlaylist: true,
smoothQualityChange: true,
},
},
};
//
player = videojs(videoPlayer.value, options);
player.on('ended', () => {
player.currentTime(0);
player.play();
});
player.on("waiting", showLoadingSpinner);
player.on("playing", hideLoadingSpinner);
player.on("error", hideLoadingSpinner);
}
if (type === 'application/x-mpegURL' && Hls.isSupported()) {
// HLS
if (!hls) {
hls = new Hls();
hls.attachMedia(videoPlayer.value);
hls.on(Hls.Events.MANIFEST_LOADING, showLoadingSpinner);
hls.on(Hls.Events.MANIFEST_PARSED, hideLoadingSpinner);
hls.on(Hls.Events.ERROR, hideLoadingSpinner);
}
if (Hls.isSupported()) {
hls.loadSource(url);
videoPlayer.value.play();
videoPlayer.value.muted = videoMuted.value;
videoPlayStatus.value = true;
videoLoading.value = false;
modalShow.value = false;
} else if (videoPlayer.value.canPlayType('application/vnd.apple.mpegurl')) {
// Safari HLS
videoPlayer.value.src = url;
videoPlayer.value.play();
videoPlayer.value.muted = videoMuted.value;
videoPlayStatus.value = true;
videoLoading.value = false;
modalShow.value = false;
}
hls.loadSource(url);
} else {
// m3u8 URL videoPlayer src
// HLS
if (hls) {
hls.destroy();
hls = null; // HLS 使
hls = null;
}
videoPlayer.value.src = url;
videoPlayer.value.load(); //
videoPlayer.value.play();
videoPlayer.value.muted = videoMuted.value;
showLoadingSpinner();
player.src({ type, src: url });
}
player.play();
player.on("loadeddata", hideLoadingSpinner);
player.on("loadedmetadata", hideLoadingSpinner);
};
const handleSwithcSource = async (url) => {
if (channelCategory.value === 3) {
modalShow.value = true
} else {
videoLoading.value = true;
videoSrc.value = url;
loadHLS(url);
videoPlayStatus.value = true;
videoLoading.value = false;
modalShow.value = false;
}
};
//
const handleSwithcSource = (url) => {
videoLoading.value = true;
// src
if (videoPlayer.value) {
videoPlayer.value.pause();
videoPlayer.value.removeAttribute('src'); //
videoPlayer.value.load(); // <video>
}
videoSrc.value = url;
loadHLS(url);
};
const handleInputPassword = () => {
if (tvPassword.value === 'aipan.me') {
alert('密码正确')
tvPasswordInputShow.value = false
} else {
alert('密码错误')
}
}
const handleSwithcSourceAipan = (url) => {
if (tvPassword.value) {
if (tvPassword.value === 'aipan.me') {
handleSwithcSource(url)
} else {
alert('请输入密码', '提示')
tvPasswordInputShow.value = true
}
} else {
alert('请输入密码', '提示')
tvPasswordInputShow.value = true
}
}
//
const handleSwitchVideoStatus = () => {
if (videoPlayer.value.paused) {
videoPlayer.value.play();
videoPlayStatus.value = true;
} else {
videoPlayer.value.pause();
videoPlayStatus.value = false;
if (player) {
if (videoPlayer.value.paused) {
videoPlayer.value.play();
videoPlayStatus.value = true;
} else {
videoPlayer.value.pause();
videoPlayStatus.value = false;
}
}
};
@ -130,6 +172,7 @@ const videoEffects = [
];
const handleSwitchVideoTheme = () => {
if (!player) return
videoPlayer.value.classList.remove(videoEffects[currentEffectIndex]);
currentEffectIndex = (currentEffectIndex + 1) % videoEffects.length;
videoPlayer.value.classList.add(videoEffects[currentEffectIndex]);
@ -144,25 +187,13 @@ const handleResetTheme = () => {
//
const handleFullscreen = () => {
if (!document.fullscreenElement) {
if (videoPlayer.value.requestFullscreen) {
videoPlayer.value.requestFullscreen();
} else if (videoPlayer.value.mozRequestFullScreen) {
videoPlayer.value.mozRequestFullScreen();
} else if (videoPlayer.value.webkitRequestFullscreen) {
videoPlayer.value.webkitRequestFullscreen();
} else if (videoPlayer.value.msRequestFullscreen) {
videoPlayer.value.msRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
if (player) {
if (player.isFullscreen()) {
player.exitFullscreen();
} else {
player.requestFullscreen();
}
}
};
@ -176,20 +207,197 @@ const handlePlaying = () => {
videoLoading.value = false;
};
const handleMute = () => {
if (videoPlayer.value.muted) {
videoPlayer.value.muted = false;
videoMuted.value = false
} else {
videoPlayer.value.muted = true;
videoMuted.value = true
if (player) {
videoMuted.value = !videoMuted.value;
player.muted(videoMuted.value);
}
}
const channelCategory = ref(1)
const channelCategoryData = [
{
id: 1,
name: "常用",
},
{
id: 2,
name: "电视直播",
},
{
id: 3,
name: "Alist",
}
]
const handleSwithcChannelCategory = (id) => {
tvStore.setTvCategory(id)
channelCategory.value = id
if (id === 3) {
alistSettingShow.value = false
currentIsDir.value = true
alistPath.value = [""]
tvStore.setAlistPath([""])
let params = {
page: 1,
password: '',
path: alistPath.value.join('/'),
per_page: 0,
refresh: false
}
getFsList(params)
}
}
const getFsList = async (params) => {
if (!alistUrl.value) return
let res = await $fetch(`${alistUrl.value}/api/fs/list`, {
method: 'POST',
body: params
})
// console.log(res)
if (res.code === 200) {
alistData.value = res.data
tvStore.setAlistData(res.data)
} else {
alistData.value.pop()
tvStore.setAlistData(alistData.value)
}
}
const getFsGet = async (params) => {
if (!alistUrl.value) return
let res = await $fetch(`${alistUrl.value}/api/fs/get`, {
method: 'POST',
body: params
})
if (res.code === 200) {
let sign = res.data.sign;
let alistPathTemp = []
alistPath.value.forEach((item, index) => {
alistPathTemp[index] = encodeURIComponent(item)
})
let temp_url = alistPathTemp.join('/') + '?sign=' + sign
videoSrc.value = `${alistUrl.value}/d${temp_url}`
loadHLS(`${alistUrl.value}/d${temp_url}`);
} else {
alistData.value.pop()
tvStore.setAlistData(alistData.value)
}
}
const handleClickAlist = (item, index) => {
if (item.is_dir) {
console.log('this is is dir')
//
if (currentIsDir.value) {
alistPath.value.push(item.name)
} else {
alistPath.value.pop()
alistPath.value.push(item.name)
}
currentIsDir.value = true
//
const currentPath = [...alistPath.value]; // 使 JSON.parse(JSON.stringify(alistPath.value))
tvStore.setAlistPath(currentPath)
tvStore.setAlistCurrentPlayIndex(0)
getFsList({
page: 1,
password: "",
path: alistPath.value.join('/'),
per_page: 0,
refresh: false
})
} else {
//
if (currentIsDir.value) {
alistPath.value.push(item.name)
} else {
alistPath.value.pop()
alistPath.value.push(item.name)
}
currentIsDir.value = false
tvStore.setAlistCurrentPlayIndex(index)
alistCurrentPlayIndex.value = index
getFsGet({
path: alistPath.value.join('/'),
password: "",
})
}
}
const handleBackAlist = () => {
alistPath.value.pop()
currentIsDir.value = true
// console.log(alistPath.value)
// tvStore.setAlistPath(alistPath.value)
getFsList({
page: 1,
password: "",
path: alistPath.value.join('/'),
per_page: 0,
refresh: false
})
}
const getAlists = async () => {
try {
let res = await $fetch("/api/alist/get", {
method: "GET",
})
// console.log(res)
alistSettingData.value = res.alists;
} catch (e) {
console.log(e)
}
}
const handleAlistSetting = async () => {
alistSettingShow.value = true
tvStore.setAlistSettingShow(true)
await getAlists()
}
const handleClickAlistUrl = (item) => {
alistUrl.value = item.link;
tvStore.setAlistUrl(item.link)
}
//
onMounted(() => {
//
getTvSources();
tvPassword.value = localStorage.getItem('tvPassword') || '';
videoPlayer.value.addEventListener('waiting', handleWaiting);
videoPlayer.value.addEventListener('playing', handlePlaying);
getAlists();
// store
if (tvStore.tvCategory) {
channelCategory.value = tvStore.tvCategory
}
if (tvStore.alistUrl) {
alistUrl.value = tvStore.alistUrl
}
if (tvStore.alistSettingShow) {
alistSettingShow.value = tvStore.alistSettingShow
}
if (tvStore.alistData) {
alistData.value = tvStore.alistData
}
if (tvStore.alistPath) {
alistPath.value = tvStore.alistPath
}
if (tvStore.alistCurrentPlayIndex) {
alistCurrentPlayIndex.value = tvStore.alistCurrentPlayIndex
}
if (channelCategory.value === 3) {
let params = {
page: 1,
password: '',
path: alistPath.value.join('/'),
per_page: 0,
refresh: false
}
getFsList(params)
}
});
onBeforeUnmount(() => {
@ -197,24 +405,24 @@ onBeforeUnmount(() => {
hls.destroy();
hls = null; //
}
if (videoPlayer.value) {
videoPlayer.value.removeEventListener('waiting', handleWaiting);
videoPlayer.value.removeEventListener('playing', handlePlaying);
videoPlayer.value = null;
if (player) {
player.dispose();
player = null;
}
});
</script>
<template>
<div class="dark:bg-slate-800 min-h-screen bg-no-repeat bg-cover bg-center relative"
<div class="custom-bg dark:bg-slate-800 min-h-screen bg-no-repeat bg-cover bg-center relative"
:style="{ 'background-image': `url(${bgImage})` }">
<div class="absolute top-0 left-0 right-0 bottom-0 bg-black/20 backdrop-blur-sm"></div>
<div class="fixed bottom-10 left-10 right-10 top-10 rounded-xl max-w-screen-lg mx-auto">
<div class="fixed bottom-10 left-10 right-10 top-10 rounded-xl max-w-screen-lg mx-auto flex items-center ">
<div class="w-full rounded-t-xl dark:bg-slate-700">
<div
<div id="aipan-video-container"
class="relative w-full h-full bg-black rounded-t-md overflow-hidden px-6 pt-6 flex items-center justify-center aspect-video">
<video ref="videoPlayer" id="video"
class="w-full h-full relative shadow-md border border-gray-900 rounded-md"></video>
<video ref="videoPlayer" id="aipan-video"
class="video-js w-full h-full relative shadow-md border border-gray-900 rounded-md"></video>
<div v-if="!videoPlayStatus"
class="absolute top-6 left-6 right-6 bottom-0 video-mask shadow-xl rounded-md flex items-center justify-center">
<button class="bg-red-500 text-white px-2 py-1 rounded-md text-xs hover:text-md"
@ -266,52 +474,97 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-if="modalShow"
class="fixed bottom-0 left-0 right-0 w-full h-full bg-black/50 flex flex-col items-center justify-center">
<div class="bg-white p-10 rounded-xl dark:bg-black dark:text-white">
<div class="flex flex-row items-center justify-center gap-2">
<input class="border border-gray-300 px-4 py-2 rounded-md w-2/3" type="text" v-model="videoSrc"
placeholder="请输入视频链接">
<button class="bg-red-500 text-white px-2 py-2 rounded-md text-xs hover:text-md" type="button"
@click="handleSwithcSource(videoSrc)">切换视频</button>
</div>
<div class="flex flex-row flex-wrap items-center justify-center max-w-screen-sm mx-auto gap-4 mt-5">
<div class="text-sm font-semibold border border-gray-300 text-slate-600 dark:text-white dark:bg-slate-700 rounded-full p-2 cursor-pointer hover:bg-black hover:text-white transition duration-300"
:class="{ 'bg-black text-white': item.url === videoSrc }" v-for=" (item, index) in tvSources"
:key="index" @click="handleSwithcSource(item.url)">
{{ item.name }}
class="fixed bottom-0 top-0 left-0 p-5 w-full md:w-[520px] h-full bg-black/80 overflow-y-scroll">
<div class="flex flex-row items-center justify-center gap-2">
<input class="border border-gray-300 px-4 py-2 rounded-md w-2/3" type="text" v-model="videoSrc"
placeholder="请输入视频链接">
<button class="bg-red-500 text-white px-2 py-2 rounded-md text-xs hover:text-md" type="button"
@click="handleSwithcSource(videoSrc)">切换视频</button>
</div>
<div class="mt-5 flex flex-row gap-2">
<div class="w-10 space-y-2">
<div>
<button class="bg-red-500 text-white px-2 py-1 rounded-md text-xs hover:text-md" type="button"
@click="modalShow = false">关闭</button>
</div>
</div>
<ul class="space-y-2">
<li class="text-sm font-semibold text-gray-600 border border-gray-700 p-2 rounded-md cursor-pointer hover:bg-gray-700 hover:text-white transition duration-300"
:class="{ 'bg-gray-700 text-white': channelCategory === category.id }"
style="writing-mode: vertical-rl;" v-for="(category, index) in channelCategoryData"
:key="index" @click="handleSwithcChannelCategory(category.id)">
<div v-if="tvPasswordInputShow" class="flex flex-row items-center justify-center gap-2 mt-10">
<input class="border border-gray-300 px-4 py-2 rounded-md w-2/3" type="text" v-model="tvPassword"
placeholder="请输入密码">
<button class="bg-red-500 text-white px-2 py-2 rounded-md text-xs hover:text-md" type="button"
@click="handleInputPassword">输入密码</button>
{{ category.name }}
</li>
</ul>
</div>
<div class="flex flex-row flex-wrap items-center justify-center max-w-screen-lg mx-auto gap-4 mt-4">
<div class="text-sm font-semibold border border-gray-300 text-slate-600 dark:text-white dark:bg-slate-700 rounded-full p-2 cursor-pointer hover:bg-black hover:text-white transition duration-300"
:class="{ 'bg-black text-white': sourceIndex === index }" v-for=" (item, index) in sourcesAipan"
:key="index" @click="sourceIndex = index">
{{ item.label }}
<div class="w-full">
<div v-if="channelCategory === 1" class="space-y-2">
<div class="w-full text-sm font-semibold border border-gray-800 text-slate-600 dark:text-white dark:bg-slate-700 rounded-full p-2 cursor-pointer hover:bg-black hover:text-white transition duration-300"
:class="{ 'bg-black text-white': item.url === videoSrc }"
v-for=" (item, index) in tvSources" :key="index" @click="handleSwithcSource(item.url)">
{{ item.name }}
</div>
</div>
</div>
<div class="flex flex-row flex-wrap items-center justify-center max-w-screen-sm mx-auto gap-4 mt-5">
<div class="text-sm font-semibold border border-gray-300 text-slate-600 dark:text-white dark:bg-slate-700 rounded-full p-2 cursor-pointer hover:bg-black hover:text-white transition duration-300"
:class="{ 'bg-black text-white': item.url === videoSrc }"
v-for=" (item, index) in sourcesAipan[sourceIndex]['sources']" :key="index"
@click="handleSwithcSourceAipan(item.url)">
{{ item.name }}
<div v-if="channelCategory === 2">
<div class="flex flex-row flex-wrap items-center justify-center max-w-screen-lg mx-auto gap-4">
<div class="text-sm font-semibold border border-gray-700 text-slate-600 dark:text-white dark:bg-slate-700 rounded-md p-2 cursor-pointer hover:bg-black hover:text-white transition duration-300"
:class="{ 'bg-black text-white': sourceIndex === index }"
v-for=" (item, index) in sourcesAipan" :key="index" @click="sourceIndex = index">
{{ item.label }}
</div>
</div>
<div class=" space-y-2 mt-5">
<div class="text-sm font-semibold border border-gray-700 text-slate-600 dark:text-white dark:bg-slate-700 rounded-full p-2 cursor-pointer hover:bg-black hover:text-white transition duration-300"
:class="{ 'bg-black text-white': item.url === videoSrc }"
v-for=" (item, index) in sourcesAipan[sourceIndex]['sources']" :key="index"
@click="handleSwithcSource(item.url)">
{{ item.name }}
</div>
</div>
</div>
<div class="space-y-2" v-if="channelCategory === 3">
<div class="space-x-2">
<button class="border-gray-800 text-white px-2 py-1 rounded-md text-xs hover:text-md"
type="button" @click="handleBackAlist">
返回上级
</button>
<button class="border border-gray-800 text-white px-2 py-1 rounded-md text-xs hover:text-md"
type="button" @click="() => {
handleSwithcChannelCategory(3)
alistSettingShow = false
tvStore.setAlistSettingShow(false)
}">
主页
</button>
<button class="border border-gray-800 text-white px-2 py-1 rounded-md text-xs hover:text-md"
:class="alistSettingShow ? 'bg-red-500' : ''" type="button" @click="handleAlistSetting">
设置
</button>
</div>
<div class="space-y-2" v-if="alistSettingShow">
<div class="text-gray-600 p-2 text-xs border border-gray-700 rounded-md cursor-pointer transition duration-300 break-words"
:class="alistUrl === item.link ? 'bg-gray-400 text-gray-900 ' : 'hover:bg-gray-200'"
v-for=" (item, index) in alistSettingData" :key="index"
@click="handleClickAlistUrl(item)">
{{ item.name }} {{ item.link }}
</div>
</div>
<div class="space-y-2" v-else>
<div class="text-gray-600 p-2 text-xs border border-gray-700 rounded-md cursor-pointer transition duration-300 break-words"
:class="item.is_dir ? 'bg-yellow-400 text-gray-900 hover:bg-yellow-600' : alistCurrentPlayIndex === index ? 'bg-gray-400 text-gray-900 ' : 'hover:bg-gray-200'"
v-for=" (item, index) in alistData?.content" :key="index"
@click="handleClickAlist(item, index)">
{{ item.name }} - {{ item.is_dir ? '文件夹' : '文件' }}
</div>
</div>
</div>
</div>
<div class="flex flex-row items-center justify-center gap-2 mt-5">
<button class="bg-red-500 text-white px-2 py-1 rounded-md text-xs hover:text-md" type="button"
@click="modalShow = false">关闭</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.video-mask {

View File

@ -9,8 +9,12 @@ useHead({
{ name: 'format-detection', content: 'telephone=no' }
]
})
const getData = async () => {
const res = await $fetch('/api/tvbox')
tvbox.value = res.list || [];
}
onMounted(() => {
getData()
})
const { data: tvbox } = await useAsyncData('tvbox', async () => {
const res = await $fetch('/api/tvbox')

View File

@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "Alist" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"link" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"creatorId" INTEGER NOT NULL,
CONSTRAINT "Alist_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Alist" ADD CONSTRAINT "Alist_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -28,6 +28,7 @@ model User {
resources Resource[] // 一个用户可以有多个资源
resourceTypes ResourceType[] // 一个用户可以创建多种资源类型
Post Post[]
Alist Alist[]
}
model ResourceType {
@ -84,3 +85,14 @@ model PostToCategory {
@@id([postId, categoryId]) // 组合主键,确保唯一性
}
model Alist {
id Int @id @default(autoincrement())
name String
link String // alist 源链接
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creatorId Int
creator User @relation(fields: [creatorId], references: [id])
}

View File

@ -0,0 +1,16 @@
import prisma from "~/lib/prisma";
export default defineEventHandler(async (event) => {
const { id } = getRouterParams(event)
const userId = event.context.user.userId
const alist = await prisma.alist.delete({
where: {
id: Number(id),
}
})
return {
code: 200,
msg: 'success',
data: []
}
})

View File

@ -0,0 +1,14 @@
import prisma from "~/lib/prisma";
export default defineEventHandler(async (event) => {
const { id } = getRouterParams(event)
const alist = await prisma.alist.findUnique({
where: {
id: Number(id),
}
})
return {
code: 200,
msg: 'success',
data: alist
}
})

View File

@ -0,0 +1,31 @@
import prisma from "~/lib/prisma";
export default defineEventHandler(async (event) => {
const { id } = getRouterParams(event);
const userId = event.context.user.userId;
const { name, link } = await readBody(event);
const alist = await prisma.alist.findUnique({
where: {
id: Number(id),
}
})
if (!alist) {
throw createError({ statusCode: 404, statusMessage: 'Alist not found' });
}
if (alist.creatorId !== userId) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' }); // 仅允许资源的创建者更新资源
}
const updatedAlist = await prisma.alist.update({
where: { id: Number(id) },
data: {
name,
link
},
});
return {
code: 200,
msg: 'success',
data: updatedAlist
};
})

View File

@ -0,0 +1,31 @@
import prisma from '~/lib/prisma';
export default defineEventHandler(async (event) => {
const query = await getQuery(event);
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const skip = (page - 1) * pageSize;
const take = pageSize;
// 获取总记录数
const totalCount = await prisma.alist.count();
const alists = await prisma.alist.findMany({
skip,
take,
include: {
creator: {
select: { username: true }, // 包含创建者的用户名
},
},
orderBy: {
createdAt: 'desc',
},
});
return {
totalCount,
page,
pageSize,
alists,
};
});

View File

@ -0,0 +1,21 @@
import prisma from "~/lib/prisma";
export default defineEventHandler(async (event) => {
const { name, link } = await readBody(event)
const userId = event.context.user.userId;
const alist = await prisma.alist.create({
data: {
name,
link,
creatorId: userId
}
})
return {
code: 200,
msg: 'success',
data: alist
}
})

31
server/api/alist/get.ts Normal file
View File

@ -0,0 +1,31 @@
import prisma from '~/lib/prisma';
export default defineEventHandler(async (event) => {
const query = await getQuery(event);
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const skip = (page - 1) * pageSize;
const take = pageSize;
// 获取总记录数
const totalCount = await prisma.alist.count();
const alists = await prisma.alist.findMany({
skip,
take,
include: {
creator: {
select: { username: true }, // 包含创建者的用户名
},
},
orderBy: {
createdAt: 'desc',
},
});
return {
totalCount,
page,
pageSize,
alists,
};
});

40
stores/tv.ts Normal file
View File

@ -0,0 +1,40 @@
import { defineStore } from "pinia";
export const useTvStore = defineStore('tv', {
state() {
return {
tvCategory: 1,
alistUrl: '',
alistData: {},
alistPath: [],
alistSettingShow: false,
alistPlayingShow: false,
alistCurrentPlayIndex: 0
}
},
actions: {
setTvCategory(id: number) {
this.tvCategory = id
},
setAlistUrl(url: string) {
this.alistUrl = url
},
setAlistSettingShow(show: boolean) {
this.alistSettingShow = show
},
setAlistPlayingShow(show: boolean) {
this.alistPlayingShow = show
},
setAlistPath(path: []) {
this.alistPath = path
},
setAlistData(data: {}) {
this.alistData = data
},
setAlistCurrentPlayIndex(index: number) {
this.alistCurrentPlayIndex = index
}
},
persist: {
storage: persistedState.localStorage
},
})