mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-10 17:42:50 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b5e4a330 | ||
|
|
f8f7275438 | ||
|
|
6eec2e97f9 | ||
|
|
9020494f65 | ||
|
|
43fbc7abd7 | ||
|
|
d65a4b747d | ||
|
|
849fad8a8a | ||
|
|
f0b2d14502 | ||
|
|
fa169fb785 | ||
|
|
49acf7fba3 | ||
|
|
80d55dae8d | ||
|
|
76aa5407a2 | ||
|
|
d70789934f | ||
|
|
398e8b6afc | ||
|
|
593fede47c | ||
|
|
40c7e9c126 | ||
|
|
54e2f70ee0 | ||
|
|
81f85b9e46 | ||
|
|
60a5476e59 | ||
|
|
4271b63530 | ||
|
|
8aca17f0c6 | ||
|
|
4f238dc1a3 | ||
|
|
d4777fde70 | ||
|
|
b6c823c386 | ||
|
|
b7488214fc | ||
|
|
06b6c3f3cb | ||
|
|
abfaf926c4 | ||
|
|
6eabeb09c9 | ||
|
|
a15afabfa7 | ||
|
|
30276d5022 | ||
|
|
683ddc3fce | ||
|
|
f00f79279b | ||
|
|
7989965b1a | ||
|
|
5b84ce307b |
@@ -30,7 +30,7 @@
|
|||||||
<meta name="referrer" content="never" />
|
<meta name="referrer" content="never" />
|
||||||
<meta name="msapplication-TileColor" content="#7D34FD" />
|
<meta name="msapplication-TileColor" content="#7D34FD" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta name="theme-color" content="#28243D" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="HandheldFriendly" content="True" />
|
<meta name="HandheldFriendly" content="True" />
|
||||||
<meta name="MobileOptimized" content="320" />
|
<meta name="MobileOptimized" content="320" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.3.4",
|
"version": "2.3.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ onMounted(() => {
|
|||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
<!-- 自定义 CSS -- -->
|
<!-- 自定义 CSS -- -->
|
||||||
<VDialog v-if="cssDialog" v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard title="自定义主题风格">
|
<VCard title="自定义主题风格">
|
||||||
<DialogCloseBtn @click="cssDialog = false" />
|
<DialogCloseBtn @click="cssDialog = false" />
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
|||||||
@@ -3,26 +3,31 @@ export const storageOptions = [
|
|||||||
title: '本地',
|
title: '本地',
|
||||||
value: 'local',
|
value: 'local',
|
||||||
icon: 'mdi-folder-multiple-outline',
|
icon: 'mdi-folder-multiple-outline',
|
||||||
|
remote: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '阿里云盘',
|
title: '阿里云盘',
|
||||||
value: 'alipan',
|
value: 'alipan',
|
||||||
icon: 'mdi-cloud-outline',
|
icon: 'mdi-cloud-outline',
|
||||||
|
remote: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '115网盘',
|
title: '115网盘',
|
||||||
value: 'u115',
|
value: 'u115',
|
||||||
icon: 'mdi-cloud-outline',
|
icon: 'mdi-cloud-outline',
|
||||||
|
remote: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'RClone',
|
title: 'RClone',
|
||||||
value: 'rclone',
|
value: 'rclone',
|
||||||
icon: 'mdi-cloud-outline',
|
icon: 'mdi-cloud-outline',
|
||||||
|
remote: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'AList',
|
title: 'AList',
|
||||||
value: 'alist',
|
value: 'alist',
|
||||||
icon: 'mdi-cloud-outline',
|
icon: 'mdi-cloud-outline',
|
||||||
|
remote: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
BIN
src/assets/images/logos/trimemedia.png
Normal file
BIN
src/assets/images/logos/trimemedia.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -7,25 +7,62 @@ interface Props {
|
|||||||
errorCode?: string
|
errorCode?: string
|
||||||
errorTitle?: string
|
errorTitle?: string
|
||||||
errorDescription?: string
|
errorDescription?: string
|
||||||
|
size?: number
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VEmptyState :image="image" size="250">
|
<div class="no-data-container">
|
||||||
<template #title>
|
<VEmptyState :image="image" :size="props.size || 'auto'">
|
||||||
<div class="mt-8 text-2xl">
|
<template #title>
|
||||||
{{ props.errorTitle }}
|
<div class="mt-8 text-2xl">
|
||||||
</div>
|
{{ props.errorTitle }}
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #text>
|
<template #text>
|
||||||
<div class="text-subtitle mt-3">
|
<div class="text-subtitle mt-3">
|
||||||
{{ props.errorDescription }}
|
{{ props.errorDescription }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<slot name="button" />
|
<slot name="button" />
|
||||||
</template>
|
</template>
|
||||||
</VEmptyState>
|
</VEmptyState>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.no-data-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.no-data-container :deep(.v-empty-state) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.no-data-container :deep(.v-empty-state__media) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.no-data-container :deep(.v-responsive) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.no-data-container :deep(.v-responsive) {
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.no-data-container :deep(.v-responsive) {
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const typeItems = [
|
|||||||
{ title: '电视剧', value: '电视剧' },
|
{ title: '电视剧', value: '电视剧' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
|
||||||
|
const resourceStorageOptions = computed(() => {
|
||||||
|
return storageOptions.filter(item => !item.remote || props.directory.monitor_type !== 'downloader')
|
||||||
|
})
|
||||||
|
|
||||||
// 自动整理方式下拉字典
|
// 自动整理方式下拉字典
|
||||||
const transferSourceItems = [
|
const transferSourceItems = [
|
||||||
{ title: '不整理', value: '' },
|
{ title: '不整理', value: '' },
|
||||||
@@ -131,7 +136,7 @@ const getCategories = computed(() => {
|
|||||||
return default_value.concat(props.categories[props.directory.media_type ?? ''])
|
return default_value.concat(props.categories[props.directory.media_type ?? ''])
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听 下载储存与媒体库储存 变化,重新加载整理方式下拉字典
|
// 监听 资源存储与媒体库储存 变化,重新加载整理方式下拉字典
|
||||||
watch(
|
watch(
|
||||||
[() => props.directory.library_storage, () => props.directory.storage],
|
[() => props.directory.library_storage, () => props.directory.storage],
|
||||||
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
|
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
|
||||||
@@ -156,6 +161,16 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听monitor_type变化,如果为downloader则设置为本地
|
||||||
|
watch(
|
||||||
|
() => props.directory.monitor_type,
|
||||||
|
newMonitorType => {
|
||||||
|
if (newMonitorType === 'downloader') {
|
||||||
|
props.directory.storage = 'local'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -198,8 +213,8 @@ watch(
|
|||||||
<VSelect
|
<VSelect
|
||||||
v-model="props.directory.storage"
|
v-model="props.directory.storage"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
:items="storageOptions"
|
:items="resourceStorageOptions"
|
||||||
label="下载存储/源存储"
|
label="资源存储"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="8">
|
<VCol cols="8">
|
||||||
@@ -207,7 +222,7 @@ watch(
|
|||||||
v-model="props.directory.download_path"
|
v-model="props.directory.download_path"
|
||||||
:storage="props.directory.storage"
|
:storage="props.directory.storage"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
label="下载目录/源目录"
|
label="资源目录"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
|
|||||||
import plex from '@images/misc/plex.png'
|
import plex from '@images/misc/plex.png'
|
||||||
import emby from '@images/misc/emby.png'
|
import emby from '@images/misc/emby.png'
|
||||||
import jellyfin from '@images/misc/jellyfin.png'
|
import jellyfin from '@images/misc/jellyfin.png'
|
||||||
|
import trimemedia from '@images/logos/trimemedia.png'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -38,6 +39,7 @@ function getDefaultImage() {
|
|||||||
if (props.media?.server === 'plex') return plex
|
if (props.media?.server === 'plex') return plex
|
||||||
else if (props.media?.server === 'emby') return emby
|
else if (props.media?.server === 'emby') return emby
|
||||||
else if (props.media?.server === 'jellyfin') return jellyfin
|
else if (props.media?.server === 'jellyfin') return jellyfin
|
||||||
|
else if (props.media?.server === 'trimemedia') return trimemedia
|
||||||
else return plex
|
else return plex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,18 @@ const selectedSites = ref<number[]>([])
|
|||||||
// 搜索菜单显示状态
|
// 搜索菜单显示状态
|
||||||
const searchMenuShow = ref(false)
|
const searchMenuShow = ref(false)
|
||||||
|
|
||||||
|
// 全选/全不选按钮文字
|
||||||
|
const checkAllText = computed(() => (selectedSites.value.length === allSites.value.length ? '全不选' : '全选'))
|
||||||
|
|
||||||
|
// 全选/全不选
|
||||||
|
function checkAllSitesorNot() {
|
||||||
|
if (selectedSites.value.length === allSites.value.length) {
|
||||||
|
selectedSites.value = []
|
||||||
|
} else {
|
||||||
|
selectedSites.value = allSites.value.map(item => item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 查询所有站点
|
// 查询所有站点
|
||||||
async function querySites() {
|
async function querySites() {
|
||||||
try {
|
try {
|
||||||
@@ -573,6 +585,11 @@ function onRemoveSubscribe() {
|
|||||||
{{ site.name }}
|
{{ site.name }}
|
||||||
</VChip>
|
</VChip>
|
||||||
</VChipGroup>
|
</VChipGroup>
|
||||||
|
<div>
|
||||||
|
<VBtn size="small" variant="text" @click.stop="checkAllSitesorNot">
|
||||||
|
{{ checkAllText }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem>
|
<VListItem>
|
||||||
<VBtn @click="handleSearch" block>搜索</VBtn>
|
<VBtn @click="handleSearch" block>搜索</VBtn>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useToast } from 'vue-toast-notification'
|
|||||||
import emby_image from '@images/logos/emby.png'
|
import emby_image from '@images/logos/emby.png'
|
||||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||||
import plex_image from '@images/logos/plex.png'
|
import plex_image from '@images/logos/plex.png'
|
||||||
|
import trimemedia_image from '@images/logos/trimemedia.png'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { cloneDeep } from 'lodash-es'
|
import { cloneDeep } from 'lodash-es'
|
||||||
|
|
||||||
@@ -101,6 +102,8 @@ const getIcon = computed(() => {
|
|||||||
return emby_image
|
return emby_image
|
||||||
case 'jellyfin':
|
case 'jellyfin':
|
||||||
return jellyfin_image
|
return jellyfin_image
|
||||||
|
case 'trimemedia':
|
||||||
|
return trimemedia_image
|
||||||
default:
|
default:
|
||||||
return plex_image
|
return plex_image
|
||||||
}
|
}
|
||||||
@@ -278,6 +281,53 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
<VRow v-if="mediaServerInfo.type == 'trimemedia'">
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model="mediaServerInfo.name"
|
||||||
|
label="名称"
|
||||||
|
placeholder="必填;不可与其他名称重名"
|
||||||
|
hint="媒体服务器的别名"
|
||||||
|
persistent-hint
|
||||||
|
active
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model="mediaServerInfo.config.host"
|
||||||
|
label="地址"
|
||||||
|
placeholder="http(s)://ip:port"
|
||||||
|
hint="服务端地址,格式:http(s)://ip:port"
|
||||||
|
persistent-hint
|
||||||
|
active
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VTextField
|
||||||
|
v-model="mediaServerInfo.config.play_host"
|
||||||
|
label="外网播放地址"
|
||||||
|
placeholder="http(s)://domain:port"
|
||||||
|
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||||
|
persistent-hint
|
||||||
|
active
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model="mediaServerInfo.config.username"
|
||||||
|
label="用户名"
|
||||||
|
active
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
type="password"
|
||||||
|
v-model="mediaServerInfo.config.password"
|
||||||
|
label="密码"
|
||||||
|
active
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
<VRow v-if="mediaServerInfo.type == 'plex'">
|
<VRow v-if="mediaServerInfo.type == 'plex'">
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
<VTextField
|
<VTextField
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const meta = ref(props.torrent?.meta_info)
|
|||||||
const downloadItem = ref(props.torrent)
|
const downloadItem = ref(props.torrent)
|
||||||
|
|
||||||
// 站点图标
|
// 站点图标
|
||||||
const siteIcon = ref('')
|
const siteIcons = ref<Record<number, string>>({})
|
||||||
|
|
||||||
// 存储是否已经下载过的记录
|
// 存储是否已经下载过的记录
|
||||||
const downloaded = ref<string[]>([])
|
const downloaded = ref<string[]>([])
|
||||||
@@ -51,9 +51,10 @@ function addDownloadError(error: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查询站点图标
|
// 查询站点图标
|
||||||
async function getSiteIcon() {
|
async function getSiteIcon(site: number | undefined) {
|
||||||
|
if (!site) return
|
||||||
try {
|
try {
|
||||||
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -78,141 +79,183 @@ async function downloadTorrentFile() {
|
|||||||
window.open(torrent.value?.enclosure, '_blank')
|
window.open(torrent.value?.enclosure, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 促销Chip类
|
// 获取优惠类型样式
|
||||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
if (!downloadVolumeFactor) return 'free-discount'
|
||||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
if (downloadVolumeFactor === 0) return 'free-discount'
|
||||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
else if (downloadVolumeFactor < 1) return 'percent-discount'
|
||||||
else return 'text-white bg-gray-500'
|
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
|
||||||
|
else return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开更多来源对话框
|
||||||
|
async function openMoreTorrentsDialog() {
|
||||||
|
props.more?.forEach(t => {
|
||||||
|
return getSiteIcon(t.torrent_info?.site)
|
||||||
|
})
|
||||||
|
showMoreTorrents.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 装载时查询站点图标
|
// 装载时查询站点图标
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getSiteIcon()
|
getSiteIcon(props.torrent?.torrent_info?.site)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<VCard
|
<VCard
|
||||||
:width="props.width"
|
:width="props.width || '100%'"
|
||||||
:height="props.height"
|
height="300px"
|
||||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
|
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||||
@click="handleAddDownload(props.torrent)"
|
@click="handleAddDownload(props.torrent)"
|
||||||
|
class="torrent-card"
|
||||||
|
:class="{ 'downloaded-card': downloaded.includes(torrent?.enclosure || '') }"
|
||||||
>
|
>
|
||||||
<template v-if="!showMoreTorrents" #image>
|
<!-- 优惠标签 -->
|
||||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
<div
|
||||||
<VImg :src="siteIcon" />
|
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||||
</VAvatar>
|
class="discount-banner"
|
||||||
</template>
|
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||||
<VCardItem class="py-1">
|
>
|
||||||
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
|
{{ torrent?.volume_factor }}
|
||||||
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
|
</div>
|
||||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
|
||||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
<!-- 媒体标题 -->
|
||||||
</VCardTitle>
|
<div class="card-header">
|
||||||
<template #append>
|
<div class="media-title-wrapper">
|
||||||
<div class="me-n3">
|
<h3 class="media-title">
|
||||||
<IconBtn>
|
{{ media?.title ?? meta?.name }}
|
||||||
<VIcon icon="mdi-dots-vertical" />
|
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
|
||||||
<VMenu activator="parent" close-on-content-click>
|
</h3>
|
||||||
<VList>
|
|
||||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon icon="mdi-information" />
|
|
||||||
</template>
|
|
||||||
<VListItemTitle>查看详情</VListItemTitle>
|
|
||||||
</VListItem>
|
|
||||||
<VListItem
|
|
||||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
|
||||||
variant="plain"
|
|
||||||
@click="downloadTorrentFile()"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon icon="mdi-download" />
|
|
||||||
</template>
|
|
||||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
</VMenu>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VCardItem>
|
|
||||||
<VCardText class="text-subtitle-2">
|
|
||||||
{{ torrent?.title }}
|
|
||||||
</VCardText>
|
|
||||||
<VCardText>【{{ torrent?.site_name }}】{{ torrent?.description }}</VCardText>
|
|
||||||
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
|
|
||||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
|
||||||
H&R
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
|
||||||
{{ torrent?.freedate_diff }}
|
|
||||||
</VChip>
|
|
||||||
<VChip
|
|
||||||
v-for="(label, index) in torrent?.labels"
|
|
||||||
:key="index"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
class="me-1 mb-1"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
|
||||||
{{ meta?.edition }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
|
||||||
{{ meta?.resource_pix }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
|
||||||
{{ meta?.video_encode }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
|
||||||
{{ formatFileSize(torrent?.size) }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
|
||||||
{{ meta?.resource_team }}
|
|
||||||
</VChip>
|
|
||||||
<VChip
|
|
||||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
|
||||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1"
|
|
||||||
>
|
|
||||||
{{ torrent?.volume_factor }}
|
|
||||||
</VChip>
|
|
||||||
</VCardItem>
|
|
||||||
<VCardActions>
|
|
||||||
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
|
|
||||||
<template #append>
|
|
||||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
|
|
||||||
</template>
|
|
||||||
更多来源
|
|
||||||
</VBtn>
|
|
||||||
</VCardActions>
|
|
||||||
<VExpandTransition>
|
|
||||||
<div v-show="showMoreTorrents">
|
|
||||||
<VDivider />
|
|
||||||
<VChipGroup class="p-3" column>
|
|
||||||
<VChip v-for="(item, index) in props.more" :key="index" @click.stop="handleAddDownload(item)">
|
|
||||||
<template #append>
|
|
||||||
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
|
|
||||||
<VBadge
|
|
||||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
|
||||||
:content="item.torrent_info?.volume_factor"
|
|
||||||
inline
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
{{ item.torrent_info.site_name }}
|
|
||||||
</VChip>
|
|
||||||
</VChipGroup>
|
|
||||||
</div>
|
</div>
|
||||||
</VExpandTransition>
|
|
||||||
|
<!-- 站点信息条 -->
|
||||||
|
<div class="site-info">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<img v-if="siteIcons[torrent?.site || 0]" :src="siteIcons[torrent?.site || 0]" class="site-icon" />
|
||||||
|
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
|
||||||
|
<span class="site-name">{{ torrent?.site_name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="seeder-peers">
|
||||||
|
<span v-if="torrent?.seeders" class="seed-info">
|
||||||
|
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
|
||||||
|
</span>
|
||||||
|
<span v-if="torrent?.peers" class="peer-info">
|
||||||
|
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 种子内容 -->
|
||||||
|
<div class="card-content">
|
||||||
|
<!-- 种子标题 -->
|
||||||
|
<div class="torrent-title" :title="torrent?.title">
|
||||||
|
{{ torrent?.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 种子描述 -->
|
||||||
|
<div
|
||||||
|
v-if="meta?.subtitle || torrent?.description"
|
||||||
|
class="torrent-desc"
|
||||||
|
:title="meta?.subtitle || torrent?.description"
|
||||||
|
>
|
||||||
|
{{ meta?.subtitle || torrent?.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 资源标签区 -->
|
||||||
|
<div class="tags-container">
|
||||||
|
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
|
||||||
|
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
|
||||||
|
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
|
||||||
|
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
|
||||||
|
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
|
||||||
|
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
|
||||||
|
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 卡片底部信息 -->
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="more-sources-wrapper" v-if="props.more && props.more.length > 0">
|
||||||
|
<div class="more-sources-toggle" @click.stop="openMoreTorrentsDialog">
|
||||||
|
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="me-1"></VIcon>
|
||||||
|
<span>更多来源 ({{ props.more.length }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VSpacer />
|
||||||
|
|
||||||
|
<!-- 体积和详情按钮并排 -->
|
||||||
|
<div class="card-actions">
|
||||||
|
<div v-if="torrent?.size" class="size-badge">
|
||||||
|
{{ formatFileSize(torrent.size) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
size="small"
|
||||||
|
icon="mdi-information-outline"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
class="detail-btn"
|
||||||
|
@click.stop="openTorrentDetail"
|
||||||
|
></VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
|
<!-- 更多来源对话框 - 改为独立对话框 -->
|
||||||
|
<VDialog v-model="showMoreTorrents" max-width="380px" location="center">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="py-2 d-flex align-center">
|
||||||
|
<span>其他来源</span>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VCardText class="more-sources-content">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in props.more"
|
||||||
|
:key="index"
|
||||||
|
@click.stop="handleAddDownload(item)"
|
||||||
|
class="more-source-item cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="source-site-info">
|
||||||
|
<img
|
||||||
|
v-if="siteIcons[item.torrent_info?.site || 0]"
|
||||||
|
:src="siteIcons[item.torrent_info?.site || 0]"
|
||||||
|
class="source-site-icon"
|
||||||
|
/>
|
||||||
|
<span v-else class="source-site-fallback">{{ item.torrent_info?.site_name?.substring(0, 1) }}</span>
|
||||||
|
<span class="source-site-name">{{ item.torrent_info.site_name }}</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||||
|
class="source-discount"
|
||||||
|
:class="
|
||||||
|
getPromotionClass(item.torrent_info?.downloadvolumefactor, item.torrent_info?.uploadvolumefactor)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ item.torrent_info?.volume_factor }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-stats">
|
||||||
|
<span class="source-size">{{ formatFileSize(item.torrent_info?.size) }}</span>
|
||||||
|
<span class="source-seeders">
|
||||||
|
<VIcon size="x-small" color="success" icon="mdi-arrow-up"></VIcon>
|
||||||
|
{{ item.torrent_info?.seeders }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
<AddDownloadDialog
|
<AddDownloadDialog
|
||||||
v-if="addDownloadDialog"
|
v-if="addDownloadDialog"
|
||||||
v-model="addDownloadDialog"
|
v-model="addDownloadDialog"
|
||||||
@@ -227,3 +270,380 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.torrent-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 0 0 0 12px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-discount {
|
||||||
|
background-color: #4caf50;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent-discount {
|
||||||
|
background-color: #ff5722;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-bonus {
|
||||||
|
background-color: #9c27b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-badge {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 12px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title-wrapper {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-tag {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-fallback {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeder-peers {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-info {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
height: 4em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tag {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition {
|
||||||
|
background-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolution {
|
||||||
|
background-color: #e91e63;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codec {
|
||||||
|
background-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team {
|
||||||
|
background-color: #03a9f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire {
|
||||||
|
background-color: #9c27b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
background-color: #3f51b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr {
|
||||||
|
background-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sources-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sources-toggle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sources-toggle:hover {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sources-content {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-source-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-source-item:hover {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-site-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-site-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-site-fallback {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-site-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-discount {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-size {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-seeders {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-btn {
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloaded-card {
|
||||||
|
border: 2px solid #4caf50 !important;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.media-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-card {
|
||||||
|
height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-text {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-activator {
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-words {
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-visible {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-break-spaces {
|
||||||
|
white-space: normal !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const meta = ref(props.torrent?.meta_info)
|
|||||||
// 站点图标
|
// 站点图标
|
||||||
const siteIcon = ref('')
|
const siteIcon = ref('')
|
||||||
|
|
||||||
|
// 站点图标加载状态
|
||||||
|
const iconLoading = ref(false)
|
||||||
|
const iconError = ref(false)
|
||||||
|
|
||||||
// 存储是否已经下载过的记录
|
// 存储是否已经下载过的记录
|
||||||
const downloaded = ref<string[]>([])
|
const downloaded = ref<string[]>([])
|
||||||
|
|
||||||
@@ -33,11 +37,65 @@ const addDownloadDialog = ref(false)
|
|||||||
|
|
||||||
// 查询站点图标
|
// 查询站点图标
|
||||||
async function getSiteIcon() {
|
async function getSiteIcon() {
|
||||||
try {
|
if (!torrent?.value?.site || iconLoading.value) {
|
||||||
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
return
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
iconLoading.value = true
|
||||||
|
iconError.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`site/icon/${torrent.value.site}`)
|
||||||
|
if (response && response.data && response.data.icon) {
|
||||||
|
siteIcon.value = response.data.icon
|
||||||
|
} else {
|
||||||
|
iconError.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load site icon:', error)
|
||||||
|
iconError.value = true
|
||||||
|
} finally {
|
||||||
|
iconLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取站点颜色
|
||||||
|
function getSiteColor(siteId: string | number | undefined) {
|
||||||
|
if (!siteId) return '#3F51B5'
|
||||||
|
|
||||||
|
// 根据站点ID生成不同颜色
|
||||||
|
const colors = [
|
||||||
|
'#3F51B5',
|
||||||
|
'#673AB7',
|
||||||
|
'#9C27B0',
|
||||||
|
'#E91E63',
|
||||||
|
'#F44336',
|
||||||
|
'#FF5722',
|
||||||
|
'#FF9800',
|
||||||
|
'#FFC107',
|
||||||
|
'#4CAF50',
|
||||||
|
'#009688',
|
||||||
|
'#00BCD4',
|
||||||
|
'#03A9F4',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 简单哈希函数
|
||||||
|
let hash = 0
|
||||||
|
const idStr = String(siteId)
|
||||||
|
for (let i = 0; i < idStr.length; i++) {
|
||||||
|
hash = idStr.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return colors[Math.abs(hash) % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取优惠类型样式
|
||||||
|
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||||
|
if (!downloadVolumeFactor) return 'free-discount'
|
||||||
|
if (downloadVolumeFactor === 0) return 'free-discount'
|
||||||
|
else if (downloadVolumeFactor < 1) return 'percent-discount'
|
||||||
|
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
|
||||||
|
else return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 询问并添加下载
|
// 询问并添加下载
|
||||||
@@ -69,10 +127,11 @@ async function downloadTorrentFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 促销Chip类
|
// 促销Chip类
|
||||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
function getVolumeFactorClass(downloadVolume: number | undefined, uploadVolume: number | undefined) {
|
||||||
|
if (!downloadVolume) return 'text-white bg-gray-500'
|
||||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
else if (uploadVolume !== undefined && uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||||
else return 'text-white bg-gray-500'
|
else return 'text-white bg-gray-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,96 +142,88 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="list-item-wrapper">
|
||||||
<VListItem
|
<VListItem
|
||||||
|
:value="props.torrent?.torrent_info?.enclosure"
|
||||||
|
class="torrent-item"
|
||||||
|
:class="{ 'downloaded-item': downloaded.includes(torrent?.enclosure || '') }"
|
||||||
@click="handleAddDownload"
|
@click="handleAddDownload"
|
||||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
|
||||||
>
|
>
|
||||||
<template v-if="!showMoreTorrents" #prepend>
|
<template v-slot:prepend>
|
||||||
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
|
<div class="site-wrapper">
|
||||||
<VImg :src="siteIcon" />
|
<img v-if="siteIcon" :src="siteIcon" class="site-icon" />
|
||||||
</VAvatar>
|
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
|
||||||
|
<div class="site-name">{{ torrent?.site_name }}</div>
|
||||||
|
<span
|
||||||
|
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||||
|
class="free-tag"
|
||||||
|
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||||
|
>{{ torrent?.volume_factor }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
|
|
||||||
{{ torrent?.title }}
|
<VListItemTitle class="item-content">
|
||||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
<div class="item-header">
|
||||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
<div class="media-info">
|
||||||
|
<span class="media-title">{{ media?.title ?? meta?.name }}</span>
|
||||||
|
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="torrent-title" :title="torrent?.title">
|
||||||
|
{{ torrent?.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="torrent-description" :title="meta?.subtitle || torrent?.description || '暂无描述'">
|
||||||
|
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tags-container">
|
||||||
|
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
|
||||||
|
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
|
||||||
|
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
|
||||||
|
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
|
||||||
|
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
|
||||||
|
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
|
||||||
|
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
|
||||||
|
</div>
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<VListItemSubtitle> 【{{ torrent?.site_name }}】{{ torrent?.description }} </VListItemSubtitle>
|
|
||||||
<div v-if="torrent?.labels" class="pt-2">
|
<template v-slot:append>
|
||||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
<div class="item-actions">
|
||||||
H&R
|
<div class="torrent-stats">
|
||||||
</VChip>
|
<span v-if="torrent?.seeders" class="seed-info">
|
||||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
|
||||||
{{ torrent?.freedate_diff }}
|
</span>
|
||||||
</VChip>
|
<span v-if="torrent?.peers" class="peer-info">
|
||||||
<VChip
|
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
|
||||||
v-for="(label, index) in torrent?.labels"
|
</span>
|
||||||
:key="index"
|
</div>
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
<div class="action-buttons">
|
||||||
color="primary"
|
<div v-if="torrent?.size" class="size-badge">
|
||||||
class="me-1 mb-1"
|
{{ formatFileSize(torrent.size) }}
|
||||||
>
|
</div>
|
||||||
{{ label }}
|
|
||||||
</VChip>
|
<VBtn
|
||||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
density="comfortable"
|
||||||
{{ meta?.edition }}
|
variant="text"
|
||||||
</VChip>
|
color="primary"
|
||||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
icon="mdi-information-outline"
|
||||||
{{ meta?.resource_pix }}
|
size="small"
|
||||||
</VChip>
|
class="detail-btn"
|
||||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
@click.stop="openTorrentDetail"
|
||||||
{{ meta?.video_encode }}
|
></VBtn>
|
||||||
</VChip>
|
</div>
|
||||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
|
||||||
{{ formatFileSize(torrent?.size) }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
|
||||||
{{ meta?.resource_team }}
|
|
||||||
</VChip>
|
|
||||||
<VChip
|
|
||||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
|
||||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1"
|
|
||||||
>
|
|
||||||
{{ torrent?.volume_factor }}
|
|
||||||
</VChip>
|
|
||||||
</div>
|
|
||||||
<template #append>
|
|
||||||
<div class="me-n3">
|
|
||||||
<IconBtn>
|
|
||||||
<VIcon icon="mdi-dots-vertical" />
|
|
||||||
<VMenu activator="parent" close-on-content-click>
|
|
||||||
<VList>
|
|
||||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon icon="mdi-information" />
|
|
||||||
</template>
|
|
||||||
<VListItemTitle>查看详情</VListItemTitle>
|
|
||||||
</VListItem>
|
|
||||||
<VListItem
|
|
||||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
|
||||||
variant="plain"
|
|
||||||
@click="downloadTorrentFile()"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon icon="mdi-download" />
|
|
||||||
</template>
|
|
||||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
</VMenu>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<AddDownloadDialog
|
<AddDownloadDialog
|
||||||
v-if="addDownloadDialog"
|
v-if="addDownloadDialog"
|
||||||
v-model="addDownloadDialog"
|
v-model="addDownloadDialog"
|
||||||
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
|
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
|
||||||
:media="media"
|
:media="media"
|
||||||
:torrent="torrent"
|
:torrent="torrent"
|
||||||
@done="addDownloadSuccess"
|
@done="addDownloadSuccess"
|
||||||
@@ -181,3 +232,292 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-item-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item {
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-item:hover {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 50px;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-fallback {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.85);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-discount {
|
||||||
|
background-color: #4caf50;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent-discount {
|
||||||
|
background-color: #ff5722;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-bonus {
|
||||||
|
background-color: #9c27b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-tag {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-info {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-badge {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.65);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tag {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition {
|
||||||
|
background-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolution {
|
||||||
|
background-color: #e91e63;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codec {
|
||||||
|
background-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team {
|
||||||
|
background-color: #03a9f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire {
|
||||||
|
background-color: #9c27b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
background-color: #3f51b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr {
|
||||||
|
background-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-btn {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloaded-item {
|
||||||
|
border-left: 4px solid #4caf50;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-words {
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-visible {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-break-spaces {
|
||||||
|
white-space: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.torrent-item {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-icon,
|
||||||
|
.site-fallback {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-wrapper {
|
||||||
|
width: 40px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-description {
|
||||||
|
max-width: calc(100vw - 150px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ onMounted(() => {
|
|||||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem>
|
<VListItem v-if="torrent?.description">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-subtitles-outline"></VIcon>
|
<VIcon icon="mdi-subtitles-outline"></VIcon>
|
||||||
</template>
|
</template>
|
||||||
@@ -146,7 +146,7 @@ onMounted(() => {
|
|||||||
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
|
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem>
|
<VListItem v-if="torrent?.size">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-database"></VIcon>
|
<VIcon icon="mdi-database"></VIcon>
|
||||||
</template>
|
</template>
|
||||||
@@ -164,7 +164,7 @@ onMounted(() => {
|
|||||||
<VSelect
|
<VSelect
|
||||||
v-model="selectedDownloader"
|
v-model="selectedDownloader"
|
||||||
:items="downloaderOptions"
|
:items="downloaderOptions"
|
||||||
label="指定下载器"
|
label="下载器(默认)"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
placeholder="留空默认"
|
placeholder="留空默认"
|
||||||
/>
|
/>
|
||||||
@@ -173,7 +173,7 @@ onMounted(() => {
|
|||||||
<VCombobox
|
<VCombobox
|
||||||
v-model="selectedDirectory"
|
v-model="selectedDirectory"
|
||||||
:items="targetDirectories"
|
:items="targetDirectories"
|
||||||
label="指定保存目录"
|
label="保存目录(自动)"
|
||||||
placeholder="留空自动匹配"
|
placeholder="留空自动匹配"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import QrcodeVue from 'qrcode.vue'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
// 定义输入
|
// 定义输入
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
conf: {
|
conf: {
|
||||||
type: Object as PropType<{ [key: string]: any }>,
|
type: Object as PropType<{ [key: string]: any }>,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -14,13 +13,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['done', 'close'])
|
const emit = defineEmits(['done', 'close'])
|
||||||
|
|
||||||
// 二维码内容
|
// 二维码内容
|
||||||
const qrCodeContent = ref('')
|
const qrCodeUrl = ref('')
|
||||||
|
|
||||||
// ck参数
|
|
||||||
const ck = ref('')
|
|
||||||
|
|
||||||
// t参数
|
|
||||||
const t = ref('')
|
|
||||||
|
|
||||||
// 下方的提示信息
|
// 下方的提示信息
|
||||||
const text = ref('请用阿里云盘 App 扫码')
|
const text = ref('请用阿里云盘 App 扫码')
|
||||||
@@ -34,9 +27,6 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
|||||||
// 完成
|
// 完成
|
||||||
async function handleDone() {
|
async function handleDone() {
|
||||||
clearTimeout(timeoutTimer)
|
clearTimeout(timeoutTimer)
|
||||||
if (props.conf?.refreshToken) {
|
|
||||||
await savaAliPanConfig()
|
|
||||||
}
|
|
||||||
emit('done')
|
emit('done')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +35,8 @@ async function getQrcode() {
|
|||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
|
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
qrCodeContent.value = result.data.codeContent
|
qrCodeUrl.value = result.data.codeUrl
|
||||||
ck.value = result.data.ck
|
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||||
t.value = result.data.t
|
|
||||||
} else {
|
} else {
|
||||||
text.value = result.message
|
text.value = result.message
|
||||||
}
|
}
|
||||||
@@ -59,23 +48,21 @@ async function getQrcode() {
|
|||||||
// 调用/aliyun/check api验证二维码
|
// 调用/aliyun/check api验证二维码
|
||||||
async function checkQrcode() {
|
async function checkQrcode() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('/storage/check/alipan', {
|
const result: { [key: string]: any } = await api.get('/storage/check/alipan')
|
||||||
params: { ck: ck.value, t: t.value },
|
|
||||||
})
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const qrCodeStatus = result.data.qrCodeStatus
|
const qrCodeStatus = result.data.status
|
||||||
text.value = result.data.tip
|
text.value = result.data.tip
|
||||||
if (qrCodeStatus == 'CONFIRMED') {
|
if (qrCodeStatus == 'LoginSuccess') {
|
||||||
// 已确认完成
|
// 登录成功
|
||||||
alertType.value = 'success'
|
alertType.value = 'success'
|
||||||
handleDone()
|
handleDone()
|
||||||
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
|
} else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {
|
||||||
|
// 等待登录扫码成功
|
||||||
alertType.value = 'info'
|
alertType.value = 'info'
|
||||||
// 新建、待扫码
|
|
||||||
clearTimeout(timeoutTimer)
|
clearTimeout(timeoutTimer)
|
||||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||||
} else {
|
} else {
|
||||||
// 过期或者已取消
|
// 二维码过期
|
||||||
alertType.value = 'error'
|
alertType.value = 'error'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -87,18 +74,8 @@ async function checkQrcode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存cookie设置
|
|
||||||
async function savaAliPanConfig() {
|
|
||||||
try {
|
|
||||||
await api.post(`storage/save/alipan`, props.conf)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await getQrcode()
|
await getQrcode()
|
||||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -112,19 +89,18 @@ onUnmounted(() => {
|
|||||||
<DialogCloseBtn @click="emit('close')" />
|
<DialogCloseBtn @click="emit('close')" />
|
||||||
<VCardText class="pt-2 flex flex-col items-center">
|
<VCardText class="pt-2 flex flex-col items-center">
|
||||||
<div class="my-6 shadow-lg rounded text-center p-3 border">
|
<div class="my-6 shadow-lg rounded text-center p-3 border">
|
||||||
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
|
<VImg class="mx-auto" :src="qrCodeUrl" width="200" height="200">
|
||||||
|
<template #placeholder>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VImg>
|
||||||
</div>
|
</div>
|
||||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||||
<template #prepend />
|
<template #prepend />
|
||||||
</VAlert>
|
</VAlert>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText>
|
|
||||||
<VRow>
|
|
||||||
<VCol class="mt-2">
|
|
||||||
<VTextField label="自定义refreshToken" v-model="props.conf.refreshToken" outlined dense />
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
</VCardText>
|
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable :close-on-back="false" eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard
|
<VCard
|
||||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
|
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
|
||||||
class="rounded-t"
|
class="rounded-t"
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const historyChartOptions = computed(() => {
|
|||||||
parentHeightOffset: 0,
|
parentHeightOffset: 0,
|
||||||
toolbar: { show: false },
|
toolbar: { show: false },
|
||||||
animations: { enabled: true },
|
animations: { enabled: true },
|
||||||
|
background: currentTheme.value.surface, // 新增背景色同步
|
||||||
|
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||||
dataLabels: {
|
dataLabels: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -61,6 +63,9 @@ const historyChartOptions = computed(() => {
|
|||||||
autoScaleYaxis: true,
|
autoScaleYaxis: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
theme: {
|
||||||
|
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
@@ -68,6 +73,10 @@ const historyChartOptions = computed(() => {
|
|||||||
format: 'dd MMM yyyy',
|
format: 'dd MMM yyyy',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
style: {
|
||||||
|
background: currentTheme.value.background, // 提示框背景色同步
|
||||||
|
color: currentTheme.value.onBackground, // 文字颜色同步
|
||||||
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
xaxis: {
|
xaxis: {
|
||||||
@@ -140,10 +149,15 @@ const seedingChartOptions = computed(() => {
|
|||||||
parentHeightOffset: 0,
|
parentHeightOffset: 0,
|
||||||
toolbar: { show: false },
|
toolbar: { show: false },
|
||||||
animations: { enabled: true },
|
animations: { enabled: true },
|
||||||
|
background: currentTheme.value.surface, // 新增背景色同步
|
||||||
|
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||||
zoom: {
|
zoom: {
|
||||||
autoScaleYaxis: true,
|
autoScaleYaxis: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
theme: {
|
||||||
|
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
x: {
|
x: {
|
||||||
@@ -151,6 +165,10 @@ const seedingChartOptions = computed(() => {
|
|||||||
return '数量:' + val.toLocaleString()
|
return '数量:' + val.toLocaleString()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
style: {
|
||||||
|
background: currentTheme.value.background, // 提示框背景色同步
|
||||||
|
color: currentTheme.value.onBackground, // 文字颜色同步
|
||||||
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
xaxis: {
|
xaxis: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import QrcodeVue from 'qrcode.vue'
|
import QrcodeVue from 'qrcode.vue'
|
||||||
import { VCardItem, VTextField } from 'vuetify/lib/components/index.mjs'
|
|
||||||
|
|
||||||
// 定义输入
|
// 定义输入
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -18,7 +17,7 @@ const emit = defineEmits(['done', 'close'])
|
|||||||
const qrCodeContent = ref('')
|
const qrCodeContent = ref('')
|
||||||
|
|
||||||
// 下方的提示信息
|
// 下方的提示信息
|
||||||
const text = ref('请使用微信或115客户端扫码,或在下方输入Cookie')
|
const text = ref('请使用微信或115客户端扫码')
|
||||||
|
|
||||||
// 提醒类型
|
// 提醒类型
|
||||||
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
||||||
@@ -29,9 +28,6 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
|||||||
// 完成
|
// 完成
|
||||||
async function handleDone() {
|
async function handleDone() {
|
||||||
clearTimeout(timeoutTimer)
|
clearTimeout(timeoutTimer)
|
||||||
if (props.conf?.cookie) {
|
|
||||||
await savaU115Config()
|
|
||||||
}
|
|
||||||
emit('done')
|
emit('done')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +37,7 @@ async function getQrcode() {
|
|||||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
|
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
qrCodeContent.value = result.data.codeContent
|
qrCodeContent.value = result.data.codeContent
|
||||||
|
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||||
} else {
|
} else {
|
||||||
text.value = result.message
|
text.value = result.message
|
||||||
}
|
}
|
||||||
@@ -84,18 +81,8 @@ async function checkQrcode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存cookie设置
|
|
||||||
async function savaU115Config() {
|
|
||||||
try {
|
|
||||||
await api.post(`storage/save/u115`, props.conf)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await getQrcode()
|
await getQrcode()
|
||||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -115,13 +102,6 @@ onUnmounted(() => {
|
|||||||
<template #prepend />
|
<template #prepend />
|
||||||
</VAlert>
|
</VAlert>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText>
|
|
||||||
<VRow>
|
|
||||||
<VCol class="mt-2">
|
|
||||||
<VTextField label="自定义Cookie" v-model="props.conf.cookie" outlined dense />
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
</VCardText>
|
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable :close-on-back="false" eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard
|
<VCard
|
||||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
|
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
|
||||||
class="rounded-t"
|
class="rounded-t"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async function editWorkflow() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable :close-on-back="false" eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard :title="`${title}任务`" class="rounded-t">
|
<VCard :title="`${title}任务`" class="rounded-t">
|
||||||
<DialogCloseBtn @click="emit('close')" />
|
<DialogCloseBtn @click="emit('close')" />
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ onMounted(() => {
|
|||||||
placeholder="搜索 ..."
|
placeholder="搜索 ..."
|
||||||
prepend-inner-icon="mdi-filter-outline"
|
prepend-inner-icon="mdi-filter-outline"
|
||||||
class="me-2"
|
class="me-2"
|
||||||
rounded="0"
|
rounded
|
||||||
/>
|
/>
|
||||||
<VSpacer v-if="isFile" />
|
<VSpacer v-if="isFile" />
|
||||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ const sortIcon = computed(() => {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
<VDialog v-if="newFolderPopper" v-model="newFolderPopper" max-width="50rem">
|
<VDialog v-model="newFolderPopper" max-width="50rem">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props">
|
<IconBtn v-bind="props">
|
||||||
<VTooltip text="新建文件夹">
|
<VTooltip text="新建文件夹">
|
||||||
|
|||||||
@@ -49,9 +49,28 @@ const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) =
|
|||||||
model[value] = newValue
|
model[value] = newValue
|
||||||
}
|
}
|
||||||
} else if (key.startsWith('on')) {
|
} else if (key.startsWith('on')) {
|
||||||
// 处理事件监听,值是函数的代码
|
// 处理事件监听,值是函数的代码 function xxx(e) { ... }
|
||||||
const eventName = key.replace('on', '').toLowerCase()
|
if (typeof value === 'string') {
|
||||||
parsedProps[eventName] = new Function('model', `with(model) { return ${value} }`)(model)
|
// 创建动态函数并绑定model上下文
|
||||||
|
const handler = new Function(
|
||||||
|
'model',
|
||||||
|
'event',
|
||||||
|
`
|
||||||
|
try {
|
||||||
|
with(model) {
|
||||||
|
return (${value})(event);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('事件处理函数执行错误:', e);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
// 包装事件处理器,保持vue事件参数传递特性
|
||||||
|
parsedProps[key] = (...args: any[]) => {
|
||||||
|
const [event] = args
|
||||||
|
return handler(model, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果是表达式,需要绑定
|
// 如果是表达式,需要绑定
|
||||||
if (typeof value === 'string' && isExpression(value)) {
|
if (typeof value === 'string' && isExpression(value)) {
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ onMounted(() => {
|
|||||||
<VDialog
|
<VDialog
|
||||||
v-if="messageDialog"
|
v-if="messageDialog"
|
||||||
v-model="messageDialog"
|
v-model="messageDialog"
|
||||||
max-width="60rem"
|
max-width="45rem"
|
||||||
scrollable
|
scrollable
|
||||||
:fullscreen="!display.mdAndUp.value"
|
:fullscreen="!display.mdAndUp.value"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const sites = route.query?.sites?.toString() ?? ''
|
|||||||
// 视图类型,从localStorage中读取
|
// 视图类型,从localStorage中读取
|
||||||
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
|
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
|
||||||
|
|
||||||
|
// 视图切换中
|
||||||
|
const isViewChanging = ref(false)
|
||||||
|
|
||||||
// 数据列表
|
// 数据列表
|
||||||
const dataList = ref<Array<Context>>([])
|
const dataList = ref<Array<Context>>([])
|
||||||
|
|
||||||
@@ -61,25 +64,63 @@ const errorDescription = ref('未搜索到任何资源')
|
|||||||
// 使用SSE监听加载进度
|
// 使用SSE监听加载进度
|
||||||
function startLoadingProgress() {
|
function startLoadingProgress() {
|
||||||
progressText.value = '正在搜索,请稍候...'
|
progressText.value = '正在搜索,请稍候...'
|
||||||
|
progressValue.value = 10 // 初始进度设为10%,确保进度条显示
|
||||||
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/search`)
|
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/search`)
|
||||||
progressEventSource.value.onmessage = event => {
|
progressEventSource.value.onmessage = event => {
|
||||||
const progress = JSON.parse(event.data)
|
const progress = JSON.parse(event.data)
|
||||||
if (progress) {
|
if (progress) {
|
||||||
progressText.value = progress.text
|
progressText.value = progress.text
|
||||||
progressValue.value = progress.value
|
progressValue.value = progress.value
|
||||||
|
|
||||||
|
// 搜索完成条件调整:只有明确完成时才关闭
|
||||||
|
if (progress.text.includes('完成') && progress.value >= 99) {
|
||||||
|
setTimeout(() => {
|
||||||
|
stopLoadingProgress()
|
||||||
|
}, 1000) // 延迟1秒关闭,确保用户能看到100%
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加错误处理
|
||||||
|
progressEventSource.value.onerror = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
stopLoadingProgress()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加安全超时,确保不会永远卡住
|
||||||
|
setTimeout(() => {
|
||||||
|
if (progressEventSource.value && progressValue.value < 100) {
|
||||||
|
stopLoadingProgress()
|
||||||
|
}
|
||||||
|
}, 60000) // 60秒超时
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止监听加载进度
|
// 停止监听加载进度
|
||||||
function stopLoadingProgress() {
|
function stopLoadingProgress() {
|
||||||
if (progressEventSource.value) progressEventSource.value?.close()
|
if (progressEventSource.value) {
|
||||||
|
progressEventSource.value.close()
|
||||||
|
progressEventSource.value = undefined
|
||||||
|
}
|
||||||
|
// 确保进度显示100%,然后再渐进清零
|
||||||
|
progressValue.value = 100
|
||||||
|
setTimeout(() => {
|
||||||
|
progressValue.value = 0
|
||||||
|
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置视图类型
|
// 设置视图类型
|
||||||
function setViewType(type: string) {
|
function changeViewType(newType: string) {
|
||||||
localStorage.setItem('MPTorrentsViewType', type)
|
if (viewType.value !== newType) {
|
||||||
viewType.value = type
|
isViewChanging.value = true
|
||||||
|
viewType.value = newType
|
||||||
|
localStorage.setItem('MPTorrentsViewType', newType)
|
||||||
|
|
||||||
|
// 模拟视图切换的加载过程
|
||||||
|
setTimeout(() => {
|
||||||
|
isViewChanging.value = false
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取搜索列表数据
|
// 获取搜索列表数据
|
||||||
@@ -113,7 +154,7 @@ async function fetchData() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
dataList.value = result.data
|
dataList.value = result.data || []
|
||||||
} else if (result && result.message) {
|
} else if (result && result.message) {
|
||||||
errorDescription.value = result.message
|
errorDescription.value = result.message
|
||||||
}
|
}
|
||||||
@@ -125,6 +166,8 @@ async function fetchData() {
|
|||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
stopLoadingProgress()
|
||||||
|
isRefreshed.value = true
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,39 +184,403 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" :text="progressText" :progress="progressValue" />
|
<div>
|
||||||
<NoDataFound
|
<!-- 加载进度条 -->
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
<VFadeTransition>
|
||||||
:error-title="errorTitle"
|
<div v-if="progressValue > 0" class="search-progress-container">
|
||||||
:error-description="errorDescription"
|
<div class="search-progress-card">
|
||||||
/>
|
<div class="progress-header">
|
||||||
<div v-if="dataList.length > 0">
|
<VIcon icon="mdi-movie-search" color="primary" size="small" class="me-2" />
|
||||||
<TorrentRowListView v-if="viewType === 'list'" :items="dataList" />
|
<span class="progress-title">{{ progressText }}</span>
|
||||||
<TorrentCardListView v-else :items="dataList" />
|
</div>
|
||||||
</div>
|
<div class="progress-bar-container">
|
||||||
<!-- 视图切换 -->
|
<div class="progress-bar-wrapper">
|
||||||
<div v-if="isRefreshed">
|
<div class="progress-bar" :style="{ width: `${progressValue}%` }"></div>
|
||||||
<VFab
|
</div>
|
||||||
v-if="viewType === 'list'"
|
<div class="progress-percentage">{{ Math.ceil(progressValue) }}%</div>
|
||||||
icon="mdi-view-grid"
|
</div>
|
||||||
location="bottom"
|
</div>
|
||||||
size="x-large"
|
</div>
|
||||||
absolute
|
</VFadeTransition>
|
||||||
app
|
|
||||||
appear
|
<!-- 精简标题栏 -->
|
||||||
@click="setViewType('card')"
|
<div v-if="isRefreshed" class="search-header d-flex align-center mb-4">
|
||||||
:class="{ 'mb-12': appMode }"
|
<div class="search-info-container d-flex align-center flex-wrap">
|
||||||
/>
|
<div class="search-title text-primary">资源搜索结果</div>
|
||||||
<VFab
|
<div class="search-tags d-flex flex-wrap">
|
||||||
v-else
|
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
|
||||||
icon="mdi-view-list"
|
关键词: {{ keyword }}
|
||||||
location="bottom"
|
</VChip>
|
||||||
size="x-large"
|
<VChip v-if="title" class="search-tag" color="primary" size="small" variant="flat"> 标题: {{ title }} </VChip>
|
||||||
fixed
|
<VChip v-if="year" class="search-tag" color="primary" size="small" variant="flat"> 年份: {{ year }} </VChip>
|
||||||
app
|
<VChip v-if="season" class="search-tag" color="primary" size="small" variant="flat"> 季: {{ season }} </VChip>
|
||||||
appear
|
</div>
|
||||||
@click="setViewType('list')"
|
</div>
|
||||||
:class="{ 'mb-12': appMode }"
|
|
||||||
/>
|
<VSpacer />
|
||||||
|
|
||||||
|
<!-- 重新设计的视图切换按钮 -->
|
||||||
|
<div class="view-toggle-container">
|
||||||
|
<div class="view-toggle-buttons">
|
||||||
|
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
|
||||||
|
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
|
||||||
|
</button>
|
||||||
|
<button class="view-toggle-btn" :class="{ active: viewType === 'row' }" @click="changeViewType('row')">
|
||||||
|
<VIcon icon="mdi-view-list-outline" :color="viewType === 'row' ? 'primary' : undefined" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视图切换加载状态 -->
|
||||||
|
<VFadeTransition>
|
||||||
|
<div v-if="isRefreshed && isViewChanging" class="view-changing-container">
|
||||||
|
<div class="view-changing-content">
|
||||||
|
<div class="pulse-loader">
|
||||||
|
<div class="pulse-circle"></div>
|
||||||
|
<div class="pulse-circle"></div>
|
||||||
|
<div class="pulse-circle"></div>
|
||||||
|
</div>
|
||||||
|
<div class="view-changing-text">切换视图</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VFadeTransition>
|
||||||
|
|
||||||
|
<!-- 搜索结果 -->
|
||||||
|
<div v-if="isRefreshed && dataList.length > 0 && !isViewChanging" class="search-results-container">
|
||||||
|
<!-- 卡片视图模式 -->
|
||||||
|
<VFadeTransition>
|
||||||
|
<TorrentCardListView v-if="viewType === 'card'" :items="dataList" />
|
||||||
|
</VFadeTransition>
|
||||||
|
|
||||||
|
<!-- 列表视图模式 -->
|
||||||
|
<VFadeTransition>
|
||||||
|
<TorrentRowListView v-if="viewType === 'row'" :items="dataList" />
|
||||||
|
</VFadeTransition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无数据显示 -->
|
||||||
|
<div v-else-if="isRefreshed && !isViewChanging" class="d-flex flex-column align-center justify-center py-8">
|
||||||
|
<NoDataFound
|
||||||
|
:errorTitle="errorTitle"
|
||||||
|
:errorDescription="errorDescription"
|
||||||
|
/>
|
||||||
|
<VBtn class="mt-4" color="primary" prepend-icon="mdi-magnify" to="/"> 返回首页 </VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 初始加载状态 -->
|
||||||
|
<div v-else-if="!isRefreshed && !progressValue" class="initial-loading-container">
|
||||||
|
<div class="initial-loading-content">
|
||||||
|
<div class="wave-loader">
|
||||||
|
<div class="wave-dot"></div>
|
||||||
|
<div class="wave-dot"></div>
|
||||||
|
<div class="wave-dot"></div>
|
||||||
|
<div class="wave-dot"></div>
|
||||||
|
</div>
|
||||||
|
<div class="initial-loading-text">搜索中</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-progress-container {
|
||||||
|
position: fixed;
|
||||||
|
top: env(safe-area-inset-top);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-card {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
border: 1px solid rgba(var(--v-theme-primary), 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgb(var(--v-theme-primary)) 0%,
|
||||||
|
rgb(var(--v-theme-primary)) 70%,
|
||||||
|
rgba(var(--v-theme-primary), 0.8) 100%
|
||||||
|
);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-percentage {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 精简标题栏样式 */
|
||||||
|
.search-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info-container {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tags {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重新设计的视图切换按钮 */
|
||||||
|
.view-toggle-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-buttons {
|
||||||
|
display: flex;
|
||||||
|
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn.active {
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn:hover:not(.active) {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视图切换加载状态 */
|
||||||
|
.view-changing-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(var(--v-theme-background), 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-changing-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-loader {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-circle {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgb(var(--v-theme-primary));
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-circle:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-circle:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-changing-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 初始的加载状态 */
|
||||||
|
.initial-loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initial-loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-loader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgb(var(--v-theme-primary));
|
||||||
|
animation: wave 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-dot:nth-child(1) {
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-dot:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-dot:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-dot:nth-child(4) {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.initial-loading-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-container {
|
||||||
|
min-height: 50vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.search-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info-container {
|
||||||
|
flex: 1;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tags {
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
scrollbar-width: none;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tags::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-buttons {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ const theme: VuetifyOptions['theme'] = {
|
|||||||
'on-primary': '#FFFFFF',
|
'on-primary': '#FFFFFF',
|
||||||
'on-success': '#FFFFFF',
|
'on-success': '#FFFFFF',
|
||||||
'on-warning': '#FFFFFF',
|
'on-warning': '#FFFFFF',
|
||||||
'background': '#111827',
|
'background': '#0E1116',
|
||||||
'on-background': '#E7E3FC',
|
'on-background': '#E7E3FC',
|
||||||
'surface': '#161D2C',
|
'surface': '#14161F',
|
||||||
'on-surface': '#E7E3FC',
|
'on-surface': '#E7E3FC',
|
||||||
'grey-50': '#2A2E42',
|
'grey-50': '#2A2E42',
|
||||||
'grey-100': '#474360',
|
'grey-100': '#474360',
|
||||||
@@ -87,7 +87,7 @@ const theme: VuetifyOptions['theme'] = {
|
|||||||
},
|
},
|
||||||
variables: {
|
variables: {
|
||||||
'code-color': '#d400ff',
|
'code-color': '#d400ff',
|
||||||
'overlay-scrim-background': '#1F2937',
|
'overlay-scrim-background': '#191D21',
|
||||||
'overlay-scrim-opacity': 0.6,
|
'overlay-scrim-opacity': 0.6,
|
||||||
'hover-opacity': 0.04,
|
'hover-opacity': 0.04,
|
||||||
'focus-opacity': 0.1,
|
'focus-opacity': 0.1,
|
||||||
@@ -96,7 +96,7 @@ const theme: VuetifyOptions['theme'] = {
|
|||||||
'pressed-opacity': 0.14,
|
'pressed-opacity': 0.14,
|
||||||
'dragged-opacity': 0.1,
|
'dragged-opacity': 0.1,
|
||||||
'border-color': '#E7E3FC',
|
'border-color': '#E7E3FC',
|
||||||
'table-header-background': '#1F2937',
|
'table-header-background': '#14161F',
|
||||||
'custom-background': '#373452',
|
'custom-background': '#373452',
|
||||||
// Shadows
|
// Shadows
|
||||||
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
|
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const SystemNavMenus = [
|
|||||||
icon: 'mdi-state-machine',
|
icon: 'mdi-state-machine',
|
||||||
to: '/workflow',
|
to: '/workflow',
|
||||||
header: '订阅',
|
header: '订阅',
|
||||||
admin: false,
|
admin: true,
|
||||||
footer: false,
|
footer: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ const selectedSites = ref<number[]>([])
|
|||||||
// 搜索方式 title/imdbid
|
// 搜索方式 title/imdbid
|
||||||
const searchType = ref('title')
|
const searchType = ref('title')
|
||||||
|
|
||||||
|
// 全选/全不选按钮文字
|
||||||
|
const checkAllText = computed(() => (selectedSites.value.length === allSites.value.length ? '全不选' : '全选'))
|
||||||
|
|
||||||
|
// 全选/全不选
|
||||||
|
function checkAllSitesorNot() {
|
||||||
|
if (selectedSites.value.length === allSites.value.length) {
|
||||||
|
selectedSites.value = []
|
||||||
|
} else {
|
||||||
|
selectedSites.value = allSites.value.map(item => item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 查询所有站点
|
// 查询所有站点
|
||||||
async function querySites() {
|
async function querySites() {
|
||||||
try {
|
try {
|
||||||
@@ -586,6 +598,11 @@ onBeforeMount(() => {
|
|||||||
{{ site.name }}
|
{{ site.name }}
|
||||||
</VChip>
|
</VChip>
|
||||||
</VChipGroup>
|
</VChipGroup>
|
||||||
|
<div>
|
||||||
|
<VBtn size="small" variant="text" @click.stop="checkAllSitesorNot">
|
||||||
|
{{ checkAllText }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem>
|
<VListItem>
|
||||||
<VBtn @click="handleSearch" block>搜索</VBtn>
|
<VBtn @click="handleSearch" block>搜索</VBtn>
|
||||||
|
|||||||
@@ -538,6 +538,9 @@ onDeactivated(() => {
|
|||||||
<VListItem variant="plain" @click="addMediaServer('plex')">
|
<VListItem variant="plain" @click="addMediaServer('plex')">
|
||||||
<VListItemTitle>Plex</VListItemTitle>
|
<VListItemTitle>Plex</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
<VListItem variant="plain" @click="addMediaServer('trimemedia')">
|
||||||
|
<VListItemTitle>飞牛影视</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ onMounted(() => {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VTextarea
|
<VTextarea
|
||||||
v-model="customIdentifiers"
|
v-model="customIdentifiers"
|
||||||
auto-grow
|
|
||||||
placeholder="支持正则表达式,特殊字符需要\转义,一行为一组"
|
placeholder="支持正则表达式,特殊字符需要\转义,一行为一组"
|
||||||
hint="支持正则表达式,特殊字符需要\转义,一行为一组"
|
hint="支持正则表达式,特殊字符需要\转义,一行为一组"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
@@ -181,7 +180,6 @@ onMounted(() => {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VTextarea
|
<VTextarea
|
||||||
v-model="customReleaseGroups"
|
v-model="customReleaseGroups"
|
||||||
auto-grow
|
|
||||||
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
||||||
hint="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
hint="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
@@ -207,7 +205,6 @@ onMounted(() => {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VTextarea
|
<VTextarea
|
||||||
v-model="customization"
|
v-model="customization"
|
||||||
auto-grow
|
|
||||||
placeholder="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
|
placeholder="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
|
||||||
hint="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
|
hint="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
@@ -233,7 +230,6 @@ onMounted(() => {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VTextarea
|
<VTextarea
|
||||||
v-model="transferExcludeWords"
|
v-model="transferExcludeWords"
|
||||||
auto-grow
|
|
||||||
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
||||||
hint="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
hint="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,15 @@ const filterForm: Record<string, string[]> = reactive({
|
|||||||
resolution: [] as string[],
|
resolution: [] as string[],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 排序选项
|
||||||
|
const sortField = ref('default')
|
||||||
|
const sortTitles: Record<string, string> = {
|
||||||
|
default: '默认',
|
||||||
|
site: '站点',
|
||||||
|
size: '大小',
|
||||||
|
seeder: '做种数',
|
||||||
|
}
|
||||||
|
|
||||||
// 过滤项映射(保持中文标题)
|
// 过滤项映射(保持中文标题)
|
||||||
const filterTitles: Record<string, string> = {
|
const filterTitles: Record<string, string> = {
|
||||||
site: '站点',
|
site: '站点',
|
||||||
@@ -70,6 +79,17 @@ const displayDataList = ref<Array<SearchTorrent>>([])
|
|||||||
// 分组后的数据列表
|
// 分组后的数据列表
|
||||||
const groupedDataList = ref<Map<string, Context[]>>()
|
const groupedDataList = ref<Map<string, Context[]>>()
|
||||||
|
|
||||||
|
// 过滤菜单相关
|
||||||
|
const filterMenuOpen = ref(false)
|
||||||
|
const currentFilter = ref('site')
|
||||||
|
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
|
||||||
|
const currentFilterOptions = computed(() => {
|
||||||
|
if (currentFilter.value === 'season') {
|
||||||
|
return sortSeasonFilterOptions.value
|
||||||
|
}
|
||||||
|
return filterOptions[currentFilter.value]
|
||||||
|
})
|
||||||
|
|
||||||
// 初始化过滤选项
|
// 初始化过滤选项
|
||||||
function initOptions(data: Context) {
|
function initOptions(data: Context) {
|
||||||
const { torrent_info, meta_info } = data
|
const { torrent_info, meta_info } = data
|
||||||
@@ -226,8 +246,9 @@ onMounted(() => {
|
|||||||
groupedDataList.value = groupMap
|
groupedDataList.value = groupMap
|
||||||
})
|
})
|
||||||
|
|
||||||
// 只监听filterForm和groupedDataList的变化。因为displayDataList的变化不需要清空列表
|
// 修改watch监听,同时监听排序字段的变化
|
||||||
watch([filterForm, groupedDataList], filterData)
|
watch([filterForm, groupedDataList, sortField], filterData)
|
||||||
|
|
||||||
function filterData() {
|
function filterData() {
|
||||||
// 清空列表
|
// 清空列表
|
||||||
dataList = []
|
dataList = []
|
||||||
@@ -236,6 +257,9 @@ function filterData() {
|
|||||||
const match = (filter: Array<string>, value: string | undefined) =>
|
const match = (filter: Array<string>, value: string | undefined) =>
|
||||||
filter.length === 0 || (value && filter.includes(value))
|
filter.length === 0 || (value && filter.includes(value))
|
||||||
|
|
||||||
|
// 筛选数据
|
||||||
|
const filteredData: SearchTorrent[] = []
|
||||||
|
|
||||||
groupedDataList.value?.forEach(value => {
|
groupedDataList.value?.forEach(value => {
|
||||||
if (value.length > 0) {
|
if (value.length > 0) {
|
||||||
const matchData = value.filter(data => {
|
const matchData = value.filter(data => {
|
||||||
@@ -261,17 +285,115 @@ function filterData() {
|
|||||||
if (matchData.length > 0) {
|
if (matchData.length > 0) {
|
||||||
const firstData = cloneDeepWith(matchData[0]) as SearchTorrent
|
const firstData = cloneDeepWith(matchData[0]) as SearchTorrent
|
||||||
if (matchData.length > 1) firstData.more = matchData.slice(1)
|
if (matchData.length > 1) firstData.more = matchData.slice(1)
|
||||||
|
filteredData.push(firstData)
|
||||||
// 显示前20个,4行左右。
|
|
||||||
if (displayDataList.value.length < 20) {
|
|
||||||
displayDataList.value.push(firstData)
|
|
||||||
} else {
|
|
||||||
// 后续内容不显示,存在list里。loadMore的时候再加载。
|
|
||||||
dataList.push(firstData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 排序数据
|
||||||
|
if (sortField.value !== 'default') {
|
||||||
|
filteredData.sort((a, b) => {
|
||||||
|
if (sortField.value === 'site') {
|
||||||
|
// 按站点名称排序
|
||||||
|
return (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
|
||||||
|
} else if (sortField.value === 'size') {
|
||||||
|
// 按文件大小排序(降序)
|
||||||
|
return (Number(b.torrent_info.size) || 0) - (Number(a.torrent_info.size) || 0)
|
||||||
|
} else if (sortField.value === 'seeder') {
|
||||||
|
// 按做种数排序(降序)
|
||||||
|
return (Number(b.torrent_info.seeders) || 0) - (Number(a.torrent_info.seeders) || 0)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示前20个
|
||||||
|
displayDataList.value = filteredData.slice(0, 20)
|
||||||
|
// 保存剩余数据
|
||||||
|
dataList = filteredData.slice(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给定过滤类型返回不同图标
|
||||||
|
function getFilterIcon(key: string) {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
site: 'mdi-server-network',
|
||||||
|
season: 'mdi-television-classic',
|
||||||
|
freeState: 'mdi-gift-outline',
|
||||||
|
resolution: 'mdi-monitor-screenshot',
|
||||||
|
videoCode: 'mdi-video-vintage',
|
||||||
|
edition: 'mdi-quality-high',
|
||||||
|
releaseGroup: 'mdi-account-group-outline',
|
||||||
|
}
|
||||||
|
return icons[key] || 'mdi-filter-variant'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开关筛选菜单
|
||||||
|
function toggleFilterMenu(key: string) {
|
||||||
|
if (currentFilter.value === key && filterMenuOpen.value) {
|
||||||
|
filterMenuOpen.value = false
|
||||||
|
} else {
|
||||||
|
currentFilter.value = key
|
||||||
|
filterMenuOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换过滤器选项
|
||||||
|
function toggleFilter(key: string, value: string) {
|
||||||
|
const index = filterForm[key].indexOf(value)
|
||||||
|
if (index === -1) {
|
||||||
|
filterForm[key].push(value)
|
||||||
|
} else {
|
||||||
|
filterForm[key].splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有过滤条件
|
||||||
|
function clearAllFilters() {
|
||||||
|
for (const key in filterForm) {
|
||||||
|
filterForm[key] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除某个过滤项
|
||||||
|
function clearFilter(key: string) {
|
||||||
|
filterForm[key] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选某个过滤项
|
||||||
|
function selectAll(key: string) {
|
||||||
|
if (key === 'season') {
|
||||||
|
filterForm[key] = [...sortSeasonFilterOptions.value]
|
||||||
|
} else {
|
||||||
|
filterForm[key] = [...filterOptions[key]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算已选择的过滤条件数量
|
||||||
|
const getFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
for (const key in filterForm) {
|
||||||
|
count += filterForm[key].length
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算已选择的过滤条件
|
||||||
|
const getSelectedFilters = computed(() => {
|
||||||
|
const filters: Record<string, string[]> = {}
|
||||||
|
for (const key in filterForm) {
|
||||||
|
if (filterForm[key].length > 0) {
|
||||||
|
filters[key] = [...filterForm[key]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
})
|
||||||
|
|
||||||
|
// 移除单个过滤条件
|
||||||
|
function removeFilter(key: string, value: string) {
|
||||||
|
const index = filterForm[key].indexOf(value)
|
||||||
|
if (index !== -1) {
|
||||||
|
filterForm[key].splice(index, 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore({ done }: { done: any }) {
|
function loadMore({ done }: { done: any }) {
|
||||||
@@ -282,34 +404,191 @@ function loadMore({ done }: { done: any }) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard class="bg-transparent mb-3 pt-2 shadow-none">
|
<div class="search-header d-none d-sm-flex">
|
||||||
<VRow>
|
<!-- 页面头部和筛选栏 -->
|
||||||
<VCol v-for="(options, key) in filterOptionsNotEmpty" :key="key" cols="6" md="">
|
<div class="view-header bg-surface rounded-xl">
|
||||||
<VSelect
|
<div class="d-flex align-center flex-wrap pa-3">
|
||||||
v-if="key === 'season'"
|
<VChip color="primary" variant="elevated" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
|
||||||
v-model="filterForm[key]"
|
{{ props.items?.length || 0 }} 个资源
|
||||||
:items="sortSeasonFilterOptions"
|
</VChip>
|
||||||
|
|
||||||
|
<!-- 排序选择 -->
|
||||||
|
<div class="sort-container me-4">
|
||||||
|
<VSelect
|
||||||
|
v-model="sortField"
|
||||||
|
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
variant="plain"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="sort-select"
|
||||||
|
prepend-icon="mdi-sort"
|
||||||
|
></VSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选按钮组 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<VBtn
|
||||||
|
v-for="(title, key) in filterTitles"
|
||||||
|
v-show="filterOptions[key].length > 0"
|
||||||
|
:key="key"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||||
|
:prepend-icon="getFilterIcon(key)"
|
||||||
|
@click="toggleFilterMenu(key)"
|
||||||
|
class="filter-btn"
|
||||||
|
rounded="pill"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
<VChip v-if="filterForm[key].length > 0" size="x-small" color="primary" class="ms-1" variant="elevated">{{
|
||||||
|
filterForm[key].length
|
||||||
|
}}</VChip>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<!-- 清除全部筛选按钮 -->
|
||||||
|
<VBtn
|
||||||
|
v-if="getFilterCount > 0"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="clearAllFilters"
|
||||||
|
class="filter-btn"
|
||||||
|
prepend-icon="mdi-close-circle-outline"
|
||||||
|
rounded="pill"
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已选择的过滤项显示 -->
|
||||||
|
<div v-if="getFilterCount > 0" class="selected-filters pa-3 pt-0">
|
||||||
|
<div class="d-flex flex-wrap align-center">
|
||||||
|
<template v-for="(values, key) in getSelectedFilters" :key="key">
|
||||||
|
<VChip
|
||||||
|
v-for="(value, index) in values"
|
||||||
|
:key="`${key}-${index}`"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
closable
|
||||||
|
variant="elevated"
|
||||||
|
class="me-1 mt-2 filter-tag"
|
||||||
|
@click:close="removeFilter(key, value)"
|
||||||
|
>
|
||||||
|
<VIcon size="x-small" :icon="getFilterIcon(key)" class="me-1"></VIcon>
|
||||||
|
<strong>{{ filterTitles[key] }}:</strong> {{ value }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端头部和筛选区域 -->
|
||||||
|
<div class="d-block d-sm-none search-header-mobile">
|
||||||
|
<!-- 移动端头部 -->
|
||||||
|
<div class="view-header mb-2">
|
||||||
|
<div class="d-flex align-center flex-wrap pa-2">
|
||||||
|
<div class="d-flex align-center w-100 mb-1">
|
||||||
|
<VChip
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
size="x-small"
|
||||||
|
class="search-count me-auto"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
>
|
||||||
|
{{ props.items?.length || 0 }} 个资源
|
||||||
|
</VChip>
|
||||||
|
|
||||||
|
<!-- 排序选择 -->
|
||||||
|
<VSelect
|
||||||
|
v-model="sortField"
|
||||||
|
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="mobile-sort-select"
|
||||||
|
prepend-icon="mdi-sort"
|
||||||
|
></VSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选图标按钮区域 -->
|
||||||
|
<div class="filter-buttons-grid w-100">
|
||||||
|
<VBtn
|
||||||
|
v-for="(title, key) in filterTitles"
|
||||||
|
v-show="filterOptions[key].length > 0"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
class="filter-btn-mobile"
|
||||||
|
@click="toggleFilterMenu(key)"
|
||||||
|
>
|
||||||
|
<VIcon :icon="getFilterIcon(key)" class="filter-icon"></VIcon>
|
||||||
|
<span class="filter-label">
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
<VBadge
|
||||||
|
v-if="filterForm[key].length > 0"
|
||||||
|
:content="filterForm[key].length"
|
||||||
|
color="primary"
|
||||||
|
location="top end"
|
||||||
|
offset-x="-10"
|
||||||
|
offset-y="-10"
|
||||||
|
></VBadge>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选菜单 -->
|
||||||
|
<VDialog v-model="filterMenuOpen" max-width="400px" location="center">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="py-2 d-flex align-center">
|
||||||
|
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
|
||||||
|
<span>{{ currentFilterTitle }} 筛选</span>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
v-if="filterForm[currentFilter].length > 0"
|
||||||
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
density="compact"
|
color="error"
|
||||||
chips
|
@click="clearFilter(currentFilter)"
|
||||||
:label="filterTitles[key]"
|
>
|
||||||
multiple
|
清除
|
||||||
clearable
|
</VBtn>
|
||||||
/>
|
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)"> 全选 </VBtn>
|
||||||
<VSelect
|
</VCardTitle>
|
||||||
v-else
|
|
||||||
v-model="filterForm[key]"
|
<VDivider />
|
||||||
:items="options"
|
|
||||||
size="small"
|
<VCardText class="filter-menu-content pt-4">
|
||||||
density="compact"
|
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
|
||||||
chips
|
<VChip
|
||||||
:label="filterTitles[key]"
|
v-for="option in currentFilterOptions"
|
||||||
multiple
|
:key="option"
|
||||||
clearable
|
:value="option"
|
||||||
/>
|
filter
|
||||||
</VCol>
|
variant="elevated"
|
||||||
</VRow>
|
class="ma-1 filter-chip"
|
||||||
</VCard>
|
size="small"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</VChip>
|
||||||
|
</VChipGroup>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="elevated" color="primary" @click="filterMenuOpen = false"> 确定 </VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- 资源列表 -->
|
||||||
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-hidden" @load="loadMore">
|
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-hidden" @load="loadMore">
|
||||||
<template #loading />
|
<template #loading />
|
||||||
<template #empty />
|
<template #empty />
|
||||||
@@ -323,3 +602,170 @@ function loadMore({ done }: { done: any }) {
|
|||||||
</div>
|
</div>
|
||||||
</VInfiniteScroll>
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: rgba(var(--v-theme-background), 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-container {
|
||||||
|
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
min-width: 0;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 160px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select :deep(.v-field__input) {
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-filters {
|
||||||
|
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-menu-content {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
margin: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag {
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-count {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-torrent-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.sort-select {
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-container {
|
||||||
|
border-right: none;
|
||||||
|
padding-right: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sort-select {
|
||||||
|
min-width: 110px;
|
||||||
|
max-width: 130px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-buttons-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn-mobile {
|
||||||
|
height: auto;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background-color: rgba(var(--v-theme-surface), 1);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header-mobile {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: rgba(var(--v-theme-background), 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -204,6 +204,41 @@ const sortField = ref('default')
|
|||||||
// 数据列表
|
// 数据列表
|
||||||
const dataList = ref<Array<Context>>([])
|
const dataList = ref<Array<Context>>([])
|
||||||
|
|
||||||
|
// 计算已选择的过滤条件数量
|
||||||
|
const getFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
for (const key in filterForm) {
|
||||||
|
count += filterForm[key].length
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算已选择的过滤条件
|
||||||
|
const getSelectedFilters = computed(() => {
|
||||||
|
const filters: Record<string, string[]> = {}
|
||||||
|
for (const key in filterForm) {
|
||||||
|
if (filterForm[key].length > 0) {
|
||||||
|
filters[key] = [...filterForm[key]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
})
|
||||||
|
|
||||||
|
// 移除单个过滤条件
|
||||||
|
function removeFilter(key: string, value: string) {
|
||||||
|
const index = filterForm[key].indexOf(value)
|
||||||
|
if (index !== -1) {
|
||||||
|
filterForm[key].splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有过滤条件
|
||||||
|
function clearAllFilters() {
|
||||||
|
for (const key in filterForm) {
|
||||||
|
filterForm[key] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化过滤选项
|
// 初始化过滤选项
|
||||||
function initOptions(data: Context) {
|
function initOptions(data: Context) {
|
||||||
const { torrent_info, meta_info } = data
|
const { torrent_info, meta_info } = data
|
||||||
@@ -242,169 +277,473 @@ watchEffect(() => {
|
|||||||
const match = (filter: Array<string>, value: string | undefined) =>
|
const match = (filter: Array<string>, value: string | undefined) =>
|
||||||
filter.length === 0 || (value && filter.includes(value))
|
filter.length === 0 || (value && filter.includes(value))
|
||||||
|
|
||||||
props.items?.forEach(data => {
|
// 先收集所有过滤选项,再过滤数据
|
||||||
const { meta_info, torrent_info } = data
|
if (props.items?.length) {
|
||||||
if (
|
// 首先收集所有过滤选项
|
||||||
// 站点过滤
|
props.items.forEach(data => {
|
||||||
match(filterForm.site, torrent_info.site_name) &&
|
const { meta_info, torrent_info } = data
|
||||||
// 促销状态过滤
|
initOptions(data)
|
||||||
match(filterForm.freeState, torrent_info.volume_factor) &&
|
})
|
||||||
// 季过滤
|
|
||||||
match(filterForm.season, meta_info.season_episode) &&
|
// 然后根据过滤条件筛选数据
|
||||||
// 制作组过滤
|
props.items.forEach(data => {
|
||||||
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
const { meta_info, torrent_info } = data
|
||||||
// 视频编码过滤
|
if (
|
||||||
match(filterForm.videoCode, meta_info.video_encode) &&
|
// 站点过滤
|
||||||
// 分辨率过滤
|
match(filterForm.site, torrent_info.site_name) &&
|
||||||
match(filterForm.resolution, meta_info.resource_pix) &&
|
// 促销状态过滤
|
||||||
// 质量过滤
|
match(filterForm.freeState, torrent_info.volume_factor) &&
|
||||||
match(filterForm.edition, meta_info.edition)
|
// 季过滤
|
||||||
)
|
match(filterForm.season, meta_info.season_episode) &&
|
||||||
dataList.value.push(data)
|
// 制作组过滤
|
||||||
})
|
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
||||||
|
// 视频编码过滤
|
||||||
|
match(filterForm.videoCode, meta_info.video_encode) &&
|
||||||
|
// 分辨率过滤
|
||||||
|
match(filterForm.resolution, meta_info.resource_pix) &&
|
||||||
|
// 质量过滤
|
||||||
|
match(filterForm.edition, meta_info.edition)
|
||||||
|
) {
|
||||||
|
dataList.value.push(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 初始化过滤选项
|
// 切换过滤器选项
|
||||||
onMounted(() => {
|
function toggleFilter(key: string, value: string) {
|
||||||
props.items?.forEach(initOptions)
|
const index = filterForm[key].indexOf(value)
|
||||||
|
if (index === -1) {
|
||||||
|
filterForm[key].push(value)
|
||||||
|
} else {
|
||||||
|
filterForm[key].splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤菜单相关
|
||||||
|
const filterMenuOpen = ref(false)
|
||||||
|
const filterMenuAnchor = ref(null)
|
||||||
|
const currentFilter = ref('site')
|
||||||
|
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
|
||||||
|
const currentFilterOptions = computed(() => {
|
||||||
|
if (currentFilter.value === 'season') {
|
||||||
|
return sortSeasonFilterOptions.value
|
||||||
|
}
|
||||||
|
return filterOptions[currentFilter.value]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 打开过滤菜单
|
||||||
|
function openFilterMenu(key: string) {
|
||||||
|
currentFilter.value = key
|
||||||
|
filterMenuOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给定过滤类型返回不同图标
|
||||||
|
function getFilterIcon(key: string) {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
site: 'mdi-server-network',
|
||||||
|
season: 'mdi-television-classic',
|
||||||
|
freeState: 'mdi-gift-outline',
|
||||||
|
resolution: 'mdi-monitor-screenshot',
|
||||||
|
videoCode: 'mdi-video-vintage',
|
||||||
|
edition: 'mdi-quality-high',
|
||||||
|
releaseGroup: 'mdi-account-group-outline',
|
||||||
|
}
|
||||||
|
return icons[key] || 'mdi-filter-variant'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选某个过滤项
|
||||||
|
function selectAll(key: string) {
|
||||||
|
if (key === 'season') {
|
||||||
|
filterForm[key] = [...sortSeasonFilterOptions.value]
|
||||||
|
} else {
|
||||||
|
filterForm[key] = [...filterOptions[key]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除某个过滤项
|
||||||
|
function clearFilter(key: string) {
|
||||||
|
filterForm[key] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加toggleFilterMenu函数
|
||||||
|
function toggleFilterMenu(key: string) {
|
||||||
|
if (currentFilter.value === key && filterMenuOpen.value) {
|
||||||
|
filterMenuOpen.value = false
|
||||||
|
} else {
|
||||||
|
currentFilter.value = key
|
||||||
|
filterMenuOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="torrent-view">
|
||||||
<VRow>
|
<!-- 搜索头部容器 - 新增,用于固定在顶部 -->
|
||||||
<VCol>
|
<div class="search-header d-none d-sm-block">
|
||||||
<VList v-if="dataList.length === 0" lines="three" class="rounded p-0 shadow-lg">
|
<!-- PC端页面头部和筛选栏 -->
|
||||||
<VListItem>
|
<div class="view-header mb-3">
|
||||||
<VListItemTitle>没有符合当前过滤条件的资源。</VListItemTitle>
|
<div class="d-flex align-center flex-wrap">
|
||||||
</VListItem>
|
<VChip color="primary" variant="flat" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
|
||||||
</VList>
|
{{ dataList.length }} 个资源
|
||||||
<VList v-else lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
|
</VChip>
|
||||||
<VVirtualScroll :items="dataList" :style="listStyle">
|
|
||||||
<template #default="{ item }">
|
|
||||||
<TorrentItem :torrent="item" :key="item.torrent_info.page_url" />
|
|
||||||
</template>
|
|
||||||
</VVirtualScroll>
|
|
||||||
</VList>
|
|
||||||
</VCol>
|
|
||||||
<!-- 排序 & 过滤列表 -->
|
|
||||||
<VCol xl="2" md="3" v-if="display.mdAndUp.value">
|
|
||||||
<VList lines="one" class="rounded shadow-lg" :style="listStyle">
|
|
||||||
<FilterOption title="排序">
|
|
||||||
<VChipGroup column v-model="sortField">
|
|
||||||
<VChip
|
|
||||||
v-for="(title, key) in sortTitles"
|
|
||||||
:key="key"
|
|
||||||
:color="sortField === key ? 'primary' : ''"
|
|
||||||
filter
|
|
||||||
variant="outlined"
|
|
||||||
:value="key"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
</VChip>
|
|
||||||
</VChipGroup>
|
|
||||||
</FilterOption>
|
|
||||||
<!-- 过滤选项 -->
|
|
||||||
<FilterOption v-for="(options, key) in filterOptionsNotEmpty" :key="key" :title="filterTitles[key]">
|
|
||||||
<VChipGroup v-if="key === 'season'" v-model="filterForm[key]" column multiple>
|
|
||||||
<VChip
|
|
||||||
v-for="option in sortSeasonFilterOptions"
|
|
||||||
:key="option"
|
|
||||||
:color="filterForm[key].includes(option) ? 'primary' : ''"
|
|
||||||
filter
|
|
||||||
variant="outlined"
|
|
||||||
:value="option"
|
|
||||||
>
|
|
||||||
{{ option }}
|
|
||||||
</VChip>
|
|
||||||
</VChipGroup>
|
|
||||||
<VChipGroup v-else v-model="filterForm[key]" column multiple>
|
|
||||||
<VChip
|
|
||||||
v-for="option in options"
|
|
||||||
:key="option"
|
|
||||||
:color="filterForm[key].includes(option) ? 'primary' : ''"
|
|
||||||
filter
|
|
||||||
variant="outlined"
|
|
||||||
:value="option"
|
|
||||||
>
|
|
||||||
{{ option }}
|
|
||||||
</VChip>
|
|
||||||
</VChipGroup>
|
|
||||||
</FilterOption>
|
|
||||||
</VList>
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
|
|
||||||
<!-- 过滤弹窗 -->
|
<div class="filter-bar">
|
||||||
<VDialog v-model="filterDialog" max-width="40rem">
|
<!-- 排序选择 -->
|
||||||
<VCard title="排序 & 过滤" class="rounded-t">
|
<VSelect
|
||||||
<DialogCloseBtn v-model="filterDialog" />
|
v-model="sortField"
|
||||||
<VDivider />
|
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
||||||
<VList lines="one">
|
item-title="title"
|
||||||
<FilterOption title="排序">
|
item-value="value"
|
||||||
<VChipGroup column v-model="sortField">
|
variant="plain"
|
||||||
<VChip
|
density="compact"
|
||||||
v-for="(title, key) in sortTitles"
|
hide-details
|
||||||
:key="key"
|
class="sort-select"
|
||||||
:color="sortField === key ? 'primary' : ''"
|
>
|
||||||
filter
|
<template v-slot:prepend>
|
||||||
variant="outlined"
|
<VIcon size="small" icon="mdi-sort"></VIcon>
|
||||||
:value="key"
|
</template>
|
||||||
>
|
</VSelect>
|
||||||
|
|
||||||
|
<div class="filter-divider"></div>
|
||||||
|
|
||||||
|
<!-- 筛选按钮 -->
|
||||||
|
<VBtn
|
||||||
|
v-for="(title, key) in filterTitles"
|
||||||
|
v-show="filterOptions[key].length > 0"
|
||||||
|
:key="key"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||||
|
:prepend-icon="getFilterIcon(key)"
|
||||||
|
@click="toggleFilterMenu(key)"
|
||||||
|
class="filter-btn"
|
||||||
|
rounded="pill"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
<VChip v-if="filterForm[key].length > 0" size="x-small" color="primary" class="ms-1" variant="elevated">{{
|
||||||
|
filterForm[key].length
|
||||||
|
}}</VChip>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<!-- 清除全部筛选按钮 -->
|
||||||
|
<VBtn
|
||||||
|
v-if="getFilterCount > 0"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="clearAllFilters"
|
||||||
|
class="filter-btn"
|
||||||
|
prepend-icon="mdi-close-circle-outline"
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已选择的过滤项显示 -->
|
||||||
|
<div v-if="getFilterCount > 0" class="selected-filters mb-3">
|
||||||
|
<div class="d-flex flex-wrap align-center">
|
||||||
|
<template v-for="(values, key) in getSelectedFilters" :key="key">
|
||||||
|
<VChip
|
||||||
|
v-for="(value, index) in values"
|
||||||
|
:key="`${key}-${index}`"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
closable
|
||||||
|
variant="elevated"
|
||||||
|
class="me-1 mb-1 mt-1 filter-tag"
|
||||||
|
@click:close="removeFilter(key, value)"
|
||||||
|
>
|
||||||
|
<VIcon size="x-small" :icon="getFilterIcon(key)" class="me-1"></VIcon>
|
||||||
|
<strong>{{ filterTitles[key] }}:</strong> {{ value }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端头部和筛选区域 -->
|
||||||
|
<div class="d-block d-sm-none search-header-mobile">
|
||||||
|
<!-- 移动端头部 -->
|
||||||
|
<div class="view-header mb-2">
|
||||||
|
<div class="d-flex align-center flex-wrap pa-2">
|
||||||
|
<div class="d-flex align-center w-100 mb-1">
|
||||||
|
<VChip
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
size="x-small"
|
||||||
|
class="search-count me-auto"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
>
|
||||||
|
{{ props.items?.length || 0 }} 个资源
|
||||||
|
</VChip>
|
||||||
|
|
||||||
|
<!-- 排序选择 -->
|
||||||
|
<VSelect
|
||||||
|
v-model="sortField"
|
||||||
|
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="mobile-sort-select"
|
||||||
|
prepend-icon="mdi-sort"
|
||||||
|
></VSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选图标按钮区域 -->
|
||||||
|
<div class="filter-buttons-grid w-100">
|
||||||
|
<VBtn
|
||||||
|
v-for="(title, key) in filterTitles"
|
||||||
|
v-show="filterOptions[key].length > 0"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
class="filter-btn-mobile"
|
||||||
|
@click="toggleFilterMenu(key)"
|
||||||
|
>
|
||||||
|
<VIcon :icon="getFilterIcon(key)" class="filter-icon"></VIcon>
|
||||||
|
<span class="filter-label">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</VChip>
|
</span>
|
||||||
</VChipGroup>
|
<VBadge
|
||||||
</FilterOption>
|
v-if="filterForm[key].length > 0"
|
||||||
<!-- 过滤选项 -->
|
:content="filterForm[key].length"
|
||||||
<FilterOption
|
color="primary"
|
||||||
v-for="(options, key) in filterOptionsNotEmpty"
|
location="top end"
|
||||||
v-show="options.length > 0"
|
offset-x="-10"
|
||||||
:key="key"
|
offset-y="-10"
|
||||||
:title="filterTitles[key]"
|
></VBadge>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选菜单 -->
|
||||||
|
<VDialog v-model="filterMenuOpen" max-width="400px" location="center">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="py-2 d-flex align-center">
|
||||||
|
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
|
||||||
|
<span>{{ currentFilterTitle }} 筛选</span>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
v-if="filterForm[currentFilter].length > 0"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="clearFilter(currentFilter)"
|
||||||
>
|
>
|
||||||
<VChipGroup v-if="key === 'season'" v-model="filterForm[key]" column multiple>
|
清除
|
||||||
<VChip
|
</VBtn>
|
||||||
v-for="option in sortSeasonFilterOptions"
|
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)"> 全选 </VBtn>
|
||||||
:key="option"
|
</VCardTitle>
|
||||||
:color="filterForm[key].includes(option) ? 'primary' : ''"
|
<VDivider />
|
||||||
filter
|
<VCardText class="filter-menu-content pt-4">
|
||||||
variant="outlined"
|
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
|
||||||
:value="option"
|
<VChip
|
||||||
>
|
v-for="option in currentFilterOptions"
|
||||||
{{ option }}
|
:key="option"
|
||||||
</VChip>
|
:value="option"
|
||||||
</VChipGroup>
|
filter
|
||||||
<VChipGroup v-else v-model="filterForm[key]" column multiple>
|
variant="elevated"
|
||||||
<VChip
|
class="ma-1 filter-chip"
|
||||||
v-for="option in options"
|
size="small"
|
||||||
:key="option"
|
>
|
||||||
:color="filterForm[key].includes(option) ? 'primary' : ''"
|
{{ option }}
|
||||||
filter
|
</VChip>
|
||||||
variant="outlined"
|
</VChipGroup>
|
||||||
:value="option"
|
</VCardText>
|
||||||
>
|
<VCardActions>
|
||||||
{{ option }}
|
<VSpacer />
|
||||||
</VChip>
|
<VBtn variant="elevated" color="primary" @click="filterMenuOpen = false"> 确定 </VBtn>
|
||||||
</VChipGroup>
|
</VCardActions>
|
||||||
</FilterOption>
|
|
||||||
</VList>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|
||||||
<!-- 底部操作按钮 -->
|
<!-- 资源列表容器 -->
|
||||||
<div v-if="props.items">
|
<div class="resource-list-container">
|
||||||
<VFab
|
<!-- 无结果时显示 -->
|
||||||
v-if="!display.mdAndUp.value"
|
<div v-if="dataList.length === 0" class="no-results">
|
||||||
icon="mdi-filter"
|
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||||
color="info"
|
<div class="text-h6 text-grey mt-4">暂无符合条件的资源</div>
|
||||||
location="bottom"
|
</div>
|
||||||
:class="appMode ? 'mb-28' : 'mb-16'"
|
|
||||||
size="x-large"
|
<!-- 资源列表 -->
|
||||||
fixed
|
<div v-else class="resource-list">
|
||||||
app
|
<div v-for="(item, index) in dataList" :key="`${item.torrent_info?.enclosure || ''}-${index}`">
|
||||||
appear
|
<TorrentItem :torrent="item" />
|
||||||
@click="filterDialog = true"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.torrent-view {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: rgba(var(--v-theme-background), 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header-mobile {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: rgba(var(--v-theme-background), 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-count {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
min-width: 0;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-menu-content {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
margin: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag {
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-filters {
|
||||||
|
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list-container {
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sort-select {
|
||||||
|
min-width: 110px;
|
||||||
|
max-width: 130px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-buttons-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn-mobile {
|
||||||
|
height: auto;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background-color: rgba(var(--v-theme-surface), 1);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.filter-btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ watch(
|
|||||||
</VRow>
|
</VRow>
|
||||||
|
|
||||||
<!-- 双重验证弹窗 -->
|
<!-- 双重验证弹窗 -->
|
||||||
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" persistent scrollable>
|
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" scrollable>
|
||||||
<!-- 开启双重验证弹窗内容 -->
|
<!-- 开启双重验证弹窗内容 -->
|
||||||
<VCard>
|
<VCard>
|
||||||
<DialogCloseBtn @click="otpDialog = false" />
|
<DialogCloseBtn @click="otpDialog = false" />
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ export default defineConfig({
|
|||||||
'purpose': 'maskable',
|
'purpose': 'maskable',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'theme_color': '#28243D',
|
'theme_color': '#0E1116',
|
||||||
'background_color': '#28243D',
|
'background_color': '#0E1116',
|
||||||
'shortcuts': [
|
'shortcuts': [
|
||||||
{
|
{
|
||||||
'name': '推荐',
|
'name': '推荐',
|
||||||
|
|||||||
Reference in New Issue
Block a user