release(project): publish project

This commit is contained in:
ApplePine 2025-06-21 03:29:14 +08:00
commit ac70cca4a2
19 changed files with 1554 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# Local
.DS_Store
*.local
*.log*
# Dist
node_modules
dist/
# Profile
.rspack-profile-*/
# IDE
.vscode/*
.vscode/
!.vscode/extensions.json
.idea
bun.lock

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
# Lock files
package-lock.json
pnpm-lock.yaml
yarn.lock

60
.prettierrc.json Normal file
View File

@ -0,0 +1,60 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": true,
"semi": false,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"proseWrap": "always",
"htmlWhitespaceSensitivity": "css",
"bracketSameLine": false,
"vueIndentScriptAndStyle": false,
"embeddedLanguageFormatting": "auto",
"jsxSingleQuote": true,
"overrides": [
{
"files": "*.{json,json5}",
"options": {
"printWidth": 100,
"singleQuote": false,
"trailingComma": "none"
}
},
{
"files": "*.{yaml,yml}",
"options": {
"singleQuote": false,
"tabWidth": 2
}
},
{
"files": "*.html",
"options": {
"printWidth": 120,
"htmlWhitespaceSensitivity": "ignore"
}
},
{
"files": "*.md",
"options": {
"proseWrap": "always",
"printWidth": 100
}
},
{
"files": "*.sql",
"options": {
"printWidth": 100,
"keywordCase": "upper",
"identifierCase": "lower",
"linesBetweenQueries": 1,
"useTabs": true,
"tabWidth": 4
}
}
]
}

12
README.md Normal file
View File

@ -0,0 +1,12 @@
## 免责声明
本项目仅供学习和技术研究使用,不得用于商业目的。使用本工具时,请遵守以下规定:
1. 本工具仅提供技术演示,不提供任何视频内容本身,不提供任何视频接口
2. 使用者应当遵守当地法律法规,不得利用本工具从事任何违法活动
3. 本工具不存储、复制或分发任何受版权保护的内容
4. 对于使用本工具可能引发的法律问题,使用者须自行承担全部责任
5. 开发者保留对本项目进行修改、更新或终止的权利,且不承担任何责任
6. 使用本工具即表示您已阅读并同意本免责声明的全部内容
请尊重知识产权,支持正版内容。若权利人认为本项目侵犯了您的权益,请联系开发者进行处理。

15
eslint.config.mjs Normal file
View File

@ -0,0 +1,15 @@
import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals';
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{ languageOptions: { globals: globals.browser } },
js.configs.recommended,
...pluginVue.configs['flat/essential'],
]);

12
free-video-backend/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# deps
node_modules/
# ide
.vscode/
.idea/
.zed/
# lock file
bun.lock
package-lock.json

View File

@ -0,0 +1,60 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": true,
"semi": false,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"proseWrap": "always",
"htmlWhitespaceSensitivity": "css",
"bracketSameLine": false,
"vueIndentScriptAndStyle": false,
"embeddedLanguageFormatting": "auto",
"jsxSingleQuote": true,
"overrides": [
{
"files": "*.{json,json5}",
"options": {
"printWidth": 100,
"singleQuote": false,
"trailingComma": "none"
}
},
{
"files": "*.{yaml,yml}",
"options": {
"singleQuote": false,
"tabWidth": 2
}
},
{
"files": "*.html",
"options": {
"printWidth": 120,
"htmlWhitespaceSensitivity": "ignore"
}
},
{
"files": "*.md",
"options": {
"proseWrap": "always",
"printWidth": 100
}
},
{
"files": "*.sql",
"options": {
"printWidth": 100,
"keywordCase": "upper",
"identifierCase": "lower",
"linesBetweenQueries": 1,
"useTabs": true,
"tabWidth": 4
}
}
]
}

View File

@ -0,0 +1,15 @@
{
"name": "free_backend",
"scripts": {
"dev": "bun run --hot src/index.ts"
},
"dependencies": {
"axios": "^1.10.0",
"hono": "^4.8.1"
},
"devDependencies": {
"@types/bun": "latest",
"eslint": "^9.29.0",
"prettier": "^3.5.3"
}
}

View File

@ -0,0 +1,72 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import axios from 'axios'
const app = new Hono()
app.use('*', cors())
app.get('/api/searchvideo', async (c) => {
const url_prefix = 'https://api.so.360kan.com/index?force_v=1&kw='
const keyword = c.req.query('keyword') || ''
const url = url_prefix + keyword
try {
const response = await axios.get(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36',
},
})
const rows = response.data.data.longData.rows || []
if (rows.length === 0) {
return c.json({ error: 'No results found' }, 404)
}
const promises = rows.map(async (item: any) => {
let videoLinks = []
if (!item.seriesPlaylinks || item.seriesPlaylinks.length === 0) {
const videoResponse = await axios.get(
`https://api.so.360kan.com/episodesv2?v_ap=1&s=[{"cat_id":"${item.cat_id}","ent_id":"${item.en_id}","site":"${item.vipSite[0]}"}]`,
)
if (videoResponse.data.data.length === 0) {
return
}
videoLinks = videoResponse.data.data[0].seriesHTML.seriesPlaylinks.map((video: any) => {
return video.url.split('?')[0]
})
} else {
videoLinks = item.seriesPlaylinks.map((video: any) => {
if (typeof video.url === 'string') {
return video.url.split('?')[0]
}
return video
})
}
const videoItem = {
video_id: item.id,
video_id_en: item.en_id,
cat_id: item.cat_id,
cat_name: item.cat_name,
cover: item.cover,
title: item.titleTxt,
year: item.year,
description: item.description,
areas: item.area,
tags: item.tag,
actList: item.actList,
dirList: item.dirList,
isVip: item.vip,
vipSites: item.vipSite,
videoLinks: videoLinks,
episodeCount: videoLinks.length,
}
return videoItem
})
const res = (await Promise.all(promises)).filter(Boolean)
return c.json(res)
} catch (e) {
console.error(e)
return c.json({ error: 'Failed to fetch data' }, 500)
}
})
export default app

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2020",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": "."
}
}

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "free-video-app",
"version": "1.0.0",
"description": "视频解析工具,支持多种视频网站",
"main": "electron/main.js",
"author": "Jianlong Deng",
"private": true,
"type": "module",
"scripts": {
"build": "rsbuild build",
"dev": "rsbuild dev --open",
"format": "prettier --write .",
"lint": "eslint .",
"preview": "rsbuild preview"
},
"dependencies": {
"axios": "^1.10.0",
"element-plus": "^2.10.2",
"pinia": "^3.0.3",
"vue": "^3.5.16",
"vue-router": "4"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@rsbuild/core": "^1.3.22",
"@rsbuild/plugin-vue": "^1.0.7",
"eslint": "^9.25.1",
"eslint-plugin-vue": "^10.1.0",
"globals": "^16.0.0",
"prettier": "^3.5.3"
}
}

17
rsbuild.config.mjs Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from '@rsbuild/core'
import { pluginVue } from '@rsbuild/plugin-vue'
export default defineConfig({
plugins: [pluginVue()],
server: {
port: 8080,
open: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false,
},
},
},
})

7
src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup></script>
<style></style>

16
src/index.js Normal file
View File

@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/global.css'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
import router from './router/index.js'
app.use(ElementPlus, { zIndex: 1000 })
app.use(router)
app.mount('#root')

36
src/router/index.js Normal file
View File

@ -0,0 +1,36 @@
import { createRouter, createWebHistory } from 'vue-router'
import VideoSearch from '../views/VideoSearch.vue'
import VideoPlayer from '../views/VideoPlayer.vue'
const routes = [
{
path: '/',
name: 'Home',
component: VideoSearch,
meta: {
title: '免费视频解析 - 支持全网视频解析免费观看VIP视频',
},
},
{
path: '/video_player',
name: 'VideoPlayer',
component: VideoPlayer,
meta: {
title: '免费视频解析 - 视频播放器',
},
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 全局前置守卫
router.beforeEach((to, _from, next) => {
const title = to.meta.title || '免费视频解析'
document.title = title
next()
})
export default router

82
src/store/index.js Normal file
View File

@ -0,0 +1,82 @@
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
state: () => {
return {
prefix_urls: [''],
search_results: [],
current_video: {
title: '', // 视频标题
episodeIdx: 0, // 当前集数索引
episodeUrl: '', // 当前播放地址
totalEpisodes: 0, // 总集数
coverUrl: '', // 封面
description: '', // 描述
videoLinks: [], // 所有剧集的链接
},
supportedPlatforms: ['优酷', '爱奇艺', '腾讯视频'],
supportedFormats: ['MP4', 'M3U8', 'FLV', '...'],
apiConfig: {
timeout: 10000, // 请求超时时间(毫秒)
retryTimes: 3, // 重试次数
},
appConfig: {
title: '免费视频解析',
description: '支持全网视频解析免费观看VIP视频',
version: '1.0.0',
site_begin_time: new Date('2025-06-20 18:00:00'),
},
}
},
actions: {
// 更新搜索结果
updateSearchResults(results) {
this.search_results = results
},
// 更新当前视频信息 - 综合方法,处理不同情况
updateCurrentVideo(videoData, episodeIdx = 0, episodeUrl = '') {
// 处理直接传入URL字符串的情况
if (typeof videoData === 'string') {
this.current_video = {
title: '外部链接',
episodeIdx: 0,
episodeUrl: videoData,
totalEpisodes: 1,
coverUrl: '',
description: '',
videoLinks: [videoData],
}
return
}
// 处理传入视频对象的情况
const videoLinks = videoData.videoLinks || []
this.current_video = {
title: videoData.title || '未知标题',
episodeIdx: episodeIdx,
episodeUrl: episodeUrl || videoLinks[episodeIdx] || '',
totalEpisodes: videoLinks.length || 0,
coverUrl: videoData.cover || '',
description: videoData.description || '',
videoLinks: videoLinks,
}
},
// 仅更新当前播放URL
updateEpisodeUrl(url) {
if (!url) return
this.current_video.episodeUrl = url
},
// 更新当前播放集数
updateEpisodeIdx(idx) {
if (idx < 0 || !this.current_video.videoLinks || idx >= this.current_video.videoLinks.length)
return
this.current_video.episodeIdx = idx
this.current_video.episodeUrl = this.current_video.videoLinks[idx]
},
},
})

20
src/styles/global.css Normal file
View File

@ -0,0 +1,20 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
overflow-x: hidden;
height: 100%;
}
#root {
width: 100%;
height: 100vh;
}

426
src/views/VideoPlayer.vue Normal file
View File

@ -0,0 +1,426 @@
<template>
<div class="video-player">
<!-- 视频播放区域 -->
<div class="player-container">
<iframe
v-if="currentPlayUrl"
:src="currentPlayUrl"
class="video-frame"
frameborder="0"
allowfullscreen
@load="onFrameLoad"
@error="onFrameError"
></iframe>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-container">
<el-icon class="loading-icon is-loading" size="48">
<Loading />
</el-icon>
<p>正在加载视频...</p>
</div>
<!-- 错误状态 -->
<div v-if="hasError && !isLoading" class="error-container">
<el-icon size="48"><VideoCamera /></el-icon>
<p>视频加载失败请尝试切换解析接口</p>
<el-button type="primary" @click="showDrawer = true"> 切换接口 </el-button>
</div>
</div>
<!-- 返回首页按钮 -->
<el-button class="back-btn" type="primary" :icon="ArrowLeft" @click="goBack">
返回首页
</el-button>
<!-- 悬浮切换接口按钮 -->
<el-button class="switch-btn" type="success" :icon="Setting" circle @click="showDrawer = true">
</el-button>
<!-- 切换接口抽屉 -->
<el-drawer v-model="showDrawer" title="选项" direction="rtl" size="400px">
<div class="drawer-content">
<div class="current-info">
<h4>当前视频地址</h4>
<p class="url-text">{{ videoUrl }}</p>
</div>
<div class="interface-list">
<h4>可用解析接口</h4>
<div
v-for="(prefix, index) in prefixes"
:key="prefix"
class="interface-item"
:class="{ active: currentPrefixIndex === index }"
@click="switchInterface(index)"
>
<div class="interface-info">
<span class="interface-name">接口 {{ index + 1 }}</span>
<!-- <span class="interface-url">{{ prefix }}</span> -->
</div>
<el-icon v-if="currentPrefixIndex === index" class="active-icon">
<Check />
</el-icon>
</div>
</div>
<!-- 集数选择如果有多集 -->
<div v-if="hasMultipleEpisodes" class="episodes-selector">
<h4>选集:</h4>
<div class="episodes-grid">
<el-button
v-for="index in totalEpisodes"
:key="index - 1"
size="small"
:type="currentEpisodeIdx === index - 1 ? 'primary' : ''"
@click="switchEpisode(index - 1)"
class="episode-btn"
>
{{ index }}
</el-button>
</div>
</div>
<div class="drawer-actions">
<el-button @click="showDrawer = false" type="danger"> 关闭 </el-button>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Setting, VideoCamera, Check, Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useStore } from '../store/index'
const store = useStore()
const route = useRoute()
const router = useRouter()
//
const videoUrl = ref('')
const prefixes = ref([])
const currentPrefixIndex = ref(0)
const showDrawer = ref(false)
const isLoading = ref(true)
const hasError = ref(false)
//
const videoTitle = computed(() => {
if (store.current_video && store.current_video.title) {
if (hasMultipleEpisodes.value) {
return `${store.current_video.title} - 第 ${store.current_video.episodeIdx + 1}`
}
return store.current_video.title
}
return ''
})
const currentEpisodeIdx = computed(() => store.current_video.episodeIdx || 0)
const totalEpisodes = computed(() => store.current_video.totalEpisodes || 0)
const hasMultipleEpisodes = computed(() => totalEpisodes.value > 1)
// URL
const currentPlayUrl = computed(() => {
if (!videoUrl.value || !prefixes.value.length) return ''
const prefix = prefixes.value[currentPrefixIndex.value]
return prefix + encodeURIComponent(videoUrl.value)
})
//
const updatePageTitle = () => {
let title = '视频播放器'
if (videoTitle.value) {
title = `${videoTitle.value} - 免费视频解析`
}
document.title = title
}
//
watch(videoTitle, () => {
updatePageTitle()
})
// store current_video_url
watch(
() => store.current_video.episodeUrl,
(newValue) => {
if (newValue) {
videoUrl.value = newValue
loadCurrentInterface()
updatePageTitle()
}
},
)
onMounted(() => {
//
prefixes.value = store.prefix_urls || []
// URL
if (store.current_video && store.current_video.episodeUrl) {
videoUrl.value = store.current_video.episodeUrl
} else {
// store
videoUrl.value = route.query.url || ''
// URL
if (videoUrl.value && (!store.current_video || !store.current_video.episodeUrl)) {
store.updateEpisodeUrl(videoUrl.value)
}
}
if (!videoUrl.value) {
ElMessage.error('视频地址错误')
router.push('/')
return
}
if (prefixes.value.length === 0) {
ElMessage.error('没有可用的解析接口')
router.push('/')
return
}
//
updatePageTitle()
//
loadCurrentInterface()
})
//
watch(currentPrefixIndex, () => {
loadCurrentInterface()
})
//
const loadCurrentInterface = () => {
isLoading.value = true
hasError.value = false
}
// iframe
const onFrameLoad = () => {
isLoading.value = false
hasError.value = false
ElMessage.success(`接口 ${currentPrefixIndex.value + 1} 加载成功`)
}
// iframe
const onFrameError = () => {
isLoading.value = false
hasError.value = true
ElMessage.error(`接口 ${currentPrefixIndex.value + 1} 加载失败`)
}
//
const switchInterface = (index) => {
if (index === currentPrefixIndex.value) return
currentPrefixIndex.value = index
showDrawer.value = false
ElMessage.info(`正在切换到接口 ${index + 1}`)
}
//
const switchEpisode = (index) => {
if (index === currentEpisodeIdx.value) return
store.updateEpisodeIdx(index)
ElMessage.success(`正在切换到第${index + 1}`)
showDrawer.value = false
}
//
const goBack = () => {
router.push('/')
}
</script>
<style scoped>
.video-player {
position: relative;
width: 100%;
height: 100vh;
background: #000;
}
.player-container {
width: 100%;
height: 100%;
position: relative;
}
.video-frame {
width: 100%;
height: 100%;
border: none;
}
.loading-container,
.error-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: white;
}
.loading-container p,
.error-container p {
margin-top: 20px;
font-size: 16px;
}
.loading-icon {
animation: rotating 2s linear infinite;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.back-btn {
position: fixed;
top: 20px;
left: 20px;
z-index: 1000;
}
.switch-btn {
position: fixed;
bottom: 60px;
right: 30px;
z-index: 1000;
width: 45px;
height: 45px;
font-size: 24px;
}
.drawer-content {
padding: 20px;
}
.current-info {
margin-bottom: 30px;
padding: 15px;
background: #f5f5f5;
border-radius: 8px;
}
.current-info h4 {
margin: 0 0 10px 0;
color: #333;
}
.url-text {
margin: 0;
word-break: break-all;
color: #666;
font-size: 14px;
}
.interface-list h4 {
margin: 0 0 15px 0;
color: #333;
}
.interface-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
margin-bottom: 10px;
border: 1px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.interface-item:hover {
border-color: #409eff;
background: #f0f9ff;
}
.interface-item.active {
border-color: #67c23a;
background: #f0f9f0;
}
.interface-info {
display: flex;
flex-direction: column;
flex: 1;
}
.interface-name {
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.interface-url {
font-size: 12px;
color: #999;
word-break: break-all;
}
.active-icon {
color: #67c23a;
font-size: 18px;
}
.drawer-actions {
margin-top: 30px;
display: flex;
gap: 10px;
}
@media (max-width: 768px) {
.back-btn {
top: 10px;
left: 10px;
}
.switch-btn {
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
font-size: 20px;
}
}
.episodes-selector {
margin-top: 30px;
border-top: 1px solid #e0e0e0;
padding-top: 20px;
}
.episodes-selector h4 {
margin: 0 0 15px 0;
color: #333;
}
.episodes-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.episodes-grid :deep(.el-button) {
flex: 0 0 48px;
width: 48px;
height: 32px;
padding: 0;
margin: 0;
}
</style>

639
src/views/VideoSearch.vue Normal file
View File

@ -0,0 +1,639 @@
<template>
<div class="video-search">
<div class="main-content">
<!-- 时间显示 -->
<div class="time-display">
<div class="current-time">{{ currentTime }}</div>
<div class="current-date">{{ currentDate }}</div>
</div>
<!-- 搜索区域 -->
<div class="search-section">
<el-input
v-model="videoUrl"
placeholder="请输入视频地址或者视频标题"
class="search-input"
size="large"
clearable
:loading="isSearching"
@keyup.enter="handleSearch"
>
<template #append>
<el-button
type="primary"
@click="handleSearch"
:icon="Search"
:loading="isSearching"
style="color: white"
>
{{ isSearching ? '搜索中' : '搜索' }}
</el-button>
</template>
</el-input>
</div>
<!-- 搜索结果区域 -->
<div v-if="searchResults.length > 0" class="search-results">
<h3 class="results-title">搜索结果 ({{ searchResults.length }})</h3>
<el-card
v-for="item in searchResults"
:key="item.video_id"
class="video-card"
shadow="hover"
>
<div class="card-content">
<!-- 左侧信息 -->
<div class="video-info">
<div class="video-poster">
<img :src="item.cover" :alt="item.title" />
<div v-if="item.isVip" class="vip-badge">VIP</div>
</div>
<div class="video-details">
<h4 class="video-title">{{ item.title }}</h4>
<div class="video-meta">
<span class="year">{{ item.year }}</span>
<span class="category">{{ item.cat_name }}</span>
<span class="area" v-if="item.areas && item.areas.length">{{
item.areas.join('/')
}}</span>
</div>
<div class="video-cast" v-if="item.actList && item.actList.length">
<span class="cast-label">演员</span>
{{ item.actList.slice(0, 3).join('、')
}}{{ item.actList.length > 3 ? '...' : '' }}
</div>
<div class="video-desc">{{ truncateText(item.description, 80) }}</div>
<div class="video-tags">
<el-tag v-for="tag in item.tags.slice(0, 4)" :key="tag" size="small">{{
tag
}}</el-tag>
</div>
</div>
</div>
<!-- 右侧集数 -->
<div class="episodes-container">
<h5 class="episodes-title">{{ item.episodeCount }}</h5>
<div class="episodes-grid">
<el-button
v-for="(link, index) in item.videoLinks.slice(0, 24)"
:key="index"
size="small"
@click="playEpisode(item, link, index)"
:type="index === 0 ? 'primary' : ''"
>
{{ index + 1 }}
</el-button>
<el-button
v-if="item.videoLinks.length > 24"
size="small"
@click="showAllEpisodes(item)"
>
更多...
</el-button>
</div>
</div>
</div>
</el-card>
</div>
<!-- 无搜索结果时的提示 -->
<el-empty
v-if="hasSearched && searchResults.length === 0"
description="没有找到相关视频"
></el-empty>
<!-- 解析支持 -->
<el-card v-if="!searchResults.length" class="support-card" shadow="hover">
<div class="support-text">
<el-icon><VideoPlay /></el-icon>
解析支持{{ supportedPlatforms }}{{ supportedFormats }}
</div>
</el-card>
<!-- 官方网站 -->
<div v-if="!searchResults.length" class="links">
<span class="link-text">官方网站</span>
<el-link type="primary" href="https://www.iqiyi.com" target="_blank">爱奇艺</el-link>
<el-link type="primary" href="https://v.qq.com" target="_blank">腾讯视频</el-link>
<el-link type="primary" href="https://www.youku.com" target="_blank">优酷</el-link>
</div>
<!-- 运行时间统计 -->
<el-card v-if="!searchResults.length" class="runtime-card" shadow="never">
<div class="runtime-text">
<el-icon><Timer /></el-icon>
已运行 {{ runtimeStats }}
</div>
</el-card>
</div>
<!-- 全部剧集对话框 -->
<el-dialog
v-model="episodesDialogVisible"
:title="`${currentVideo?.title || ''} - 全部剧集`"
width="80%"
>
<div class="all-episodes">
<el-button
v-for="(link, index) in currentVideo?.videoLinks"
:key="index"
size="default"
@click="playEpisode(currentVideo, link, index)"
>
{{ index + 1 }}
</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search, VideoPlay, Timer } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useStore } from '../store/index'
import axios from 'axios'
const store = useStore()
const router = useRouter()
const currentTime = ref('')
const currentDate = ref('')
const videoUrl = ref('')
const runtimeStats = ref('')
const isSearching = ref(false)
const hasSearched = ref(false)
const searchResults = ref([])
const episodesDialogVisible = ref(false)
const currentVideo = ref(null)
const supportedPlatforms = store.supportedPlatforms.join('、')
const supportedFormats = store.supportedFormats.join('、')
//
const formatTime = (date) => {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}
//
const formatDate = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const weekday = weekdays[date.getDay()]
return `${year}-${month}-${day} 星期${weekday}`
}
//
const calculateRuntime = () => {
const beginDate = store.appConfig.site_begin_time
const now = Date.now()
const diffTime = Math.abs(now - beginDate)
//
const days = Math.floor(diffTime / (24 * 60 * 60 * 1000))
const years = Math.floor(days / 365)
const remainingDays = days % 365
const hours = Math.floor((diffTime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000))
const minutes = Math.floor((diffTime % (60 * 60 * 1000)) / (60 * 1000))
const seconds = Math.floor((diffTime % (60 * 1000)) / 1000)
return `${years}${remainingDays}${hours} 小时 ${minutes} 分钟 ${seconds}`
}
//
const updateTime = () => {
const now = new Date()
currentTime.value = formatTime(now)
currentDate.value = formatDate(now)
runtimeStats.value = calculateRuntime()
}
//
const truncateText = (text, maxLength) => {
if (!text) return ''
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
}
//
const handleSearch = async () => {
if (!videoUrl.value.trim()) {
ElMessage({
message: '请输入视频链接或搜索关键词',
type: 'warning',
})
return
}
// URL
const urlPattern = /^https?:\/\/.+/
if (urlPattern.test(videoUrl.value)) {
store.updateCurrentVideo(videoUrl.value)
router.push('/video_player')
} else {
//
try {
isSearching.value = true
hasSearched.value = true
// API
const response = await axios.get(
`/api/searchvideo?keyword=${encodeURIComponent(videoUrl.value)}`,
)
searchResults.value = response.data || []
// store
store.updateSearchResults(searchResults.value)
if (searchResults.value.length === 0) {
ElMessage.info('未找到相关视频')
} else {
ElMessage.success(`找到 ${searchResults.value.length} 个相关视频`)
}
} catch (error) {
console.error('搜索失败:', error)
ElMessage.error('搜索失败,请稍后重试')
searchResults.value = []
} finally {
isSearching.value = false
}
}
}
//
const playEpisode = (video, videoLink, episodeIndex) => {
if (!video || !videoLink) return
// store
const videoData = {
title: video.title,
cover: video.cover,
description: video.description,
videoLinks: video.videoLinks,
}
store.updateCurrentVideo(videoData, episodeIndex, videoLink)
//
ElMessage.success(`正在为您解析: ${video.title}${episodeIndex + 1}`)
//
router.push('/video_player')
//
episodesDialogVisible.value = false
}
//
const showAllEpisodes = (video) => {
currentVideo.value = video
episodesDialogVisible.value = true
}
let timer = null
onMounted(() => {
updateTime()
timer = setInterval(updateTime, 1000)
// store
if (store.search_results && store.search_results.length > 0) {
searchResults.value = store.search_results
hasSearched.value = true
}
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<style scoped>
.video-search {
min-height: 100vh;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
color: white;
font-family: 'Arial', sans-serif;
}
.main-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
padding-bottom: 50px;
}
.time-display {
text-align: center;
margin-bottom: 30px;
}
.current-time {
font-size: 4rem;
font-weight: 300;
letter-spacing: 0.1em;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.current-date {
font-size: 1rem;
opacity: 0.8;
font-weight: 300;
}
.search-section {
margin-bottom: 20px;
width: 100%;
max-width: 600px;
}
.search-input {
margin-bottom: 15px;
}
.search-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
}
.search-input :deep(.el-input__inner) {
color: white;
}
.search-input :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.6);
}
.search-input :deep(.el-input-group__append) {
background: rgba(64, 158, 255, 0.8);
border-color: rgba(64, 158, 255, 0.8);
}
/* 搜索结果样式 */
.search-results {
width: 100%;
max-width: 1000px;
margin-top: 20px;
}
.results-title {
color: white;
margin-bottom: 15px;
padding-left: 5px;
border-left: 4px solid #409eff;
}
.video-card {
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.video-card :deep(.el-card__body) {
padding: 15px;
}
.card-content {
display: flex;
flex-direction: row;
}
.video-info {
display: flex;
flex: 2;
}
.video-poster {
position: relative;
width: 140px;
height: 200px;
margin-right: 15px;
overflow: hidden;
border-radius: 4px;
flex-shrink: 0;
}
.video-poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.vip-badge {
position: absolute;
top: 5px;
right: 5px;
background-color: #f56c6c;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.video-details {
flex: 1;
}
.video-title {
font-size: 18px;
margin: 0 0 8px;
color: white;
}
.video-meta {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 8px;
}
.video-meta span {
margin-right: 10px;
}
.video-cast {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 8px;
}
.cast-label {
color: rgba(255, 255, 255, 0.5);
}
.video-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12px;
line-height: 1.4;
}
.video-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.episodes-container {
flex: 1;
margin-left: 20px;
padding-left: 20px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
.episodes-title {
font-size: 16px;
color: white;
margin: 0 0 10px;
font-weight: normal;
}
.episodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
gap: 8px;
}
.all-episodes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 10px;
}
.support-card {
margin-top: 20px;
margin-bottom: 30px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
}
.support-card :deep(.el-card__body) {
background: transparent;
border: none;
}
.support-text {
text-align: center;
font-size: 14px;
color: white;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.links {
margin-bottom: 30px;
text-align: center;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.link-text {
color: rgba(255, 255, 255, 0.8);
}
.runtime-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.runtime-card :deep(.el-card__body) {
background: transparent;
border: none;
}
.runtime-text {
text-align: center;
font-size: 14px;
color: white;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
@media (max-width: 768px) {
.current-time {
font-size: 2.5rem;
}
.search-section {
max-width: 100%;
padding: 0 10px;
}
.card-content {
flex-direction: column;
}
.video-info {
margin-bottom: 15px;
}
.episodes-container {
margin-left: 0;
padding-left: 0;
border-left: none;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.links {
flex-direction: column;
gap: 10px;
}
}
/* Element Plus 深色主题适配 */
:deep(.el-link) {
color: #87ceeb;
}
:deep(.el-link:hover) {
color: #b3d9f2;
}
:deep(.el-dialog) {
background: rgba(30, 60, 114, 0.95);
backdrop-filter: blur(10px);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
:deep(.el-dialog__title) {
color: white;
}
:deep(.el-dialog__headerbtn .el-dialog__close) {
color: white;
}
:deep(.el-tag) {
background: rgba(64, 158, 255, 0.2);
border-color: rgba(64, 158, 255, 0.3);
color: #87ceeb;
}
:deep(.el-empty__description) {
color: white;
}
</style>