Compare commits

...

34 Commits

Author SHA1 Message Date
jxxghp
59b5e4a330 更新 package.json 2025-03-30 01:16:20 +08:00
jxxghp
f8f7275438 Merge pull request #315 from madrays/v2 2025-03-30 00:55:25 +08:00
madrays
6eec2e97f9 Merge branch 'v2' of https://github.com/madrays/MoviePilot-Frontend into v2 2025-03-30 00:47:22 +08:00
madrays
9020494f65 改进搜索体验:优化搜索对话框、进度条及无数据提示 2025-03-30 00:46:12 +08:00
madrays
43fbc7abd7 改进搜索体验:优化搜索对话框、进度条及无数据提示 2025-03-30 00:37:48 +08:00
jxxghp
d65a4b747d Merge pull request #314 from madrays/v2 2025-03-29 22:35:55 +08:00
madrays
849fad8a8a 优化移动端界面:修复列表视图筛选栏固定问题,优化头部布局减少空间占用 2025-03-29 22:31:46 +08:00
jxxghp
f0b2d14502 fix: 更新 TorrentCard 组件以支持多个站点图标的加载和显示 2025-03-29 20:50:55 +08:00
jxxghp
fa169fb785 fix: 优化初始加载状态的条件判断 2025-03-29 20:07:48 +08:00
jxxghp
49acf7fba3 fix: 重置加载进度值并调整搜索进度卡的内边距 2025-03-29 20:05:38 +08:00
jxxghp
80d55dae8d 更新 resource.vue 2025-03-29 19:41:55 +08:00
jxxghp
76aa5407a2 更新 TorrentRowListView.vue 2025-03-29 19:03:30 +08:00
jxxghp
d70789934f 更新 TorrentCardListView.vue 2025-03-29 19:03:03 +08:00
jxxghp
398e8b6afc feat: 优化过滤器按钮显示逻辑,支持动态显示和已选择过滤项 2025-03-29 18:28:56 +08:00
jxxghp
593fede47c feat: 更新主题颜色和背景色 2025-03-29 17:04:49 +08:00
jxxghp
40c7e9c126 chore: 更新版本号至 2.3.6 2025-03-29 15:42:20 +08:00
jxxghp
54e2f70ee0 feat: 优化 TorrentCard 组件标题显示,支持多行文本截断 2025-03-29 08:27:03 +08:00
jxxghp
81f85b9e46 feat: 优化搜索结果界面UI,感谢 @madrays 2025-03-29 08:11:13 +08:00
jxxghp
60a5476e59 Merge pull request #313 from cddjr/trimemedia
初步支持飞牛影视
2025-03-28 19:28:02 +08:00
景大侠
4271b63530 初步支持飞牛影视 2025-03-28 15:51:55 +08:00
jxxghp
8aca17f0c6 fix: 更新 AliyunAuthDialog 以使用二维码 URL 并调整状态处理逻辑 2025-03-28 13:39:36 +08:00
jxxghp
4f238dc1a3 fix: 移除 AliyunAuthDialog 和 U115AuthDialog 中的 refreshToken 相关代码 2025-03-25 12:57:42 +08:00
jxxghp
d4777fde70 fix: 移除新建文件夹对话框的 v-if 条件 2025-03-24 13:32:30 +08:00
jxxghp
b6c823c386 chore: 更新版本号至2.3.5 2025-03-23 16:39:56 +08:00
jxxghp
b7488214fc feat: 添加全选/全不选功能及按钮文本更新 2025-03-23 12:28:30 +08:00
jxxghp
06b6c3f3cb fix: 调整对话框最大宽度至45rem 2025-03-22 14:11:27 +08:00
jxxghp
abfaf926c4 fix #305 2025-03-22 10:03:11 +08:00
jxxghp
6eabeb09c9 fix #293 2025-03-22 09:53:21 +08:00
jxxghp
a15afabfa7 fix #310 2025-03-22 09:27:27 +08:00
jxxghp
30276d5022 fixhttps://github.com/jxxghp/MoviePilot/issues/4002 2025-03-22 08:08:50 +08:00
jxxghp
683ddc3fce fix: 更新二维码获取逻辑,修复定时器设置位置并优化提示信息 2025-03-21 20:32:05 +08:00
jxxghp
f00f79279b Merge pull request #309 from Aqr-K/fix/settings 2025-03-21 09:55:11 +08:00
Aqr-K
7989965b1a fix: VTextarea no longer displays all rows 2025-03-16 22:19:52 +08:00
jxxghp
5b84ce307b fix: 移除对话框的 persistent 属性 2025-03-15 11:52:16 +08:00
34 changed files with 3483 additions and 729 deletions

View File

@@ -30,7 +30,7 @@
<meta name="referrer" content="never" />
<meta name="msapplication-TileColor" content="#7D34FD" />
<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="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.3.4",
"version": "2.3.7",
"private": true,
"bin": "dist/service.js",
"scripts": {

View File

@@ -209,7 +209,7 @@ onMounted(() => {
</VList>
</VMenu>
<!-- 自定义 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="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" />
<VDivider />

View File

@@ -3,26 +3,31 @@ export const storageOptions = [
title: '本地',
value: 'local',
icon: 'mdi-folder-multiple-outline',
remote: false,
},
{
title: '阿里云盘',
value: 'alipan',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: '115网盘',
value: 'u115',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'RClone',
value: 'rclone',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'AList',
value: 'alist',
icon: 'mdi-cloud-outline',
remote: true,
},
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -7,25 +7,62 @@ interface Props {
errorCode?: string
errorTitle?: string
errorDescription?: string
size?: number
}
</script>
<template>
<VEmptyState :image="image" size="250">
<template #title>
<div class="mt-8 text-2xl">
{{ props.errorTitle }}
</div>
</template>
<div class="no-data-container">
<VEmptyState :image="image" :size="props.size || 'auto'">
<template #title>
<div class="mt-8 text-2xl">
{{ props.errorTitle }}
</div>
</template>
<template #text>
<div class="text-subtitle mt-3">
{{ props.errorDescription }}
</div>
</template>
<template #text>
<div class="text-subtitle mt-3">
{{ props.errorDescription }}
</div>
</template>
<template #actions>
<slot name="button" />
</template>
</VEmptyState>
<template #actions>
<slot name="button" />
</template>
</VEmptyState>
</div>
</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>

View File

@@ -29,6 +29,11 @@ const typeItems = [
{ title: '电视剧', value: '电视剧' },
]
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
const resourceStorageOptions = computed(() => {
return storageOptions.filter(item => !item.remote || props.directory.monitor_type !== 'downloader')
})
// 自动整理方式下拉字典
const transferSourceItems = [
{ title: '不整理', value: '' },
@@ -131,7 +136,7 @@ const getCategories = computed(() => {
return default_value.concat(props.categories[props.directory.media_type ?? ''])
})
// 监听 下载储存与媒体库储存 变化,重新加载整理方式下拉字典
// 监听 资源存储与媒体库储存 变化,重新加载整理方式下拉字典
watch(
[() => props.directory.library_storage, () => props.directory.storage],
([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>
<template>
@@ -198,8 +213,8 @@ watch(
<VSelect
v-model="props.directory.storage"
variant="underlined"
:items="storageOptions"
label="下载存储/源存储"
:items="resourceStorageOptions"
label="源存储"
/>
</VCol>
<VCol cols="8">
@@ -207,7 +222,7 @@ watch(
v-model="props.directory.download_path"
:storage="props.directory.storage"
variant="underlined"
label="下载目录/源目录"
label="源目录"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">

View File

@@ -3,6 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
import trimemedia from '@images/logos/trimemedia.png'
// 输入参数
const props = defineProps({
@@ -38,6 +39,7 @@ function getDefaultImage() {
if (props.media?.server === 'plex') return plex
else if (props.media?.server === 'emby') return emby
else if (props.media?.server === 'jellyfin') return jellyfin
else if (props.media?.server === 'trimemedia') return trimemedia
else return plex
}

View File

@@ -84,6 +84,18 @@ const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
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() {
try {
@@ -573,6 +585,11 @@ function onRemoveSubscribe() {
{{ site.name }}
</VChip>
</VChipGroup>
<div>
<VBtn size="small" variant="text" @click.stop="checkAllSitesorNot">
{{ checkAllText }}
</VBtn>
</div>
</VListItem>
<VListItem>
<VBtn @click="handleSearch" block>搜索</VBtn>

View File

@@ -4,6 +4,7 @@ import { useToast } from 'vue-toast-notification'
import emby_image from '@images/logos/emby.png'
import jellyfin_image from '@images/logos/jellyfin.png'
import plex_image from '@images/logos/plex.png'
import trimemedia_image from '@images/logos/trimemedia.png'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
@@ -101,6 +102,8 @@ const getIcon = computed(() => {
return emby_image
case 'jellyfin':
return jellyfin_image
case 'trimemedia':
return trimemedia_image
default:
return plex_image
}
@@ -278,6 +281,53 @@ onMounted(() => {
/>
</VCol>
</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'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -30,7 +30,7 @@ const meta = ref(props.torrent?.meta_info)
const downloadItem = ref(props.torrent)
// 站点图标
const siteIcon = ref('')
const siteIcons = ref<Record<number, 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 {
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) {
console.error(error)
}
@@ -78,141 +79,183 @@ async function downloadTorrentFile() {
window.open(torrent.value?.enclosure, '_blank')
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
// 获取优惠类型样式
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 ''
}
// 打开更多来源对话框
async function openMoreTorrentsDialog() {
props.more?.forEach(t => {
return getSiteIcon(t.torrent_info?.site)
})
showMoreTorrents.value = true
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
getSiteIcon(props.torrent?.torrent_info?.site)
})
</script>
<template>
<div>
<VCard
:width="props.width"
:height="props.height"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
:width="props.width || '100%'"
height="300px"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
@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">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<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>
<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>
</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
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
class="discount-banner"
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
>
{{ torrent?.volume_factor }}
</div>
<!-- 媒体标题 -->
<div class="card-header">
<div class="media-title-wrapper">
<h3 class="media-title">
{{ media?.title ?? meta?.name }}
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
</h3>
</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>
<!-- 更多来源对话框 - 改为独立对话框 -->
<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
v-if="addDownloadDialog"
v-model="addDownloadDialog"
@@ -227,3 +270,380 @@ onMounted(() => {
/>
</div>
</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>

View File

@@ -25,6 +25,10 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 站点图标加载状态
const iconLoading = ref(false)
const iconError = ref(false)
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
@@ -33,11 +37,65 @@ const addDownloadDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
} catch (error) {
console.error(error)
if (!torrent?.value?.site || iconLoading.value) {
return
}
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类
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'
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'
}
@@ -83,96 +142,88 @@ onMounted(() => {
</script>
<template>
<div>
<div class="list-item-wrapper">
<VListItem
:value="props.torrent?.torrent_info?.enclosure"
class="torrent-item"
:class="{ 'downloaded-item': downloaded.includes(torrent?.enclosure || '') }"
@click="handleAddDownload"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
>
<template v-if="!showMoreTorrents" #prepend>
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
<VImg :src="siteIcon" />
</VAvatar>
<template v-slot:prepend>
<div class="site-wrapper">
<img v-if="siteIcon" :src="siteIcon" class="site-icon" />
<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>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
<VListItemTitle class="item-content">
<div class="item-header">
<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>
<VListItemSubtitle> {{ torrent?.site_name }}{{ torrent?.description }} </VListItemSubtitle>
<div v-if="torrent?.labels" class="pt-2">
<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>
</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>
<template v-slot:append>
<div class="item-actions">
<div class="torrent-stats">
<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 class="action-buttons">
<div v-if="torrent?.size" class="size-badge">
{{ formatFileSize(torrent.size) }}
</div>
<VBtn
density="comfortable"
variant="text"
color="primary"
icon="mdi-information-outline"
size="small"
class="detail-btn"
@click.stop="openTorrentDetail"
></VBtn>
</div>
</div>
</template>
</VListItem>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
:media="media"
:torrent="torrent"
@done="addDownloadSuccess"
@@ -181,3 +232,292 @@ onMounted(() => {
/>
</div>
</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>

View File

@@ -138,7 +138,7 @@ onMounted(() => {
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItem v-if="torrent?.description">
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
@@ -146,7 +146,7 @@ onMounted(() => {
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItem v-if="torrent?.size">
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
@@ -164,7 +164,7 @@ onMounted(() => {
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
label="指定下载器"
label="下载器(默认)"
variant="underlined"
placeholder="留空默认"
/>
@@ -173,7 +173,7 @@ onMounted(() => {
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="指定保存目录"
label="保存目录(自动)"
placeholder="留空自动匹配"
variant="underlined"
/>

View File

@@ -1,9 +1,8 @@
<script lang="ts" setup>
import QrcodeVue from 'qrcode.vue'
import api from '@/api'
// 定义输入
const props = defineProps({
defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
@@ -14,13 +13,7 @@ const props = defineProps({
const emit = defineEmits(['done', 'close'])
// 二维码内容
const qrCodeContent = ref('')
// ck参数
const ck = ref('')
// t参数
const t = ref('')
const qrCodeUrl = ref('')
// 下方的提示信息
const text = ref('请用阿里云盘 App 扫码')
@@ -34,9 +27,6 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.refreshToken) {
await savaAliPanConfig()
}
emit('done')
}
@@ -45,9 +35,8 @@ async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
ck.value = result.data.ck
t.value = result.data.t
qrCodeUrl.value = result.data.codeUrl
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
text.value = result.message
}
@@ -59,23 +48,21 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/check/alipan', {
params: { ck: ck.value, t: t.value },
})
const result: { [key: string]: any } = await api.get('/storage/check/alipan')
if (result.success && result.data) {
const qrCodeStatus = result.data.qrCodeStatus
const qrCodeStatus = result.data.status
text.value = result.data.tip
if (qrCodeStatus == 'CONFIRMED') {
// 已确认完成
if (qrCodeStatus == 'LoginSuccess') {
// 登录成功
alertType.value = 'success'
handleDone()
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
} else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {
// 等待登录扫码成功
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
// 过期或者已取消
// 二维码过期
alertType.value = 'error'
}
} 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 () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
})
onUnmounted(() => {
@@ -112,19 +89,18 @@ onUnmounted(() => {
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<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>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />
</VAlert>
</VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义refreshToken" v-model="props.conf.refreshToken" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>

View File

@@ -166,7 +166,7 @@ onMounted(async () => {
</script>
<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
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
class="rounded-t"

View File

@@ -54,6 +54,8 @@ const historyChartOptions = computed(() => {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
background: currentTheme.value.surface, // 新增背景色同步
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
dataLabels: {
enabled: true,
},
@@ -61,6 +63,9 @@ const historyChartOptions = computed(() => {
autoScaleYaxis: true,
},
},
theme: {
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
},
tooltip: {
enabled: true,
tooltip: {
@@ -68,6 +73,10 @@ const historyChartOptions = computed(() => {
format: 'dd MMM yyyy',
},
},
style: {
background: currentTheme.value.background, // 提示框背景色同步
color: currentTheme.value.onBackground, // 文字颜色同步
},
},
grid: {
xaxis: {
@@ -140,10 +149,15 @@ const seedingChartOptions = computed(() => {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
background: currentTheme.value.surface, // 新增背景色同步
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
zoom: {
autoScaleYaxis: true,
},
},
theme: {
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
},
tooltip: {
enabled: true,
x: {
@@ -151,6 +165,10 @@ const seedingChartOptions = computed(() => {
return '数量:' + val.toLocaleString()
},
},
style: {
background: currentTheme.value.background, // 提示框背景色同步
color: currentTheme.value.onBackground, // 文字颜色同步
},
},
grid: {
xaxis: {

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import QrcodeVue from 'qrcode.vue'
import { VCardItem, VTextField } from 'vuetify/lib/components/index.mjs'
// 定义输入
const props = defineProps({
@@ -18,7 +17,7 @@ const emit = defineEmits(['done', 'close'])
const qrCodeContent = ref('')
// 下方的提示信息
const text = ref('请使用微信或115客户端扫码或在下方输入Cookie')
const text = ref('请使用微信或115客户端扫码')
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
@@ -29,9 +28,6 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.cookie) {
await savaU115Config()
}
emit('done')
}
@@ -41,6 +37,7 @@ async function getQrcode() {
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
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 () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
})
onUnmounted(() => {
@@ -115,13 +102,6 @@ onUnmounted(() => {
<template #prepend />
</VAlert>
</VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义Cookie" v-model="props.conf.cookie" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>

View File

@@ -267,7 +267,7 @@ onMounted(() => {
</script>
<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
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
class="rounded-t"

View File

@@ -79,7 +79,7 @@ async function editWorkflow() {
</script>
<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">
<DialogCloseBtn @click="emit('close')" />
<VDivider />

View File

@@ -551,7 +551,7 @@ onMounted(() => {
placeholder="搜索 ..."
prepend-inner-icon="mdi-filter-outline"
class="me-2"
rounded="0"
rounded
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="!isFile" @click="changeSelectMode">

View File

@@ -169,7 +169,7 @@ const sortIcon = computed(() => {
</IconBtn>
</template>
</VTooltip>
<VDialog v-if="newFolderPopper" v-model="newFolderPopper" max-width="50rem">
<VDialog v-model="newFolderPopper" max-width="50rem">
<template #activator="{ props }">
<IconBtn v-bind="props">
<VTooltip text="新建文件夹">

View File

@@ -49,9 +49,28 @@ const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) =
model[value] = newValue
}
} else if (key.startsWith('on')) {
// 处理事件监听,值是函数的代码
const eventName = key.replace('on', '').toLowerCase()
parsedProps[eventName] = new Function('model', `with(model) { return ${value} }`)(model)
// 处理事件监听,值是函数的代码 function xxx(e) { ... }
if (typeof value === 'string') {
// 创建动态函数并绑定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 {
// 如果是表达式,需要绑定
if (typeof value === 'string' && isExpression(value)) {

View File

@@ -262,7 +262,7 @@ onMounted(() => {
<VDialog
v-if="messageDialog"
v-model="messageDialog"
max-width="60rem"
max-width="45rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>

View File

@@ -37,6 +37,9 @@ const sites = route.query?.sites?.toString() ?? ''
// 视图类型从localStorage中读取
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
// 视图切换中
const isViewChanging = ref(false)
// 数据列表
const dataList = ref<Array<Context>>([])
@@ -61,25 +64,63 @@ const errorDescription = ref('未搜索到任何资源')
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '正在搜索,请稍候...'
progressValue.value = 10 // 初始进度设为10%,确保进度条显示
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/search`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
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() {
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) {
localStorage.setItem('MPTorrentsViewType', type)
viewType.value = type
function changeViewType(newType: string) {
if (viewType.value !== newType) {
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) {
dataList.value = result.data
dataList.value = result.data || []
} else if (result && result.message) {
errorDescription.value = result.message
}
@@ -125,6 +166,8 @@ async function fetchData() {
isRefreshed.value = true
} catch (error) {
console.error(error)
stopLoadingProgress()
isRefreshed.value = true
return Promise.reject(error)
}
}
@@ -141,39 +184,403 @@ onUnmounted(() => {
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" :text="progressText" :progress="progressValue" />
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
:error-title="errorTitle"
:error-description="errorDescription"
/>
<div v-if="dataList.length > 0">
<TorrentRowListView v-if="viewType === 'list'" :items="dataList" />
<TorrentCardListView v-else :items="dataList" />
</div>
<!-- 视图切换 -->
<div v-if="isRefreshed">
<VFab
v-if="viewType === 'list'"
icon="mdi-view-grid"
location="bottom"
size="x-large"
absolute
app
appear
@click="setViewType('card')"
:class="{ 'mb-12': appMode }"
/>
<VFab
v-else
icon="mdi-view-list"
location="bottom"
size="x-large"
fixed
app
appear
@click="setViewType('list')"
:class="{ 'mb-12': appMode }"
/>
<div>
<!-- 加载进度条 -->
<VFadeTransition>
<div v-if="progressValue > 0" class="search-progress-container">
<div class="search-progress-card">
<div class="progress-header">
<VIcon icon="mdi-movie-search" color="primary" size="small" class="me-2" />
<span class="progress-title">{{ progressText }}</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar-wrapper">
<div class="progress-bar" :style="{ width: `${progressValue}%` }"></div>
</div>
<div class="progress-percentage">{{ Math.ceil(progressValue) }}%</div>
</div>
</div>
</div>
</VFadeTransition>
<!-- 精简标题栏 -->
<div v-if="isRefreshed" class="search-header d-flex align-center mb-4">
<div class="search-info-container d-flex align-center flex-wrap">
<div class="search-title text-primary">资源搜索结果</div>
<div class="search-tags d-flex flex-wrap">
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
关键词: {{ keyword }}
</VChip>
<VChip v-if="title" class="search-tag" color="primary" size="small" variant="flat"> 标题: {{ title }} </VChip>
<VChip v-if="year" class="search-tag" color="primary" size="small" variant="flat"> 年份: {{ year }} </VChip>
<VChip v-if="season" class="search-tag" color="primary" size="small" variant="flat"> : {{ season }} </VChip>
</div>
</div>
<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>
</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>

View File

@@ -67,9 +67,9 @@ const theme: VuetifyOptions['theme'] = {
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#111827',
'background': '#0E1116',
'on-background': '#E7E3FC',
'surface': '#161D2C',
'surface': '#14161F',
'on-surface': '#E7E3FC',
'grey-50': '#2A2E42',
'grey-100': '#474360',
@@ -87,7 +87,7 @@ const theme: VuetifyOptions['theme'] = {
},
variables: {
'code-color': '#d400ff',
'overlay-scrim-background': '#1F2937',
'overlay-scrim-background': '#191D21',
'overlay-scrim-opacity': 0.6,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
@@ -96,7 +96,7 @@ const theme: VuetifyOptions['theme'] = {
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#E7E3FC',
'table-header-background': '#1F2937',
'table-header-background': '#14161F',
'custom-background': '#373452',
// Shadows
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',

View File

@@ -56,7 +56,7 @@ export const SystemNavMenus = [
icon: 'mdi-state-machine',
to: '/workflow',
header: '订阅',
admin: false,
admin: true,
footer: false,
},
{

View File

@@ -68,6 +68,18 @@ const selectedSites = ref<number[]>([])
// 搜索方式 title/imdbid
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() {
try {
@@ -586,6 +598,11 @@ onBeforeMount(() => {
{{ site.name }}
</VChip>
</VChipGroup>
<div>
<VBtn size="small" variant="text" @click.stop="checkAllSitesorNot">
{{ checkAllText }}
</VBtn>
</div>
</VListItem>
<VListItem>
<VBtn @click="handleSearch" block>搜索</VBtn>

View File

@@ -538,6 +538,9 @@ onDeactivated(() => {
<VListItem variant="plain" @click="addMediaServer('plex')">
<VListItemTitle>Plex</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="addMediaServer('trimemedia')">
<VListItemTitle>飞牛影视</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>

View File

@@ -140,7 +140,6 @@ onMounted(() => {
<VCardText>
<VTextarea
v-model="customIdentifiers"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行为一组"
hint="支持正则表达式,特殊字符需要\转义,一行为一组"
persistent-hint
@@ -181,7 +180,6 @@ onMounted(() => {
<VCardText>
<VTextarea
v-model="customReleaseGroups"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
hint="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
persistent-hint
@@ -207,7 +205,6 @@ onMounted(() => {
<VCardText>
<VTextarea
v-model="customization"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
hint="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
persistent-hint
@@ -233,7 +230,6 @@ onMounted(() => {
<VCardText>
<VTextarea
v-model="transferExcludeWords"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
hint="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
persistent-hint

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,15 @@ const filterForm: Record<string, string[]> = reactive({
resolution: [] as string[],
})
// 排序选项
const sortField = ref('default')
const sortTitles: Record<string, string> = {
default: '默认',
site: '站点',
size: '大小',
seeder: '做种数',
}
// 过滤项映射(保持中文标题)
const filterTitles: Record<string, string> = {
site: '站点',
@@ -70,6 +79,17 @@ const displayDataList = ref<Array<SearchTorrent>>([])
// 分组后的数据列表
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) {
const { torrent_info, meta_info } = data
@@ -226,8 +246,9 @@ onMounted(() => {
groupedDataList.value = groupMap
})
// 只监听filterForm和groupedDataList的变化。因为displayDataList的变化不需要清空列表
watch([filterForm, groupedDataList], filterData)
// 修改watch监听同时监听排序字段的变化
watch([filterForm, groupedDataList, sortField], filterData)
function filterData() {
// 清空列表
dataList = []
@@ -236,6 +257,9 @@ function filterData() {
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
// 筛选数据
const filteredData: SearchTorrent[] = []
groupedDataList.value?.forEach(value => {
if (value.length > 0) {
const matchData = value.filter(data => {
@@ -261,17 +285,115 @@ function filterData() {
if (matchData.length > 0) {
const firstData = cloneDeepWith(matchData[0]) as SearchTorrent
if (matchData.length > 1) firstData.more = matchData.slice(1)
// 显示前20个4行左右。
if (displayDataList.value.length < 20) {
displayDataList.value.push(firstData)
} else {
// 后续内容不显示存在list里。loadMore的时候再加载。
dataList.push(firstData)
}
filteredData.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 }) {
@@ -282,34 +404,191 @@ function loadMore({ done }: { done: any }) {
</script>
<template>
<VCard class="bg-transparent mb-3 pt-2 shadow-none">
<VRow>
<VCol v-for="(options, key) in filterOptionsNotEmpty" :key="key" cols="6" md="">
<VSelect
v-if="key === 'season'"
v-model="filterForm[key]"
:items="sortSeasonFilterOptions"
<div class="search-header d-none d-sm-flex">
<!-- 页面头部和筛选栏 -->
<div class="view-header bg-surface rounded-xl">
<div class="d-flex align-center flex-wrap pa-3">
<VChip color="primary" variant="elevated" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
{{ props.items?.length || 0 }} 个资源
</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"
density="compact"
chips
:label="filterTitles[key]"
multiple
clearable
/>
<VSelect
v-else
v-model="filterForm[key]"
:items="options"
size="small"
density="compact"
chips
:label="filterTitles[key]"
multiple
clearable
/>
</VCol>
</VRow>
</VCard>
color="error"
@click="clearFilter(currentFilter)"
>
清除
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)"> 全选 </VBtn>
</VCardTitle>
<VDivider />
<VCardText class="filter-menu-content pt-4">
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
<VChip
v-for="option in currentFilterOptions"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
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">
<template #loading />
<template #empty />
@@ -323,3 +602,170 @@ function loadMore({ done }: { done: any }) {
</div>
</VInfiniteScroll>
</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>

View File

@@ -204,6 +204,41 @@ const sortField = ref('default')
// 数据列表
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) {
const { torrent_info, meta_info } = data
@@ -242,169 +277,473 @@ watchEffect(() => {
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
props.items?.forEach(data => {
const { meta_info, torrent_info } = data
if (
// 站点过滤
match(filterForm.site, torrent_info.site_name) &&
// 促销状态过滤
match(filterForm.freeState, torrent_info.volume_factor) &&
// 季过滤
match(filterForm.season, meta_info.season_episode) &&
// 制作组过滤
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)
})
// 先收集所有过滤选项,再过滤数据
if (props.items?.length) {
// 首先收集所有过滤选项
props.items.forEach(data => {
const { meta_info, torrent_info } = data
initOptions(data)
})
// 然后根据过滤条件筛选数据
props.items.forEach(data => {
const { meta_info, torrent_info } = data
if (
// 站点过滤
match(filterForm.site, torrent_info.site_name) &&
// 促销状态过滤
match(filterForm.freeState, torrent_info.volume_factor) &&
// 季过滤
match(filterForm.season, meta_info.season_episode) &&
// 制作组过滤
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(() => {
props.items?.forEach(initOptions)
// 切换过滤选项
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)
}
}
// 过滤菜单相关
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>
<template>
<div>
<VRow>
<VCol>
<VList v-if="dataList.length === 0" lines="three" class="rounded p-0 shadow-lg">
<VListItem>
<VListItemTitle>没有符合当前过滤条件的资源</VListItemTitle>
</VListItem>
</VList>
<VList v-else lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
<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="torrent-view">
<!-- 搜索头部容器 - 新增用于固定在顶部 -->
<div class="search-header d-none d-sm-block">
<!-- PC端页面头部和筛选栏 -->
<div class="view-header mb-3">
<div class="d-flex align-center flex-wrap">
<VChip color="primary" variant="flat" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
{{ dataList.length }} 个资源
</VChip>
<!-- 过滤弹窗 -->
<VDialog v-model="filterDialog" max-width="40rem">
<VCard title="排序 & 过滤" class="rounded-t">
<DialogCloseBtn v-model="filterDialog" />
<VDivider />
<VList lines="one">
<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"
>
<div class="filter-bar">
<!-- 排序选择 -->
<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"
>
<template v-slot:prepend>
<VIcon size="small" icon="mdi-sort"></VIcon>
</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 }}
</VChip>
</VChipGroup>
</FilterOption>
<!-- 过滤选项 -->
<FilterOption
v-for="(options, key) in filterOptionsNotEmpty"
v-show="options.length > 0"
:key="key"
:title="filterTitles[key]"
</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"
color="error"
@click="clearFilter(currentFilter)"
>
<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>
清除
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)"> 全选 </VBtn>
</VCardTitle>
<VDivider />
<VCardText class="filter-menu-content pt-4">
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
<VChip
v-for="option in currentFilterOptions"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" color="primary" @click="filterMenuOpen = false"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 底部操作按钮 -->
<div v-if="props.items">
<VFab
v-if="!display.mdAndUp.value"
icon="mdi-filter"
color="info"
location="bottom"
:class="appMode ? 'mb-28' : 'mb-16'"
size="x-large"
fixed
app
appear
@click="filterDialog = true"
/>
<!-- 资源列表容器 -->
<div class="resource-list-container">
<!-- 无结果时显示 -->
<div v-if="dataList.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">暂无符合条件的资源</div>
</div>
<!-- 资源列表 -->
<div v-else class="resource-list">
<div v-for="(item, index) in dataList" :key="`${item.torrent_info?.enclosure || ''}-${index}`">
<TorrentItem :torrent="item" />
</div>
</div>
</div>
</div>
</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>

View File

@@ -400,7 +400,7 @@ watch(
</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>
<DialogCloseBtn @click="otpDialog = false" />

View File

@@ -96,8 +96,8 @@ export default defineConfig({
'purpose': 'maskable',
},
],
'theme_color': '#28243D',
'background_color': '#28243D',
'theme_color': '#0E1116',
'background_color': '#0E1116',
'shortcuts': [
{
'name': '推荐',