mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-09 18:42:40 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76b9a8d9e7 | ||
|
|
d6d52338e9 | ||
|
|
caa67a0f49 | ||
|
|
6ddc3ea996 | ||
|
|
7edbf7c724 | ||
|
|
4f233ca886 | ||
|
|
457831536a | ||
|
|
ccef0d87db | ||
|
|
584d290283 | ||
|
|
2ab14fa33b | ||
|
|
f0317e1d74 | ||
|
|
17a206e0f4 | ||
|
|
8ea352cc2f | ||
|
|
0f10920898 | ||
|
|
eb098ca775 | ||
|
|
e25caddfef | ||
|
|
c74cf6cf6e | ||
|
|
ce2d04fa64 | ||
|
|
40a4e29c7e | ||
|
|
60385715e6 | ||
|
|
3cce92e83d | ||
|
|
602b0067d2 | ||
|
|
51d07db99b | ||
|
|
33d121fd64 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.6.4",
|
||||
"version": "2.6.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -114,4 +114,4 @@
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ code {
|
||||
position: relative;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
|
||||
|
||||
@media (width >= 1280px) {
|
||||
@media (width >= 1280px) and (hover: hover) {
|
||||
background: rgba(var(--v-theme-background), 1);
|
||||
|
||||
.v-theme--transparent & {
|
||||
@@ -33,8 +33,7 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (width < 1280px) {
|
||||
@media (width < 1280px), (hover: none) {
|
||||
background: transparent;
|
||||
|
||||
&::before {
|
||||
@@ -63,6 +62,6 @@ code {
|
||||
.v-theme--transparent & {
|
||||
background: rgba(var(--v-theme-background), 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,3 +65,6 @@ export function getQueryValue(key: string, url = window.location.href): string {
|
||||
const res = reg.exec(url)
|
||||
return res ? res[1] : ''
|
||||
}
|
||||
|
||||
// 导出 navigator 相关函数
|
||||
export { isMobileDevice, isIOSDevice, isAndroidDevice } from './navigator'
|
||||
|
||||
@@ -84,3 +84,15 @@ export const isMobileDevice = (): boolean => {
|
||||
|
||||
return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize
|
||||
}
|
||||
|
||||
// 检测是否为iOS设备
|
||||
export const isIOSDevice = (): boolean => {
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
return /iphone|ipad|ipod/.test(userAgent) && !(window as any).MSStream
|
||||
}
|
||||
|
||||
// 检测是否为Android设备
|
||||
export const isAndroidDevice = (): boolean => {
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
return /android/.test(userAgent)
|
||||
}
|
||||
|
||||
@@ -986,6 +986,8 @@ export interface MediaServerPlayItem {
|
||||
link?: string
|
||||
// 播放百分比
|
||||
percent?: number
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
}
|
||||
|
||||
// 媒体服务器媒体库
|
||||
@@ -1006,6 +1008,8 @@ export interface MediaServerLibrary {
|
||||
image_list?: string[]
|
||||
// 链接
|
||||
link?: string
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1387,3 +1391,13 @@ export interface TorrentCacheData {
|
||||
// 缓存数据
|
||||
data: TorrentCacheItem[]
|
||||
}
|
||||
|
||||
// 订阅分享统计
|
||||
export interface SubscribeShareStatistics {
|
||||
// 分享人
|
||||
share_user?: string
|
||||
// 分享数量
|
||||
share_count?: number
|
||||
// 总复用人次
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
@@ -16,8 +17,10 @@ function imageLoadHandler() {
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
function goPlay() {
|
||||
if (props.media?.link) window.open(props.media?.link, '_blank')
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算图片地址
|
||||
|
||||
@@ -43,19 +43,14 @@ function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 计算文本类
|
||||
function getTextClass() {
|
||||
return imageLoaded.value ? 'text-white' : ''
|
||||
}
|
||||
|
||||
// 下载状态控制
|
||||
async function toggleDownload() {
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
params: {
|
||||
name: props.downloaderName
|
||||
}
|
||||
name: props.downloaderName,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) isDownloading.value = !isDownloading.value
|
||||
@@ -67,7 +62,7 @@ async function toggleDownload() {
|
||||
// 删除下截
|
||||
async function deleteDownload() {
|
||||
try {
|
||||
await api.delete(`download/${props.info?.hash}`, {params: {name: props.downloaderName}})
|
||||
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
|
||||
cardState.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -76,35 +71,52 @@ async function deleteDownload() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard v-if="cardState" :key="props.info?.hash">
|
||||
<VCard v-if="cardState" :key="props.info?.hash" class="flex flex-col h-full" min-height="150">
|
||||
<template #image>
|
||||
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover class="brightness-50" @load="imageLoadHandler" />
|
||||
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
|
||||
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
<div>
|
||||
<VCardTitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
|
||||
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
|
||||
<VProgressLinear :model-value="getPercentage()" />
|
||||
</VCardText>
|
||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.downloading-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import trimemedia from '@images/logos/trimemedia.png'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -36,16 +37,18 @@ function imageErrorHandler() {
|
||||
|
||||
// 默认图片
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server === 'plex') return plex
|
||||
else if (props.media?.server === 'emby') return emby
|
||||
else if (props.media?.server === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server === 'trimemedia') return trimemedia
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
else if (props.media?.server_type === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server_type === 'trimemedia') return trimemedia
|
||||
else return plex
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
function goPlay() {
|
||||
if (props.media?.link) window.open(props.media?.link, '_blank')
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
|
||||
@@ -47,10 +47,12 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem class="pb-1">
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
<div class="text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis">
|
||||
{{ context?.media_info?.title || context?.meta_info?.name }}
|
||||
{{ context?.meta_info?.season_episode }}
|
||||
</VCardTitle>
|
||||
<span v-if="context?.meta_info?.season_episode" class="text-sm text-medium-emphasis align-top">
|
||||
{{ context?.meta_info?.season_episode }}
|
||||
</span>
|
||||
</div>
|
||||
<VCardSubtitle class="text-center text-md-left">
|
||||
{{ context?.media_info?.year || context?.meta_info?.year }}
|
||||
</VCardSubtitle>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -31,8 +32,10 @@ const getImgUrl = computed(() => {
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
function goPlay(isHovering: boolean | null = false) {
|
||||
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
|
||||
async function goPlay(isHovering: boolean | null = false) {
|
||||
if (props.media?.link && isHovering) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -121,12 +121,22 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<template v-slot:prepend>
|
||||
<div class="d-flex flex-column align-center pr-3">
|
||||
<VImg v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mb-1" width="32" height="32" />
|
||||
<VAvatar v-else size="24" class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
|
||||
<div class="d-flex flex-column align-center pr-3" :title="torrent?.site_name">
|
||||
<VImg
|
||||
v-if="siteIcon"
|
||||
:src="siteIcon"
|
||||
:alt="torrent?.site_name"
|
||||
class="rounded mb-1 site-icon"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
<VAvatar
|
||||
v-else
|
||||
size="32"
|
||||
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
|
||||
>
|
||||
{{ torrent?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
<div class="font-weight-bold text-body-2 text-center d-none d-sm-block">{{ torrent?.site_name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -332,4 +342,12 @@ onMounted(() => {
|
||||
background-color: #9c27b0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-icon:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -172,7 +172,6 @@ onMounted(() => {
|
||||
<template>
|
||||
<DialogWrapper max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
@@ -285,6 +284,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCol>
|
||||
</VCardText>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
@@ -99,6 +99,10 @@ function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
if (!subscribeForm.value.tmdbid) {
|
||||
console.warn('tmdbid is not set or is empty')
|
||||
return
|
||||
}
|
||||
try {
|
||||
episodeGroups.value = await api.get(`media/groups/${subscribeForm.value.tmdbid}`)
|
||||
} catch (error) {
|
||||
@@ -283,7 +287,7 @@ onMounted(() => {
|
||||
<DialogWrapper scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-clipboard-list-outline" class="me-2" />
|
||||
</template>
|
||||
|
||||
@@ -81,6 +81,10 @@ function getMediaId() {
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
if (!props.media?.tmdb_id) {
|
||||
console.warn('tmdbid is not set or is empty')
|
||||
return
|
||||
}
|
||||
try {
|
||||
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
|
||||
} catch (error) {
|
||||
|
||||
477
src/components/dialog/SubscribeShareStatisticsDialog.vue
Normal file
477
src/components/dialog/SubscribeShareStatisticsDialog.vue
Normal file
@@ -0,0 +1,477 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import type { SubscribeShareStatistics } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 主题
|
||||
const theme = useTheme()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref<SubscribeShareStatistics[]>([])
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 获取统计数据
|
||||
async function fetchStatistics() {
|
||||
try {
|
||||
loading.value = true
|
||||
const data: SubscribeShareStatistics[] = await api.get('subscribe/share/statistics')
|
||||
statistics.value = data
|
||||
} catch (error) {
|
||||
console.error('获取分享统计数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 计算排名
|
||||
const rankedStatistics = computed(() => {
|
||||
return statistics.value
|
||||
.sort((a, b) => (b.total_reuse_count || 0) - (a.total_reuse_count || 0))
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
}))
|
||||
})
|
||||
|
||||
// 获取排名样式
|
||||
function getRankStyle(rank: number) {
|
||||
if (rank === 1) {
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
} else if (rank === 2) {
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #CD7F32 0%, #B8860B 100%)',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
} else if (rank === 3) {
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #C0C0C0 0%, #A0A0A0 100%)',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 获取前三名文字颜色
|
||||
function getPodiumTextColor() {
|
||||
return theme.global.current.value.dark ? '#fff' : '#000'
|
||||
}
|
||||
|
||||
// 获取前三名统计背景样式
|
||||
function getPodiumStatStyle() {
|
||||
const isDark = theme.global.current.value.dark
|
||||
return {
|
||||
border: `1px solid ${isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'}`,
|
||||
background: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
}
|
||||
}
|
||||
|
||||
// 获取前三名区域背景样式
|
||||
function getPodiumAreaBackgroundStyle() {
|
||||
const isDark = theme.global.current.value.dark
|
||||
return {
|
||||
background: isDark
|
||||
? 'linear-gradient(135deg, rgba(255, 215, 0, 0.25) 0%, rgba(255, 69, 0, 0.2) 25%, rgba(255, 20, 147, 0.15) 50%, rgba(138, 43, 226, 0.1) 75%, rgba(0, 191, 255, 0.08) 100%), linear-gradient(to bottom, transparent 0%, transparent 70%, rgba(255, 215, 0, 0.1) 85%, transparent 100%)'
|
||||
: 'linear-gradient(135deg, rgba(255, 215, 0, 0.2) 0%, rgba(255, 69, 0, 0.15) 25%, rgba(255, 20, 147, 0.12) 50%, rgba(138, 43, 226, 0.08) 75%, rgba(0, 191, 255, 0.05) 100%), linear-gradient(to bottom, transparent 0%, transparent 70%, rgba(255, 215, 0, 0.08) 85%, transparent 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
padding: '32px 24px 48px 24px',
|
||||
margin: '0 -24px 0 -',
|
||||
boxShadow: isDark
|
||||
? '0 16px 48px rgba(255, 215, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)'
|
||||
: '0 16px 48px rgba(255, 215, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.3)',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
}
|
||||
}
|
||||
|
||||
// 获取排名图标
|
||||
function getRankIcon(rank: number) {
|
||||
if (rank === 1) return 'mdi-trophy'
|
||||
if (rank === 2) return 'mdi-medal-outline'
|
||||
if (rank === 3) return 'mdi-medal'
|
||||
return ''
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogWrapper scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-chart-line" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('subscribe.shareStatistics') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<LoadingBanner v-if="loading" class="mt-4" />
|
||||
<div v-else-if="rankedStatistics.length === 0" class="text-center py-8">
|
||||
<VIcon icon="mdi-chart-line" size="64" color="grey" class="mb-4" />
|
||||
<div class="text-h6 text-grey">{{ t('subscribe.noStatisticsData') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 前三名特殊展示 -->
|
||||
<div class="podium-area" :style="getPodiumAreaBackgroundStyle()">
|
||||
<!-- 装饰性背景元素 -->
|
||||
<div class="podium-decoration">
|
||||
<div class="decoration-circle decoration-1"></div>
|
||||
<div class="decoration-circle decoration-2"></div>
|
||||
<div class="decoration-circle decoration-3"></div>
|
||||
</div>
|
||||
<div class="text-h6 mb-4 text-center podium-title">{{ t('subscribe.ranking') }}</div>
|
||||
<!-- 大屏幕横向排列 -->
|
||||
<div class="d-none d-md-flex justify-center align-center gap-4 flex-wrap">
|
||||
<!-- 第二名 -->
|
||||
<div v-if="rankedStatistics[1]" class="text-center">
|
||||
<div class="rank-circle mb-2" :style="getRankStyle(2)">
|
||||
<VIcon :icon="getRankIcon(2)" size="24" />
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
|
||||
{{ rankedStatistics[1].share_user || '未知' }}
|
||||
</div>
|
||||
<div class="d-flex align-center justify-center gap-2 mt-1">
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
|
||||
rankedStatistics[1].share_count || 0
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
|
||||
rankedStatistics[1].total_reuse_count || 0
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第一名 -->
|
||||
<div v-if="rankedStatistics[0]" class="text-center">
|
||||
<div class="rank-circle mb-2 first-place" :style="getRankStyle(1)">
|
||||
<VIcon :icon="getRankIcon(1)" size="32" />
|
||||
</div>
|
||||
<div class="text-h5 font-weight-bold" :style="{ color: getPodiumTextColor() }">
|
||||
{{ rankedStatistics[0].share_user || '未知' }}
|
||||
</div>
|
||||
<div class="d-flex align-center justify-center gap-3 mt-1">
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
|
||||
rankedStatistics[0].share_count || 0
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
|
||||
rankedStatistics[0].total_reuse_count || 0
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三名 -->
|
||||
<div v-if="rankedStatistics[2]" class="text-center">
|
||||
<div class="rank-circle mb-2" :style="getRankStyle(3)">
|
||||
<VIcon :icon="getRankIcon(3)" size="24" />
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
|
||||
{{ rankedStatistics[2].share_user || '未知' }}
|
||||
</div>
|
||||
<div class="d-flex align-center justify-center gap-2 mt-1">
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
|
||||
rankedStatistics[2].share_count || 0
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
|
||||
rankedStatistics[2].total_reuse_count || 0
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 小屏幕垂直排列 -->
|
||||
<div class="d-flex d-md-none flex-column align-center gap-4">
|
||||
<!-- 第一名 -->
|
||||
<div v-if="rankedStatistics[0]" class="text-center">
|
||||
<div class="rank-circle mb-2 first-place" :style="getRankStyle(1)">
|
||||
<VIcon :icon="getRankIcon(1)" size="32" />
|
||||
</div>
|
||||
<div class="text-h5 font-weight-bold" :style="{ color: getPodiumTextColor() }">
|
||||
{{ rankedStatistics[0].share_user || '未知' }}
|
||||
</div>
|
||||
<div class="d-flex align-center justify-center gap-3 mt-1">
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span :style="{ color: getPodiumTextColor() }">{{ rankedStatistics[0].share_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span :style="{ color: getPodiumTextColor() }">{{
|
||||
rankedStatistics[0].total_reuse_count || 0
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二名 -->
|
||||
<div v-if="rankedStatistics[1]" class="text-center">
|
||||
<div class="rank-circle mb-2" :style="getRankStyle(2)">
|
||||
<VIcon :icon="getRankIcon(2)" size="24" />
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
|
||||
{{ rankedStatistics[1].share_user || '未知' }}
|
||||
</div>
|
||||
<div class="d-flex align-center justify-center gap-2 mt-1">
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span :style="{ color: getPodiumTextColor() }">{{ rankedStatistics[1].share_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span :style="{ color: getPodiumTextColor() }">{{
|
||||
rankedStatistics[1].total_reuse_count || 0
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三名 -->
|
||||
<div v-if="rankedStatistics[2]" class="text-center">
|
||||
<div class="rank-circle mb-2" :style="getRankStyle(3)">
|
||||
<VIcon :icon="getRankIcon(3)" size="24" />
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
|
||||
{{ rankedStatistics[2].share_user || '未知' }}
|
||||
</div>
|
||||
<div class="d-flex align-center justify-center gap-2 mt-1">
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span :style="{ color: getPodiumTextColor() }">
|
||||
{{ rankedStatistics[2].share_count || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
|
||||
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
|
||||
<span :style="{ color: getPodiumTextColor() }">
|
||||
{{ rankedStatistics[2].total_reuse_count || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 完整排行榜 -->
|
||||
<VList class="bg-transparent px-3">
|
||||
<VListItem
|
||||
v-for="item in rankedStatistics.filter(item => item.rank > 3)"
|
||||
:key="item.share_user"
|
||||
class="mb-2 rounded-lg"
|
||||
>
|
||||
<VListItemTitle class="font-weight-bold text-h6 mb-1">
|
||||
{{ item.share_user || '未知' }}
|
||||
</VListItemTitle>
|
||||
|
||||
<VListItemSubtitle class="d-flex align-center gap-3 mt-1">
|
||||
<div class="stat-badge share-badge">
|
||||
<VIcon icon="mdi-share-outline" size="14" color="primary" class="mr-1" />
|
||||
<span class="text-primary font-weight-bold">{{ item.share_count || 0 }}</span>
|
||||
<span class="text-grey text-caption ml-1">{{ t('subscribe.shareCount') }}</span>
|
||||
</div>
|
||||
<div class="stat-badge reuse-badge">
|
||||
<VIcon icon="mdi-fire" size="14" color="warning" class="mr-1" />
|
||||
<span class="text-warning font-weight-bold">{{ item.total_reuse_count || 0 }}</span>
|
||||
<span class="text-grey text-caption ml-1">{{ t('subscribe.totalReuseCount') }}</span>
|
||||
</div>
|
||||
</VListItemSubtitle>
|
||||
|
||||
<template #append>
|
||||
<div class="text-right">
|
||||
<div
|
||||
class="text-h6 font-weight-bold"
|
||||
:style="{ color: item.rank <= 3 ? 'var(--v-primary-base)' : 'inherit' }"
|
||||
>
|
||||
#{{ item.rank }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rank-circle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
block-size: 60px;
|
||||
inline-size: 60px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.first-place {
|
||||
block-size: 80px;
|
||||
box-shadow: 0 4px 12px rgba(255, 215, 0, 30%);
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
block-size: 32px;
|
||||
inline-size: 32px;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-theme-outline), 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-surface), 0.8);
|
||||
padding-block: 4px;
|
||||
padding-inline: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.share-badge {
|
||||
border-inline-start: 3px solid rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.reuse-badge {
|
||||
border-inline-start: 3px solid rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.podium-stat {
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(4px);
|
||||
padding-block: 4px;
|
||||
padding-inline: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.podium-stat:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 前三名区域样式 */
|
||||
.podium-area {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.podium-title {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
color: #fff !important;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 30%);
|
||||
}
|
||||
|
||||
/* 装饰性元素 */
|
||||
.podium-decoration {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.decoration-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 10%) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.decoration-1 {
|
||||
animation-delay: 0s;
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
inset-block-start: 10%;
|
||||
inset-inline-start: 10%;
|
||||
}
|
||||
|
||||
.decoration-2 {
|
||||
animation-delay: 2s;
|
||||
block-size: 60px;
|
||||
inline-size: 60px;
|
||||
inset-block-start: 20%;
|
||||
inset-inline-end: 15%;
|
||||
}
|
||||
|
||||
.decoration-3 {
|
||||
animation-delay: 4s;
|
||||
block-size: 40px;
|
||||
inline-size: 40px;
|
||||
inset-block-end: 20%;
|
||||
inset-inline-start: 20%;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-10px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 增强前三名文字效果 */
|
||||
.podium-area .text-h6,
|
||||
.podium-area .text-h5 {
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 30%);
|
||||
}
|
||||
|
||||
.podium-area .rank-circle {
|
||||
border: 2px solid rgba(255, 255, 255, 20%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 30%);
|
||||
}
|
||||
|
||||
.podium-area .first-place {
|
||||
border: 3px solid rgba(255, 215, 0, 50%);
|
||||
box-shadow: 0 12px 32px rgba(255, 215, 0, 40%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,35 @@
|
||||
import { ref, watch, onBeforeUnmount, readonly } from 'vue'
|
||||
|
||||
/**
|
||||
* 滚动锁定 Composable
|
||||
*
|
||||
* 使用示例:
|
||||
*
|
||||
* // 基本用法
|
||||
* const { isLocked, lockScroll, restoreScroll } = useScrollLock()
|
||||
*
|
||||
* // 带配置的用法
|
||||
* const { isLocked, lockScroll, restoreScroll } = useScrollLock({
|
||||
* preventTouchScroll: true,
|
||||
* preserveScrollPosition: true,
|
||||
* allowScrollSelectors: ['.my-modal', '.scrollable-content'],
|
||||
* allowScrollContainerSelectors: ['.modal-content'],
|
||||
* customScrollCheck: (element) => {
|
||||
* // 自定义逻辑
|
||||
* return element.classList.contains('allow-scroll')
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // 自动监听版本
|
||||
* const { isLocked, lockScroll, restoreScroll } = useScrollLockWithWatch(
|
||||
* showModal, // 响应式布尔值
|
||||
* {
|
||||
* allowScrollSelectors: ['.modal-content'],
|
||||
* allowScrollContainerSelectors: ['.scrollable-area']
|
||||
* }
|
||||
* )
|
||||
*/
|
||||
|
||||
// 滚动锁定配置
|
||||
export interface ScrollLockOptions {
|
||||
// 是否在组件卸载时自动恢复滚动
|
||||
@@ -14,10 +44,22 @@ export interface ScrollLockOptions {
|
||||
position?: string
|
||||
width?: string
|
||||
}
|
||||
// 允许滚动的选择器列表(CSS选择器)
|
||||
// 例如:['.my-modal', '.scrollable-content']
|
||||
allowScrollSelectors?: string[]
|
||||
// 允许滚动的容器选择器列表(CSS选择器)
|
||||
// 这些容器内的可滚动元素将被允许滚动
|
||||
// 例如:['.modal-content', '.scroll-container']
|
||||
allowScrollContainerSelectors?: string[]
|
||||
// 自定义滚动检查函数
|
||||
// 返回 true 表示允许滚动,false 表示阻止滚动
|
||||
customScrollCheck?: (element: Element) => boolean
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_OPTIONS: Required<ScrollLockOptions> = {
|
||||
const DEFAULT_OPTIONS: Required<
|
||||
Omit<ScrollLockOptions, 'allowScrollSelectors' | 'allowScrollContainerSelectors' | 'customScrollCheck'>
|
||||
> = {
|
||||
autoRestore: true,
|
||||
preserveScrollPosition: true,
|
||||
preventTouchScroll: true,
|
||||
@@ -28,15 +70,125 @@ const DEFAULT_OPTIONS: Required<ScrollLockOptions> = {
|
||||
},
|
||||
}
|
||||
|
||||
// 全局状态管理
|
||||
const globalLockCount = ref(0)
|
||||
const globalOriginalStyles = ref<{
|
||||
body: { [key: string]: string }
|
||||
documentElement: { [key: string]: string }
|
||||
html: { [key: string]: string }
|
||||
} | null>(null)
|
||||
const globalSavedScrollPosition = ref(0)
|
||||
const globalTouchEventListeners = new Set<(event: TouchEvent) => void>()
|
||||
|
||||
// 保存全局原始样式(只在第一次锁定时保存)
|
||||
const saveGlobalOriginalStyles = () => {
|
||||
if (globalOriginalStyles.value === null) {
|
||||
globalOriginalStyles.value = {
|
||||
body: {
|
||||
overflow: document.body.style.overflow,
|
||||
position: document.body.style.position,
|
||||
top: document.body.style.top,
|
||||
width: document.body.style.width,
|
||||
},
|
||||
documentElement: {
|
||||
overflow: document.documentElement.style.overflow,
|
||||
},
|
||||
html: {
|
||||
overflow: document.documentElement.style.overflow,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存全局滚动位置(只在第一次锁定时保存)
|
||||
const saveGlobalScrollPosition = () => {
|
||||
if (globalLockCount.value === 0) {
|
||||
globalSavedScrollPosition.value =
|
||||
window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 应用全局锁定样式
|
||||
const applyGlobalLockStyles = (config: any) => {
|
||||
if (globalLockCount.value === 1) {
|
||||
// 第一次锁定时应用样式
|
||||
document.body.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||
document.body.style.position = config.lockStyles.position || 'fixed'
|
||||
document.body.style.width = config.lockStyles.width || '100%'
|
||||
document.documentElement.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||
document.documentElement.classList.add('v-overlay-scroll-blocked')
|
||||
|
||||
// 如果需要保持滚动位置,设置top偏移
|
||||
if (config.preserveScrollPosition) {
|
||||
document.body.style.top = `-${globalSavedScrollPosition.value}px`
|
||||
}
|
||||
|
||||
// 保持navbar的滚动状态 - 添加一个CSS变量来记录滚动位置
|
||||
if (globalSavedScrollPosition.value > 0) {
|
||||
document.documentElement.style.setProperty('--saved-scroll-y', `${globalSavedScrollPosition.value}px`)
|
||||
document.documentElement.classList.add('dialog-scroll-locked')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复全局样式(只在最后一个锁定时恢复)
|
||||
const restoreGlobalStyles = (config: any) => {
|
||||
if (globalLockCount.value === 0 && globalOriginalStyles.value) {
|
||||
// 最后一个锁定时恢复样式
|
||||
document.body.style.overflow = globalOriginalStyles.value.body.overflow || ''
|
||||
document.body.style.position = globalOriginalStyles.value.body.position || ''
|
||||
document.body.style.top = globalOriginalStyles.value.body.top || ''
|
||||
document.body.style.width = globalOriginalStyles.value.body.width || ''
|
||||
document.documentElement.style.overflow = globalOriginalStyles.value.documentElement.overflow || ''
|
||||
|
||||
// 移除 CSS 类名
|
||||
document.documentElement.classList.remove('v-overlay-scroll-blocked')
|
||||
document.documentElement.classList.remove('dialog-scroll-locked')
|
||||
|
||||
// 移除CSS变量
|
||||
document.documentElement.style.removeProperty('--saved-scroll-y')
|
||||
|
||||
// 恢复滚动位置
|
||||
if (config.preserveScrollPosition) {
|
||||
window.scrollTo(0, globalSavedScrollPosition.value)
|
||||
}
|
||||
|
||||
// 重置全局状态
|
||||
globalOriginalStyles.value = null
|
||||
globalSavedScrollPosition.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 添加全局触摸事件监听器
|
||||
const addGlobalTouchEventListener = (listener: (event: TouchEvent) => void) => {
|
||||
globalTouchEventListeners.add(listener)
|
||||
if (globalTouchEventListeners.size === 1) {
|
||||
// 第一次添加监听器时绑定到document
|
||||
document.addEventListener('touchmove', listener, { passive: false })
|
||||
}
|
||||
}
|
||||
|
||||
// 移除全局触摸事件监听器
|
||||
const removeGlobalTouchEventListener = (listener: (event: TouchEvent) => void) => {
|
||||
globalTouchEventListeners.delete(listener)
|
||||
if (globalTouchEventListeners.size === 0) {
|
||||
// 最后一个监听器被移除时解绑
|
||||
document.removeEventListener('touchmove', listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function useScrollLock(options: ScrollLockOptions = {}) {
|
||||
const config = { ...DEFAULT_OPTIONS, ...options }
|
||||
const config = {
|
||||
...DEFAULT_OPTIONS,
|
||||
allowScrollSelectors: options.allowScrollSelectors || [],
|
||||
allowScrollContainerSelectors: options.allowScrollContainerSelectors || [],
|
||||
customScrollCheck: options.customScrollCheck,
|
||||
...options,
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
const isLocked = ref(false)
|
||||
const savedScrollPosition = ref(0)
|
||||
const originalBodyStyles = ref<{ [key: string]: string }>({})
|
||||
const originalDocumentStyles = ref<{ [key: string]: string }>({})
|
||||
const originalHtmlStyles = ref<{ [key: string]: string }>({})
|
||||
|
||||
// 保存当前滚动位置
|
||||
const saveScrollPosition = () => {
|
||||
@@ -46,58 +198,74 @@ export function useScrollLock(options: ScrollLockOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存原始样式
|
||||
const saveOriginalStyles = () => {
|
||||
// 保存 body 样式
|
||||
originalBodyStyles.value = {
|
||||
overflow: document.body.style.overflow,
|
||||
position: document.body.style.position,
|
||||
top: document.body.style.top,
|
||||
width: document.body.style.width,
|
||||
// 检查元素是否应该允许滚动
|
||||
const shouldAllowScroll = (element: Element): boolean => {
|
||||
// 1. 检查是否匹配允许滚动的选择器
|
||||
for (const selector of config.allowScrollSelectors) {
|
||||
if (element.matches(selector) || element.closest(selector)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 documentElement 样式
|
||||
originalDocumentStyles.value = {
|
||||
overflow: document.documentElement.style.overflow,
|
||||
// 2. 检查是否在允许滚动的容器内
|
||||
for (const selector of config.allowScrollContainerSelectors) {
|
||||
const container = element.closest(selector)
|
||||
if (container) {
|
||||
// 检查容器是否可滚动
|
||||
const style = getComputedStyle(container)
|
||||
const isScrollable =
|
||||
container.scrollHeight > container.clientHeight &&
|
||||
style.overflow !== 'hidden' &&
|
||||
(style.overflow === 'auto' ||
|
||||
style.overflow === 'scroll' ||
|
||||
style.overflowY === 'auto' ||
|
||||
style.overflowY === 'scroll')
|
||||
if (isScrollable) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 html 样式
|
||||
originalHtmlStyles.value = {
|
||||
overflow: document.documentElement.style.overflow,
|
||||
// 3. 检查是否在弹窗、菜单或其他覆盖层内
|
||||
const isInDialog = element.closest(
|
||||
'.v-dialog, .v-menu, .v-bottom-sheet, .v-snackbar, [role="dialog"], .v-overlay__content',
|
||||
)
|
||||
|
||||
// 4. 检查是否是可滚动的内容区域
|
||||
const isScrollableContent = element.closest(
|
||||
'.v-card-text, .v-list, .v-table__wrapper, .v-data-table__wrapper, .v-sheet, .v-card__content, .v-data-table, .v-table',
|
||||
)
|
||||
|
||||
// 5. 检查是否在可滚动的容器内
|
||||
const scrollableContainer = element.closest('[style*="overflow"], [class*="overflow"]')
|
||||
const isInScrollableContainer =
|
||||
scrollableContainer &&
|
||||
(scrollableContainer.scrollHeight > scrollableContainer.clientHeight ||
|
||||
getComputedStyle(scrollableContainer).overflow !== 'hidden')
|
||||
|
||||
// 6. 使用自定义检查函数
|
||||
if (config.customScrollCheck && config.customScrollCheck(element)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果不在弹窗内且不是可滚动内容且不在可滚动容器内,则不允许滚动
|
||||
return !!(isInDialog || isScrollableContent || isInScrollableContainer)
|
||||
}
|
||||
|
||||
// 阻止触摸滚动事件
|
||||
const preventTouchScroll = (event: TouchEvent) => {
|
||||
if (isLocked.value && config.preventTouchScroll) {
|
||||
// 检查触摸事件的目标元素是否在弹窗内
|
||||
// 检查触摸事件的目标元素
|
||||
const target = event.target as Element
|
||||
if (target) {
|
||||
// 检查目标元素是否在弹窗、菜单或其他覆盖层内
|
||||
const isInDialog = target.closest(
|
||||
'.v-dialog, .v-menu, .v-bottom-sheet, .v-snackbar, [role="dialog"], .v-overlay__content',
|
||||
)
|
||||
|
||||
// 检查目标元素是否是可滚动的内容区域
|
||||
const isScrollableContent = target.closest(
|
||||
'.v-card-text, .v-list, .v-table__wrapper, .v-data-table__wrapper, .v-sheet, .v-card__content, .v-data-table, .v-table',
|
||||
)
|
||||
|
||||
// 检查目标元素是否在可滚动的容器内
|
||||
const scrollableContainer = target.closest('[style*="overflow"], [class*="overflow"]')
|
||||
const isInScrollableContainer =
|
||||
scrollableContainer &&
|
||||
(scrollableContainer.scrollHeight > scrollableContainer.clientHeight ||
|
||||
getComputedStyle(scrollableContainer).overflow !== 'hidden')
|
||||
|
||||
// 如果不在弹窗内且不是可滚动内容且不在可滚动容器内,则阻止滚动
|
||||
if (!isInDialog && !isScrollableContent && !isInScrollableContainer) {
|
||||
event.preventDefault()
|
||||
// 如果元素应该允许滚动,则不阻止事件
|
||||
if (shouldAllowScroll(target)) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 如果无法确定目标元素,则阻止滚动以确保安全
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// 否则阻止滚动
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,35 +273,21 @@ export function useScrollLock(options: ScrollLockOptions = {}) {
|
||||
const lockScroll = () => {
|
||||
if (isLocked.value) return
|
||||
|
||||
// 保存当前状态
|
||||
saveScrollPosition()
|
||||
saveOriginalStyles()
|
||||
// 增加全局锁定计数
|
||||
globalLockCount.value++
|
||||
|
||||
// 应用锁定样式到 body
|
||||
document.body.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||
document.body.style.position = config.lockStyles.position || 'fixed'
|
||||
document.body.style.width = config.lockStyles.width || '100%'
|
||||
|
||||
// 应用锁定样式到 documentElement
|
||||
document.documentElement.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||
|
||||
// 添加 CSS 类名
|
||||
document.documentElement.classList.add('v-overlay-scroll-blocked')
|
||||
|
||||
// 如果需要保持滚动位置,设置top偏移
|
||||
if (config.preserveScrollPosition) {
|
||||
document.body.style.top = `-${savedScrollPosition.value}px`
|
||||
// 保存当前状态(只在第一次锁定时)
|
||||
if (globalLockCount.value === 1) {
|
||||
saveGlobalOriginalStyles()
|
||||
saveGlobalScrollPosition()
|
||||
}
|
||||
|
||||
// 保持navbar的滚动状态 - 添加一个CSS变量来记录滚动位置
|
||||
if (savedScrollPosition.value > 0) {
|
||||
document.documentElement.style.setProperty('--saved-scroll-y', `${savedScrollPosition.value}px`)
|
||||
document.documentElement.classList.add('dialog-scroll-locked')
|
||||
}
|
||||
// 应用锁定样式
|
||||
applyGlobalLockStyles(config)
|
||||
|
||||
// 添加触摸事件监听器
|
||||
if (config.preventTouchScroll) {
|
||||
document.addEventListener('touchmove', preventTouchScroll, { passive: false })
|
||||
addGlobalTouchEventListener(preventTouchScroll)
|
||||
}
|
||||
|
||||
isLocked.value = true
|
||||
@@ -143,29 +297,16 @@ export function useScrollLock(options: ScrollLockOptions = {}) {
|
||||
const restoreScroll = () => {
|
||||
if (!isLocked.value) return
|
||||
|
||||
// 恢复原始样式
|
||||
document.body.style.overflow = originalBodyStyles.value.overflow || ''
|
||||
document.body.style.position = originalBodyStyles.value.position || ''
|
||||
document.body.style.top = originalBodyStyles.value.top || ''
|
||||
document.body.style.width = originalBodyStyles.value.width || ''
|
||||
document.documentElement.style.overflow = originalDocumentStyles.value.overflow || ''
|
||||
|
||||
// 移除 CSS 类名
|
||||
document.documentElement.classList.remove('v-overlay-scroll-blocked')
|
||||
document.documentElement.classList.remove('dialog-scroll-locked')
|
||||
|
||||
// 移除CSS变量
|
||||
document.documentElement.style.removeProperty('--saved-scroll-y')
|
||||
// 减少全局锁定计数
|
||||
globalLockCount.value--
|
||||
|
||||
// 移除触摸事件监听器
|
||||
if (config.preventTouchScroll) {
|
||||
document.removeEventListener('touchmove', preventTouchScroll)
|
||||
removeGlobalTouchEventListener(preventTouchScroll)
|
||||
}
|
||||
|
||||
// 恢复滚动位置
|
||||
if (config.preserveScrollPosition) {
|
||||
window.scrollTo(0, savedScrollPosition.value)
|
||||
}
|
||||
// 恢复样式(只在最后一个锁定时)
|
||||
restoreGlobalStyles(config)
|
||||
|
||||
isLocked.value = false
|
||||
}
|
||||
|
||||
@@ -164,7 +164,15 @@ const handleServiceWorkerMessage = (event: MessageEvent) => {
|
||||
}
|
||||
|
||||
// 使用滚动锁定 composable(自动监听showPluginQuickAccess的变化)
|
||||
useScrollLockWithWatch(showPluginQuickAccess)
|
||||
useScrollLockWithWatch(showPluginQuickAccess, {
|
||||
preventTouchScroll: true,
|
||||
preserveScrollPosition: true,
|
||||
autoRestore: true,
|
||||
// 允许快速访问面板内的滚动
|
||||
allowScrollSelectors: ['.plugin-quick-access'],
|
||||
// 允许快速访问面板内的可滚动容器
|
||||
allowScrollContainerSelectors: ['.plugin-grid'],
|
||||
})
|
||||
|
||||
// 检查是否可以使用下拉手势
|
||||
const canUsePullGesture = () => {
|
||||
|
||||
@@ -248,7 +248,7 @@ const showDynamicButton = computed(() => {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(24px);
|
||||
background-color: rgba(var(--v-theme-surface), 0.3);
|
||||
background-color: rgba(var(--v-theme-surface), 0.6);
|
||||
pointer-events: auto;
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
|
||||
|
||||
@@ -141,7 +141,9 @@ function getPluginIcon(plugin: Plugin): string {
|
||||
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(plugin?.plugin_icon)}&cache=true`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
plugin?.plugin_icon,
|
||||
)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${plugin?.plugin_icon}`
|
||||
}
|
||||
@@ -233,6 +235,12 @@ function handleTouchStart(event: TouchEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
startedFromBottomArea.value = !!target.closest('.bottom-drag-area')
|
||||
|
||||
// 如果触摸发生在插件网格内,不处理拖拽关闭
|
||||
if (target.closest('.plugin-grid')) {
|
||||
startedFromBottomArea.value = false
|
||||
return
|
||||
}
|
||||
|
||||
startY.value = touch.clientY
|
||||
lastY.value = touch.clientY
|
||||
lastTime.value = Date.now()
|
||||
@@ -253,6 +261,12 @@ function handleTouchMove(event: TouchEvent) {
|
||||
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
|
||||
if (!startedFromBottomArea.value) return
|
||||
|
||||
// 检查当前触摸是否在插件网格内,如果是则不处理拖拽关闭
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.plugin-grid')) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentY = touch.clientY
|
||||
const currentTime = Date.now()
|
||||
const deltaY = startY.value - currentY // 向上为正值
|
||||
@@ -561,6 +575,7 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-block-size: calc(100vh - 200px); // 确保有最大高度限制
|
||||
min-block-size: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: none; // IE/Edge
|
||||
@@ -571,6 +586,7 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
// 隐藏滚动条
|
||||
scrollbar-width: none; // Firefox
|
||||
touch-action: pan-y;
|
||||
will-change: scroll-position;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; // WebKit 浏览器
|
||||
|
||||
@@ -481,7 +481,7 @@ onUnmounted(() => {
|
||||
</VMenu>
|
||||
|
||||
<!-- 👉 FAQ -->
|
||||
<VListItem href="https://wiki.movie-pilot.org" target="_blank" class="mb-1 rounded-lg" hover>
|
||||
<VListItem href="https://movie-pilot.org" target="_blank" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-help-circle-outline" />
|
||||
</template>
|
||||
|
||||
@@ -740,6 +740,8 @@ export default {
|
||||
searchResource: 'Search Resource',
|
||||
subscribe: 'Subscribe',
|
||||
playOnline: 'Play Online',
|
||||
playInApp: 'Play in App',
|
||||
playInWeb: 'Play in Web',
|
||||
},
|
||||
search: {
|
||||
byTitle: 'Title',
|
||||
@@ -768,6 +770,14 @@ export default {
|
||||
title: 'Error!',
|
||||
noMediaInfo: 'No media information recognized.',
|
||||
},
|
||||
server: {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
appLaunchFailed: 'App launch failed, redirecting to web version',
|
||||
appNotInstalled: 'App not detected, redirecting to web version',
|
||||
downloadApp: 'Download App',
|
||||
},
|
||||
},
|
||||
subscribe: {
|
||||
normalSub: 'Subscribe',
|
||||
@@ -797,6 +807,11 @@ export default {
|
||||
'After reset, {name} will be restored to its initial state, downloaded records will be cleared, and unimported content will be downloaded again. Are you sure?',
|
||||
resetSuccess: '{name} has been reset successfully!',
|
||||
resetFailed: '{name} reset failed: {message}',
|
||||
shareStatistics: 'Share Statistics',
|
||||
shareCount: 'Shares',
|
||||
totalReuseCount: 'Total Reuse Count',
|
||||
ranking: 'Ranking',
|
||||
noStatisticsData: 'No share statistics data available',
|
||||
},
|
||||
recommend: {
|
||||
all: 'All',
|
||||
@@ -1067,6 +1082,7 @@ export default {
|
||||
dataDir: 'Data Directory',
|
||||
timezone: 'Timezone',
|
||||
latest: 'Latest',
|
||||
supportingSites: 'Supporting Sites',
|
||||
support: 'Support',
|
||||
documentation: 'Documentation',
|
||||
feedback: 'Feedback',
|
||||
@@ -1771,7 +1787,8 @@ export default {
|
||||
shareSuccess: '{name} shared successfully!',
|
||||
shareFailed: '{name} share failed: {message}!',
|
||||
securityWarning: 'Security Warning',
|
||||
securityWarningMessage: 'Before sharing, please ensure the workflow does not contain sensitive information such as PassKey in RSS links to avoid information leakage.',
|
||||
securityWarningMessage:
|
||||
'Before sharing, please ensure the workflow does not contain sensitive information such as PassKey in RSS links to avoid information leakage.',
|
||||
},
|
||||
u115Auth: {
|
||||
loginTitle: '115 Cloud Login',
|
||||
|
||||
@@ -737,6 +737,8 @@ export default {
|
||||
searchResource: '搜索资源',
|
||||
subscribe: '订阅',
|
||||
playOnline: '在线播放',
|
||||
playInApp: 'APP播放',
|
||||
playInWeb: '网页播放',
|
||||
},
|
||||
search: {
|
||||
byTitle: '标题',
|
||||
@@ -765,6 +767,14 @@ export default {
|
||||
title: '出错啦!',
|
||||
noMediaInfo: '未识别到媒体信息。',
|
||||
},
|
||||
server: {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
appLaunchFailed: 'APP启动失败,正在跳转到网页版',
|
||||
appNotInstalled: '未检测到APP,正在跳转到网页版',
|
||||
downloadApp: '下载APP',
|
||||
},
|
||||
},
|
||||
subscribe: {
|
||||
normalSub: '订阅',
|
||||
@@ -793,6 +803,11 @@ export default {
|
||||
resetConfirm: '重置后 {name} 将恢复初始状态,已下载记录将被清除,未入库的内容将会重新下载,是否确认?',
|
||||
resetSuccess: '{name} 重置成功!',
|
||||
resetFailed: '{name} 重置失败:{message}',
|
||||
shareStatistics: '分享统计',
|
||||
shareCount: '个分享',
|
||||
totalReuseCount: '次复用',
|
||||
ranking: '排名',
|
||||
noStatisticsData: '暂无分享统计数据',
|
||||
},
|
||||
recommend: {
|
||||
all: '全部',
|
||||
@@ -1063,6 +1078,7 @@ export default {
|
||||
dataDir: '数据目录',
|
||||
timezone: '时区',
|
||||
latest: '最新',
|
||||
supportingSites: '支持站点',
|
||||
support: '支援',
|
||||
documentation: '文档',
|
||||
feedback: '问题反馈',
|
||||
|
||||
@@ -735,6 +735,8 @@ export default {
|
||||
searchResource: '搜索資源',
|
||||
subscribe: '訂閱',
|
||||
playOnline: '線上播放',
|
||||
playInApp: 'APP播放',
|
||||
playInWeb: '網頁播放',
|
||||
},
|
||||
search: {
|
||||
byTitle: '標題',
|
||||
@@ -763,6 +765,14 @@ export default {
|
||||
title: '出錯啦!',
|
||||
noMediaInfo: '未識別到媒體信息。',
|
||||
},
|
||||
server: {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
appLaunchFailed: 'APP啟動失敗,正在跳轉到網頁版',
|
||||
appNotInstalled: '未檢測到APP,正在跳轉到網頁版',
|
||||
downloadApp: '下載APP',
|
||||
},
|
||||
},
|
||||
subscribe: {
|
||||
normalSub: '訂閱',
|
||||
@@ -791,6 +801,11 @@ export default {
|
||||
resetConfirm: '重置後 {name} 將恢復初始狀態,已下載記錄將被清除,未入庫的內容將會重新下載,是否確認?',
|
||||
resetSuccess: '{name} 重置成功!',
|
||||
resetFailed: '{name} 重置失敗:{message}',
|
||||
shareStatistics: '分享統計',
|
||||
shareCount: '個分享',
|
||||
totalReuseCount: '次複用',
|
||||
ranking: '排名',
|
||||
noStatisticsData: '暫無分享統計數據',
|
||||
},
|
||||
recommend: {
|
||||
all: '全部',
|
||||
@@ -1063,6 +1078,7 @@ export default {
|
||||
timezone: '時區',
|
||||
latest: '最新',
|
||||
support: '支援',
|
||||
supportingSites: '支持站點',
|
||||
documentation: '文檔',
|
||||
feedback: '問題反饋',
|
||||
channel: '發布頻道',
|
||||
|
||||
@@ -3,6 +3,7 @@ import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
||||
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
|
||||
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
@@ -36,6 +37,9 @@ const filterSubscribeDialog = ref(false)
|
||||
// 搜索订阅分享弹窗
|
||||
const searchShareDialog = ref(false)
|
||||
|
||||
// 订阅分享统计弹窗
|
||||
const shareStatisticsDialog = ref(false)
|
||||
|
||||
// 订阅过滤词
|
||||
const subscribeFilter = ref('')
|
||||
|
||||
@@ -71,6 +75,17 @@ registerHeaderTab({
|
||||
},
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-chart-line',
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'statistics-btn',
|
||||
action: () => {
|
||||
shareStatisticsDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'share'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-movie-search-outline',
|
||||
variant: 'text',
|
||||
@@ -191,6 +206,13 @@ onMounted(() => {
|
||||
@save="subscribeEditDialog = false"
|
||||
@close="subscribeEditDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 订阅分享统计弹窗 -->
|
||||
<SubscribeShareStatisticsDialog
|
||||
v-if="shareStatisticsDialog"
|
||||
v-model="shareStatisticsDialog"
|
||||
@close="shareStatisticsDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ declare let self: ServiceWorkerGlobalScope & {
|
||||
}
|
||||
|
||||
// 缓存版本控制
|
||||
const CACHE_VERSION = 'v1.0.2'
|
||||
const CACHE_VERSION = 'v1.0.3'
|
||||
const CACHE_NAMES = {
|
||||
appShell: `app-shell-${CACHE_VERSION}`,
|
||||
static: `static-resources-${CACHE_VERSION}`,
|
||||
|
||||
720
src/utils/appDeepLink.ts
Normal file
720
src/utils/appDeepLink.ts
Normal file
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* 通用APP深度链接工具类
|
||||
* 支持媒体服务器(Plex、Jellyfin、Emby)和豆瓣的APP跳转和网页跳转
|
||||
*
|
||||
* 深度链接格式参考:
|
||||
* - Plex: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456
|
||||
* - Emby: https://emby.media/support/articles/Deep-Linking.html
|
||||
* - Jellyfin: https://jellyfin.org/docs/general/administration/deep-linking
|
||||
* - 豆瓣: 官方搜索格式
|
||||
*/
|
||||
|
||||
import { isMobileDevice, isIOSDevice, isAndroidDevice } from '@/@core/utils'
|
||||
|
||||
// APP类型
|
||||
export type AppType = 'plex' | 'jellyfin' | 'emby' | 'trimemedia' | 'douban'
|
||||
|
||||
// 深度链接配置
|
||||
interface DeepLinkConfig {
|
||||
appScheme: string
|
||||
webUrl: string
|
||||
timeout: number
|
||||
}
|
||||
|
||||
// 各APP的深度链接配置
|
||||
const DEEP_LINK_CONFIGS: Record<AppType, DeepLinkConfig> = {
|
||||
plex: {
|
||||
appScheme: 'plex://',
|
||||
webUrl: 'https://app.plex.tv',
|
||||
timeout: 2000,
|
||||
},
|
||||
jellyfin: {
|
||||
appScheme: 'jellyfin://',
|
||||
webUrl: 'https://jellyfin.org',
|
||||
timeout: 2000,
|
||||
},
|
||||
emby: {
|
||||
appScheme: 'emby://',
|
||||
webUrl: 'https://emby.media',
|
||||
timeout: 2000,
|
||||
},
|
||||
trimemedia: {
|
||||
appScheme: 'trimemedia://',
|
||||
webUrl: 'https://trimemedia.com',
|
||||
timeout: 2000,
|
||||
},
|
||||
douban: {
|
||||
appScheme: 'douban://',
|
||||
webUrl: 'https://movie.douban.com',
|
||||
timeout: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
// 豆瓣APP跳转参数
|
||||
interface DoubanAppParams {
|
||||
doubanId: string
|
||||
mediaType?: string
|
||||
title?: string
|
||||
year?: string
|
||||
fallbackUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试跳转到APP,如果失败则跳转到网页
|
||||
* @param appType APP类型
|
||||
* @param params 跳转参数
|
||||
*/
|
||||
export async function openApp(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): Promise<void> {
|
||||
// 如果不是移动设备,直接使用网页链接
|
||||
if (!isMobileDevice()) {
|
||||
const webUrl = getWebUrl(appType, params, fallbackUrl)
|
||||
window.open(webUrl, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
const config = DEEP_LINK_CONFIGS[appType]
|
||||
if (!config) {
|
||||
console.warn(`不支持的APP类型: ${appType}`)
|
||||
const webUrl = getWebUrl(appType, params, fallbackUrl)
|
||||
window.open(webUrl, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
// 构建APP深度链接
|
||||
const appUrl = buildDeepLinkUrl(appType, params)
|
||||
|
||||
console.log(`构建${appType}深度链接:`, {
|
||||
params,
|
||||
deepLinkUrl: appUrl,
|
||||
})
|
||||
|
||||
// 尝试跳转到APP
|
||||
try {
|
||||
await attemptAppLaunch(appUrl, config.timeout)
|
||||
} catch (error) {
|
||||
console.log(`${appType} APP跳转失败,使用网页链接: ${error}`)
|
||||
// APP跳转失败,使用网页链接
|
||||
const webUrl = getWebUrl(appType, params, fallbackUrl)
|
||||
window.open(webUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网页链接
|
||||
* @param appType APP类型
|
||||
* @param params 参数
|
||||
* @param fallbackUrl 备用链接
|
||||
*/
|
||||
function getWebUrl(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): string {
|
||||
if (fallbackUrl) return fallbackUrl
|
||||
|
||||
const config = DEEP_LINK_CONFIGS[appType]
|
||||
|
||||
switch (appType) {
|
||||
case 'douban':
|
||||
const doubanParams = params as DoubanAppParams
|
||||
return `${config.webUrl}/subject/${doubanParams.doubanId}`
|
||||
default:
|
||||
return typeof params === 'string' ? params : config.webUrl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建深度链接URL
|
||||
* @param appType APP类型
|
||||
* @param params 参数
|
||||
*/
|
||||
function buildDeepLinkUrl(appType: AppType, params: string | DoubanAppParams): string {
|
||||
switch (appType) {
|
||||
case 'plex':
|
||||
return buildPlexDeepLink(params as string)
|
||||
|
||||
case 'jellyfin':
|
||||
return buildJellyfinDeepLink(params as string)
|
||||
|
||||
case 'emby':
|
||||
return buildEmbyDeepLink(params as string)
|
||||
|
||||
case 'trimemedia':
|
||||
return buildTrimemediaDeepLink(params as string)
|
||||
|
||||
case 'douban':
|
||||
return buildDoubanDeepLink(params as DoubanAppParams)
|
||||
|
||||
default:
|
||||
return typeof params === 'string' ? params : ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Plex深度链接
|
||||
* 参考: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456
|
||||
*
|
||||
* 后台API返回格式:
|
||||
* - 媒体库: web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={token}
|
||||
* - 媒体项: web/index.html#!/server/{machineIdentifier}/details?key={item_id}&X-Plex-Token={token}
|
||||
*
|
||||
* Plex官方APP URL格式:
|
||||
* plex://play/?metadataKey=/library/metadata/$SOME_ID&server=$SERVER_ID
|
||||
* 例如: plex://play/?metadataKey=/library/metadata/123&server=456
|
||||
*
|
||||
* @param playUrl 播放链接
|
||||
*/
|
||||
function buildPlexDeepLink(playUrl: string): string {
|
||||
try {
|
||||
const url = new URL(playUrl)
|
||||
|
||||
// 提取媒体ID、机器标识符、库ID等
|
||||
let mediaId: string | null = null
|
||||
let machineIdentifier: string | null = null
|
||||
let libraryKey: string | null = null
|
||||
let librarySectionId: string | null = null
|
||||
let plexToken: string | null = null
|
||||
|
||||
// 提取X-Plex-Token
|
||||
const tokenMatch = playUrl.match(/X-Plex-Token=([^&]+)/)
|
||||
if (tokenMatch) {
|
||||
plexToken = tokenMatch[1]
|
||||
console.log('提取Plex Token:', { plexToken })
|
||||
}
|
||||
|
||||
// 格式1: 后台API返回的媒体库格式
|
||||
// web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={token}
|
||||
const mediaLibraryMatch = playUrl.match(/\/media\/([^\/]+)\/com\.plexapp\.plugins\.library\?source=([^&]+)/)
|
||||
if (mediaLibraryMatch) {
|
||||
machineIdentifier = mediaLibraryMatch[1]
|
||||
libraryKey = mediaLibraryMatch[2]
|
||||
console.log('Plex后台API媒体库格式匹配:', { machineIdentifier, libraryKey })
|
||||
|
||||
// 从library.key中提取section ID
|
||||
// library.key格式通常是: library://video-section/1 或类似格式
|
||||
const sectionMatch = libraryKey.match(/section\/(\d+)/)
|
||||
if (sectionMatch) {
|
||||
librarySectionId = sectionMatch[1]
|
||||
console.log('从library.key提取section ID:', { librarySectionId })
|
||||
}
|
||||
}
|
||||
|
||||
// 格式2: 后台API返回的媒体项格式
|
||||
// web/index.html#!/server/{machineIdentifier}/details?key={item_id}&X-Plex-Token={token}
|
||||
const serverDetailsMatch = playUrl.match(/\/server\/([^\/]+)\/details\?key=([^&]+)/)
|
||||
if (serverDetailsMatch) {
|
||||
machineIdentifier = serverDetailsMatch[1]
|
||||
const keyValue = serverDetailsMatch[2]
|
||||
console.log('Plex后台API媒体项格式匹配:', { machineIdentifier, keyValue })
|
||||
|
||||
// 从key中提取媒体ID
|
||||
// key格式可能是: /library/metadata/1668 或直接是 1668
|
||||
const metadataMatch = keyValue.match(/\/library\/metadata\/(\d+)/)
|
||||
if (metadataMatch) {
|
||||
mediaId = metadataMatch[1]
|
||||
console.log('从key提取媒体ID:', { mediaId })
|
||||
} else if (/^\d+$/.test(keyValue)) {
|
||||
// 如果key本身就是数字,直接使用
|
||||
mediaId = keyValue
|
||||
console.log('key本身就是媒体ID:', { mediaId })
|
||||
}
|
||||
}
|
||||
|
||||
// 构建深度链接 - 使用新的官方格式
|
||||
if (mediaId && machineIdentifier) {
|
||||
// plex://play/?metadataKey=/library/metadata/$SOME_ID&server=$SERVER_ID
|
||||
let deepLink = `plex://play/?metadataKey=/library/metadata/${mediaId}&server=${machineIdentifier}`
|
||||
if (plexToken) {
|
||||
deepLink += `&X-Plex-Token=${plexToken}`
|
||||
}
|
||||
console.log('Plex深度链接构建成功:', {
|
||||
originalUrl: playUrl,
|
||||
machineIdentifier,
|
||||
libraryKey,
|
||||
librarySectionId,
|
||||
mediaId,
|
||||
plexToken,
|
||||
deepLink,
|
||||
})
|
||||
return deepLink
|
||||
}
|
||||
|
||||
// 如果有媒体ID但没有机器标识符,尝试使用旧的格式作为降级
|
||||
if (mediaId) {
|
||||
let deepLink = `plex://library/metadata/${mediaId}`
|
||||
if (plexToken) {
|
||||
deepLink += `?X-Plex-Token=${plexToken}`
|
||||
}
|
||||
console.log('Plex深度链接构建成功(降级格式):', {
|
||||
originalUrl: playUrl,
|
||||
mediaId,
|
||||
plexToken,
|
||||
deepLink,
|
||||
})
|
||||
return deepLink
|
||||
}
|
||||
|
||||
// 如果有库ID,尝试使用库ID
|
||||
if (librarySectionId) {
|
||||
// http://[PMS_IP_Address]:32400/library/sections/29/all?X-Plex-Token=YourTokenGoesHere
|
||||
let libraryLink = `plex://library/sections/${librarySectionId}/all`
|
||||
if (plexToken) {
|
||||
libraryLink += `?X-Plex-Token=${plexToken}`
|
||||
}
|
||||
console.log('Plex库深度链接构建成功:', {
|
||||
originalUrl: playUrl,
|
||||
librarySectionId,
|
||||
plexToken,
|
||||
libraryLink,
|
||||
})
|
||||
return libraryLink
|
||||
}
|
||||
|
||||
// 如果无法提取媒体ID,尝试使用机器标识符
|
||||
if (machineIdentifier) {
|
||||
// http://[PMS_IP_Address]:32400/library/sections?X-Plex-Token=YourTokenGoesHere
|
||||
let fallbackLink = `plex://library/sections`
|
||||
if (plexToken) {
|
||||
fallbackLink += `?X-Plex-Token=${plexToken}`
|
||||
}
|
||||
console.log('Plex深度链接构建失败,使用机器标识符:', {
|
||||
originalUrl: playUrl,
|
||||
machineIdentifier,
|
||||
plexToken,
|
||||
fallbackLink,
|
||||
})
|
||||
return fallbackLink
|
||||
}
|
||||
|
||||
// 最后的降级方案
|
||||
console.log('Plex深度链接构建失败,使用原始URL:', {
|
||||
originalUrl: playUrl,
|
||||
})
|
||||
return `plex://${playUrl}`
|
||||
} catch (error) {
|
||||
console.warn('构建Plex深度链接失败:', error)
|
||||
return `plex://${playUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Jellyfin深度链接
|
||||
* 参考: https://jellyfin.org/docs/general/administration/deep-linking
|
||||
* @param playUrl 播放链接
|
||||
*/
|
||||
function buildJellyfinDeepLink(playUrl: string): string {
|
||||
try {
|
||||
const url = new URL(playUrl)
|
||||
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
|
||||
|
||||
// 提取媒体ID、库ID、serverId
|
||||
let mediaId: string | null = null
|
||||
let libraryId: string | null = null
|
||||
let serverId: string | null = null
|
||||
|
||||
// 格式1: /details?id={item_id}&serverId={serverid}
|
||||
const detailsMatch = playUrl.match(/\/details\?id=([^&]+)&serverId=([^&]+)/)
|
||||
if (detailsMatch) {
|
||||
mediaId = detailsMatch[1]
|
||||
serverId = detailsMatch[2]
|
||||
}
|
||||
|
||||
// 格式2: /movies.html?topParentId={libraryId}
|
||||
const moviesMatch = playUrl.match(/\/movies\.html\?topParentId=([^&]+)/)
|
||||
if (moviesMatch) {
|
||||
libraryId = moviesMatch[1]
|
||||
}
|
||||
// 格式3: /tv.html?topParentId={libraryId}
|
||||
const tvMatch = playUrl.match(/\/tv\.html\?topParentId=([^&]+)/)
|
||||
if (tvMatch) {
|
||||
libraryId = tvMatch[1]
|
||||
}
|
||||
// 格式4: /library.html?topParentId={libraryId}
|
||||
const libMatch = playUrl.match(/\/library\.html\?topParentId=([^&]+)/)
|
||||
if (libMatch) {
|
||||
libraryId = libMatch[1]
|
||||
}
|
||||
|
||||
// 兼容原有格式:?id=xxx
|
||||
if (!mediaId) {
|
||||
const idMatch = playUrl.match(/[?&]id=([^&]+)/)
|
||||
if (idMatch) {
|
||||
mediaId = idMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容原有格式:/items/xxx
|
||||
if (!mediaId) {
|
||||
const itemsMatch = playUrl.match(/\/items\/([^\/\?]+)/)
|
||||
if (itemsMatch) {
|
||||
mediaId = itemsMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 构建深度链接
|
||||
if (mediaId) {
|
||||
let deepLink = `jellyfin://${serverAddress}/item/${mediaId}`
|
||||
if (serverId) {
|
||||
deepLink += `?serverId=${serverId}`
|
||||
}
|
||||
console.log('Jellyfin深度链接构建成功:', {
|
||||
originalUrl: playUrl,
|
||||
serverAddress,
|
||||
mediaId,
|
||||
serverId,
|
||||
deepLink,
|
||||
})
|
||||
return deepLink
|
||||
}
|
||||
if (libraryId) {
|
||||
const deepLink = `jellyfin://${serverAddress}/library/${libraryId}`
|
||||
console.log('Jellyfin库深度链接构建成功:', {
|
||||
originalUrl: playUrl,
|
||||
serverAddress,
|
||||
libraryId,
|
||||
deepLink,
|
||||
})
|
||||
return deepLink
|
||||
}
|
||||
|
||||
// 如果无法提取ID,尝试直接使用服务器地址
|
||||
const fallbackLink = `jellyfin://${serverAddress}`
|
||||
console.log('Jellyfin深度链接构建失败,使用服务器地址:', {
|
||||
originalUrl: playUrl,
|
||||
serverAddress,
|
||||
fallbackLink,
|
||||
})
|
||||
return fallbackLink
|
||||
} catch (error) {
|
||||
console.warn('构建Jellyfin深度链接失败:', error)
|
||||
return `jellyfin://${playUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Emby深度链接
|
||||
* 参考: https://emby.media/support/articles/Deep-Linking.html
|
||||
* iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
|
||||
* Android格式: emby://{服务器地址}/item/{媒体ID}
|
||||
* @param playUrl 播放链接
|
||||
*/
|
||||
function buildEmbyDeepLink(playUrl: string): string {
|
||||
try {
|
||||
const url = new URL(playUrl)
|
||||
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
|
||||
|
||||
// 尝试多种格式提取媒体ID
|
||||
let mediaId: string | null = null
|
||||
let serverId: string | null = null
|
||||
|
||||
// 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式)
|
||||
const itemHashMatch = playUrl.match(/\/item\?id=([^&]+)/)
|
||||
if (itemHashMatch) {
|
||||
mediaId = itemHashMatch[1]
|
||||
// 提取serverId
|
||||
const serverIdMatch = playUrl.match(/serverId=([^&]+)/)
|
||||
if (serverIdMatch) {
|
||||
serverId = serverIdMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式)
|
||||
const videosHashMatch = playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/)
|
||||
if (videosHashMatch) {
|
||||
// 对于videos格式,我们使用parentId作为媒体ID
|
||||
mediaId = videosHashMatch[2]
|
||||
serverId = videosHashMatch[1]
|
||||
}
|
||||
|
||||
// 格式3: ?id=xxx (通用格式)
|
||||
if (!mediaId) {
|
||||
const idMatch = playUrl.match(/[?&]id=([^&]+)/)
|
||||
if (idMatch) {
|
||||
mediaId = idMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 格式4: /itemdetails.html?id=xxx
|
||||
if (!mediaId) {
|
||||
const itemMatch = playUrl.match(/\/itemdetails\.html\?id=([^&]+)/)
|
||||
if (itemMatch) {
|
||||
mediaId = itemMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 格式5: /items/xxx
|
||||
if (!mediaId) {
|
||||
const itemsMatch = playUrl.match(/\/items\/([^\/\?]+)/)
|
||||
if (itemsMatch) {
|
||||
mediaId = itemsMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 格式6: /item/xxx (路径格式)
|
||||
if (!mediaId) {
|
||||
const itemPathMatch = playUrl.match(/\/item\/([^\/\?]+)/)
|
||||
if (itemPathMatch) {
|
||||
mediaId = itemPathMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaId) {
|
||||
let deepLink: string
|
||||
|
||||
// 根据设备类型使用不同的深度链接格式
|
||||
if (isIOSDevice()) {
|
||||
// iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
|
||||
if (serverId) {
|
||||
deepLink = `emby://items?serverId=${serverId}&itemId=${mediaId}`
|
||||
} else {
|
||||
// 如果没有serverId,尝试使用服务器地址作为serverId
|
||||
deepLink = `emby://items?serverId=${serverAddress}&itemId=${mediaId}`
|
||||
}
|
||||
} else {
|
||||
// Android格式: emby://{服务器地址}/item/{媒体ID}
|
||||
deepLink = `emby://${serverAddress}/item/${mediaId}`
|
||||
if (serverId) {
|
||||
deepLink += `?serverId=${serverId}`
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Emby深度链接构建成功:', {
|
||||
originalUrl: playUrl,
|
||||
serverAddress,
|
||||
mediaId,
|
||||
serverId,
|
||||
deviceType: isIOSDevice() ? 'iOS' : 'Android',
|
||||
deepLink,
|
||||
})
|
||||
return deepLink
|
||||
}
|
||||
|
||||
// 如果无法提取媒体ID,尝试直接使用服务器地址
|
||||
// 这会打开Emby APP的主界面
|
||||
const fallbackLink = `emby://${serverAddress}`
|
||||
console.log('Emby深度链接构建失败,使用服务器地址:', {
|
||||
originalUrl: playUrl,
|
||||
serverAddress,
|
||||
fallbackLink,
|
||||
})
|
||||
return fallbackLink
|
||||
} catch (error) {
|
||||
console.warn('构建Emby深度链接失败:', error)
|
||||
return playUrl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Trimemedia深度链接
|
||||
* @param playUrl 播放链接
|
||||
*/
|
||||
function buildTrimemediaDeepLink(playUrl: string): string {
|
||||
try {
|
||||
const url = new URL(playUrl)
|
||||
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
|
||||
|
||||
// 提取媒体ID
|
||||
let mediaId: string | null = null
|
||||
|
||||
// 尝试从URL路径中提取媒体ID
|
||||
const pathMatch = playUrl.match(/\/item\/([^\/\?]+)/)
|
||||
if (pathMatch) {
|
||||
mediaId = pathMatch[1]
|
||||
}
|
||||
|
||||
// 尝试从查询参数中提取媒体ID
|
||||
if (!mediaId) {
|
||||
const idMatch = playUrl.match(/[?&]id=([^&]+)/)
|
||||
if (idMatch) {
|
||||
mediaId = idMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 构建深度链接
|
||||
if (mediaId) {
|
||||
const deepLink = `trimemedia://${serverAddress}/item/${mediaId}`
|
||||
console.log('Trimemedia深度链接构建成功:', {
|
||||
originalUrl: playUrl,
|
||||
serverAddress,
|
||||
mediaId,
|
||||
deepLink,
|
||||
})
|
||||
return deepLink
|
||||
}
|
||||
|
||||
// 如果无法提取媒体ID,尝试直接使用服务器地址
|
||||
const fallbackLink = `trimemedia://${serverAddress}`
|
||||
console.log('Trimemedia深度链接构建失败,使用服务器地址:', {
|
||||
originalUrl: playUrl,
|
||||
serverAddress,
|
||||
fallbackLink,
|
||||
})
|
||||
return fallbackLink
|
||||
} catch (error) {
|
||||
console.warn('构建Trimemedia深度链接失败:', error)
|
||||
return playUrl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建豆瓣深度链接
|
||||
* 使用豆瓣App官方支持的搜索格式
|
||||
* @param params 豆瓣参数
|
||||
*/
|
||||
function buildDoubanDeepLink(params: DoubanAppParams): string {
|
||||
const { title, year } = params
|
||||
|
||||
// 使用豆瓣App官方支持的搜索格式
|
||||
// 格式:douban:///search?q={query}
|
||||
const searchQuery = `${title || ''} ${year || ''}`.trim()
|
||||
const deepLink = `douban:///search?q=${encodeURIComponent(searchQuery)}`
|
||||
|
||||
console.log('豆瓣深度链接构建成功:', {
|
||||
params,
|
||||
searchQuery,
|
||||
deepLink,
|
||||
})
|
||||
|
||||
return deepLink
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试启动APP
|
||||
* @param appUrl APP深度链接
|
||||
* @param timeout 超时时间
|
||||
*/
|
||||
async function attemptAppLaunch(appUrl: string, timeout: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建一个隐藏的iframe来尝试启动APP
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.display = 'none'
|
||||
iframe.src = appUrl
|
||||
|
||||
// 设置超时
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.body.removeChild(iframe)
|
||||
reject(new Error('APP启动超时'))
|
||||
}, timeout)
|
||||
|
||||
// 监听页面可见性变化,如果用户切换到APP,说明启动成功
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
clearTimeout(timeoutId)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
document.body.removeChild(iframe)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// 添加到页面并尝试启动
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
// 对于iOS,还需要尝试window.location
|
||||
if (isIOSDevice()) {
|
||||
try {
|
||||
window.location.href = appUrl
|
||||
} catch (error) {
|
||||
console.log('iOS window.location跳转失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据播放链接自动检测媒体服务器类型并跳转
|
||||
* @param playUrl 播放链接
|
||||
* @param fallbackUrl 备用网页链接
|
||||
* @param serverType 媒体服务器类型(可选,优先使用此参数)
|
||||
*/
|
||||
export async function openMediaServerWithAutoDetect(
|
||||
playUrl: string,
|
||||
fallbackUrl?: string,
|
||||
serverType?: string,
|
||||
): Promise<void> {
|
||||
let detectedServerType: AppType | null = null
|
||||
|
||||
// 优先使用传入的 serverType 参数
|
||||
if (serverType) {
|
||||
const type = serverType.toLowerCase()
|
||||
if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'trimemedia') {
|
||||
detectedServerType = type as AppType
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有传入 serverType 或类型不支持,则从URL中检测
|
||||
if (!detectedServerType) {
|
||||
const url = playUrl.toLowerCase()
|
||||
|
||||
if (url.includes('plex') || url.includes('plex.tv')) {
|
||||
detectedServerType = 'plex'
|
||||
} else if (url.includes('jellyfin')) {
|
||||
detectedServerType = 'jellyfin'
|
||||
} else if (url.includes('emby')) {
|
||||
detectedServerType = 'emby'
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedServerType) {
|
||||
await openApp(detectedServerType, playUrl, fallbackUrl)
|
||||
} else {
|
||||
// 无法检测到服务器类型,直接使用网页链接
|
||||
window.open(fallbackUrl || playUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开豆瓣APP
|
||||
* @param doubanId 豆瓣ID
|
||||
* @param mediaType 媒体类型(电影/电视剧)
|
||||
* @param title 媒体标题
|
||||
* @param year 媒体年份
|
||||
* @param fallbackUrl 备用网页链接
|
||||
*/
|
||||
export async function openDoubanApp(
|
||||
doubanId: string,
|
||||
mediaType?: string,
|
||||
title?: string,
|
||||
year?: string,
|
||||
fallbackUrl?: string,
|
||||
): Promise<void> {
|
||||
const params: DoubanAppParams = {
|
||||
doubanId,
|
||||
mediaType,
|
||||
title,
|
||||
year,
|
||||
fallbackUrl,
|
||||
}
|
||||
|
||||
await openApp('douban', params, fallbackUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取APP的下载链接
|
||||
* @param appType APP类型
|
||||
*/
|
||||
export function getAppDownloadUrl(appType: AppType): string {
|
||||
switch (appType) {
|
||||
case 'plex':
|
||||
return 'https://www.plex.tv/apps/'
|
||||
case 'jellyfin':
|
||||
return 'https://jellyfin.org/downloads/'
|
||||
case 'emby':
|
||||
return 'https://emby.media/download.html'
|
||||
case 'trimemedia':
|
||||
return 'https://trimemedia.com/download'
|
||||
case 'douban':
|
||||
return 'https://www.douban.com/doubanapp/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否安装了特定的APP
|
||||
* 注意:由于浏览器安全限制,无法直接检测APP是否安装
|
||||
* 这个方法主要用于提示用户
|
||||
*/
|
||||
export function checkAppInstalled(appType: AppType): boolean {
|
||||
// 由于浏览器安全限制,无法直接检测APP是否安装
|
||||
// 这里可以根据用户代理或其他信息进行推测
|
||||
// 目前返回false,让系统总是尝试跳转
|
||||
return false
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useTheme } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openMediaServerWithAutoDetect, openDoubanApp } from '@/utils/appDeepLink'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -353,6 +354,18 @@ function getDoubanLink() {
|
||||
return `https://movie.douban.com/subject/${mediaDetail.value.douban_id}`
|
||||
}
|
||||
|
||||
// 处理豆瓣链接点击
|
||||
async function handleDoubanClick() {
|
||||
if (mediaDetail.value.douban_id) {
|
||||
await openDoubanApp(
|
||||
mediaDetail.value.douban_id,
|
||||
mediaDetail.value.type,
|
||||
mediaDetail.value.title,
|
||||
mediaDetail.value.year,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 拼装IMDB地址
|
||||
function getImdbLink() {
|
||||
return `https://www.imdb.com/title/${mediaDetail.value.imdb_id}`
|
||||
@@ -475,10 +488,8 @@ async function handlePlay() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
|
||||
if (result?.success) {
|
||||
// 打开链接地址
|
||||
setTimeout(() => {
|
||||
window.open(result.data.url, '_blank')
|
||||
}, 100)
|
||||
// 使用深度链接工具,优先跳转到APP,失败后跳转到网页
|
||||
await openMediaServerWithAutoDetect(result.data.url, undefined, result.data.server_type)
|
||||
} else {
|
||||
$toast.error(`获取播放链接失败:${result.message}!`)
|
||||
}
|
||||
@@ -669,19 +680,14 @@ onBeforeMount(() => {
|
||||
<span class="ms-1">TheMovieDb</span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
v-if="mediaDetail.douban_id"
|
||||
class="mb-2 mr-2 inline-flex last:mr-0"
|
||||
:href="getDoubanLink()"
|
||||
target="_blank"
|
||||
>
|
||||
<div v-if="mediaDetail.douban_id" class="mb-2 mr-2 inline-flex last:mr-0" @click="handleDoubanClick">
|
||||
<div
|
||||
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
|
||||
>
|
||||
<VIcon icon="mdi-link" />
|
||||
<span class="ms-1">豆瓣</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<a v-if="mediaDetail.imdb_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getImdbLink()" target="_blank">
|
||||
<div
|
||||
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
|
||||
|
||||
@@ -12,6 +12,9 @@ const systemEnv = ref<any>({})
|
||||
// 所有Release
|
||||
const allRelease = ref<any>([])
|
||||
|
||||
// 支持站点
|
||||
const supportingSites = ref<any>({})
|
||||
|
||||
// 变更日志对话框
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
@@ -56,6 +59,15 @@ async function queryAllRelease() {
|
||||
}
|
||||
}
|
||||
|
||||
// 查询支持站点
|
||||
async function querySupportingSites() {
|
||||
try {
|
||||
supportingSites.value = await api.get('site/supporting')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算发布时间
|
||||
function releaseTime(releaseDate: string) {
|
||||
// 上一次更新时间
|
||||
@@ -65,6 +77,7 @@ function releaseTime(releaseDate: string) {
|
||||
onMounted(() => {
|
||||
querySystemEnv()
|
||||
queryAllRelease()
|
||||
querySupportingSites()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -156,6 +169,28 @@ onMounted(() => {
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VChip
|
||||
v-for="(site, domain) in supportingSites"
|
||||
:key="domain"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
:title="`${site.name} - ${site.url}`"
|
||||
:href="site.url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span class="truncate max-w-32">{{ site.name }}</span>
|
||||
<VIcon icon="mdi-open-in-new" size="12" class="ml-1 flex-shrink-0" />
|
||||
</VChip>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,12 +206,12 @@ onMounted(() => {
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<a
|
||||
href="https://wiki.movie-pilot.org"
|
||||
href="https://movie-pilot.org"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://wiki.movie-pilot.org
|
||||
https://movie-pilot.org
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
@@ -31,7 +31,6 @@ const siteSetting = ref<any>({
|
||||
COOKIECLOUD_KEY: '',
|
||||
COOKIECLOUD_PASSWORD: '',
|
||||
COOKIECLOUD_INTERVAL: 0,
|
||||
USER_AGENT: '',
|
||||
COOKIECLOUD_ENABLE_LOCAL: false,
|
||||
COOKIECLOUD_BLACKLIST: '',
|
||||
},
|
||||
@@ -190,15 +189,6 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-block-helper"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="siteSetting.CookieCloud.USER_AGENT"
|
||||
:label="t('setting.site.userAgent')"
|
||||
:hint="t('setting.site.userAgentHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
|
||||
Reference in New Issue
Block a user