mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
Merge pull request #427 from PKC278/v2
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
"prebuild": "npm run build:icons",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5050",
|
"preview": "vite preview --port 5050",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
@@ -71,6 +72,9 @@
|
|||||||
"webfontloader": "^1.6.28"
|
"webfontloader": "^1.6.28"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@iconify-json/line-md": "^1.2.13",
|
||||||
|
"@iconify-json/lucide": "^1.2.85",
|
||||||
|
"@iconify-json/material-symbols": "^1.2.51",
|
||||||
"@iconify-json/mdi": "^1.1.52",
|
"@iconify-json/mdi": "^1.1.52",
|
||||||
"@iconify/tools": "^4.0.4",
|
"@iconify/tools": "^4.0.4",
|
||||||
"@iconify/vue": "^4.3.0",
|
"@iconify/vue": "^4.3.0",
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ const sources: BundleScriptConfig = {
|
|||||||
// 'mdi:logout',
|
// 'mdi:logout',
|
||||||
// 'octicon:book-24',
|
// 'octicon:book-24',
|
||||||
// 'octicon:code-square-24',
|
// 'octicon:code-square-24',
|
||||||
|
'lucide:sparkles',
|
||||||
|
'material-symbols:passkey',
|
||||||
|
'line-md:loading-twotone-loop',
|
||||||
],
|
],
|
||||||
|
|
||||||
json: [
|
json: [
|
||||||
@@ -154,7 +157,13 @@ const target = join(__dirname, 'icons-bundle.js');
|
|||||||
// Sort icons by prefix
|
// Sort icons by prefix
|
||||||
const organizedList = organizeIconsList(sources.icons)
|
const organizedList = organizeIconsList(sources.icons)
|
||||||
for (const prefix in organizedList) {
|
for (const prefix in organizedList) {
|
||||||
const filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
let filename
|
||||||
|
try {
|
||||||
|
filename = require.resolve(`@iconify-json/${prefix}/icons.json`)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
sourcesJSON.push({
|
sourcesJSON.push({
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
+5
-1
@@ -192,7 +192,11 @@ async function removeLoadingWithStateCheck() {
|
|||||||
|
|
||||||
// 并行加载关键资源
|
// 并行加载关键资源
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
globalSettingsStore.initialize().then(() => {
|
globalSettingsStore.initialize().then(async () => {
|
||||||
|
// 如果已登录,加载用户相关设置
|
||||||
|
if (isLogin.value) {
|
||||||
|
await globalSettingsStore.loadUserSettings()
|
||||||
|
}
|
||||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||||
}),
|
}),
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ onMounted(() => {
|
|||||||
<!-- 媒体标题 -->
|
<!-- 媒体标题 -->
|
||||||
<VCardItem class="pt-3 pb-0">
|
<VCardItem class="pt-3 pb-0">
|
||||||
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
|
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
|
||||||
<span class="text-h6 font-weight-bold text-truncate me-2">
|
<span class="text-h6 font-weight-bold me-2">
|
||||||
{{ media?.title ?? meta?.name }}
|
{{ media?.title ?? meta?.name }}
|
||||||
</span>
|
</span>
|
||||||
<VChip
|
<VChip
|
||||||
@@ -183,14 +183,14 @@ onMounted(() => {
|
|||||||
<!-- 种子内容 -->
|
<!-- 种子内容 -->
|
||||||
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
||||||
<!-- 种子标题 -->
|
<!-- 种子标题 -->
|
||||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1" :title="torrent?.title">
|
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1 break-all" :title="torrent?.title">
|
||||||
{{ torrent?.title }}
|
{{ torrent?.title }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 种子描述 -->
|
<!-- 种子描述 -->
|
||||||
<div
|
<div
|
||||||
v-if="meta?.subtitle || torrent?.description"
|
v-if="meta?.subtitle || torrent?.description"
|
||||||
class="text-body-2 text-medium-emphasis mb-2"
|
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||||
:title="meta?.subtitle || torrent?.description"
|
:title="meta?.subtitle || torrent?.description"
|
||||||
>
|
>
|
||||||
{{ meta?.subtitle || torrent?.description }}
|
{{ meta?.subtitle || torrent?.description }}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<VListItemTitle>
|
<VListItemTitle class="whitespace-normal">
|
||||||
<div class="d-flex flex-row flex-wrap align-center mb-2">
|
<div class="d-flex flex-row flex-wrap align-center mb-2">
|
||||||
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
|
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
|
||||||
<VChip
|
<VChip
|
||||||
@@ -153,12 +153,12 @@ onMounted(() => {
|
|||||||
</VChip>
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
|
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="torrent?.title">
|
||||||
{{ torrent?.title }}
|
{{ torrent?.title }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="text-body-2 text-medium-emphasis mb-2"
|
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||||
:title="meta?.subtitle || torrent?.description || '暂无描述'"
|
:title="meta?.subtitle || torrent?.description || '暂无描述'"
|
||||||
>
|
>
|
||||||
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||||
|
|||||||
@@ -223,7 +223,6 @@ onMounted(() => {
|
|||||||
<VSelect
|
<VSelect
|
||||||
v-model="selectedDownloader"
|
v-model="selectedDownloader"
|
||||||
:items="downloaderOptions"
|
:items="downloaderOptions"
|
||||||
size="small"
|
|
||||||
:label="t('dialog.addDownload.downloader')"
|
:label="t('dialog.addDownload.downloader')"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
|
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
|
||||||
@@ -236,7 +235,6 @@ onMounted(() => {
|
|||||||
v-model="selectedDirectory"
|
v-model="selectedDirectory"
|
||||||
:items="targetDirectories"
|
:items="targetDirectories"
|
||||||
:label="t('dialog.addDownload.saveDirectory')"
|
:label="t('dialog.addDownload.saveDirectory')"
|
||||||
size="small"
|
|
||||||
:placeholder="t('dialog.addDownload.autoPlaceholder')"
|
:placeholder="t('dialog.addDownload.autoPlaceholder')"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
@@ -248,7 +246,6 @@ onMounted(() => {
|
|||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VBtn
|
<VBtn
|
||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
|
||||||
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||||
>
|
>
|
||||||
@@ -272,7 +269,6 @@ onMounted(() => {
|
|||||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
prepend-inner-icon="mdi-identifier"
|
prepend-inner-icon="mdi-identifier"
|
||||||
size="small"
|
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
@click:append-inner="mediaSelectorDialog = true"
|
@click:append-inner="mediaSelectorDialog = true"
|
||||||
@@ -287,7 +283,6 @@ onMounted(() => {
|
|||||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
prepend-inner-icon="mdi-identifier"
|
prepend-inner-icon="mdi-identifier"
|
||||||
size="small"
|
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
@click:append-inner="mediaSelectorDialog = true"
|
@click:append-inner="mediaSelectorDialog = true"
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ async function registerPassKey() {
|
|||||||
if (!window.PublicKeyCredential) {
|
if (!window.PublicKeyCredential) {
|
||||||
if (!window.isSecureContext) {
|
if (!window.isSecureContext) {
|
||||||
$toast.error(t('login.passkeySecureContextRequired'))
|
$toast.error(t('login.passkeySecureContextRequired'))
|
||||||
return
|
} else {
|
||||||
|
$toast.error(t('login.passkeyNotSupported'))
|
||||||
}
|
}
|
||||||
$toast.error(t('login.passkeyNotSupported'))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +148,10 @@ async function registerPassKey() {
|
|||||||
console.error('PassKey注册失败:', error)
|
console.error('PassKey注册失败:', error)
|
||||||
if (error.name === 'NotAllowedError') {
|
if (error.name === 'NotAllowedError') {
|
||||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||||
|
} else if (error.name === 'NotSupportedError') {
|
||||||
|
$toast.error(t('login.passkeyNotSupported'))
|
||||||
|
} else if (error.message?.includes('start failed')) {
|
||||||
|
$toast.error(t('login.passkeyLoginStartFailed'))
|
||||||
} else if (error.response) {
|
} else if (error.response) {
|
||||||
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function getMenus(): NavMenu[] {
|
|||||||
item =>
|
item =>
|
||||||
item &&
|
item &&
|
||||||
menus.push({
|
menus.push({
|
||||||
title: t('setting') + ' -> ' + item.title,
|
title: t('navItems.setting') + ' -> ' + item.title,
|
||||||
icon: item.icon,
|
icon: item.icon,
|
||||||
to: `/setting?tab=${item.tab}`,
|
to: `/setting?tab=${item.tab}`,
|
||||||
header: '',
|
header: '',
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ onMounted(async () => {
|
|||||||
await fetchSiteInfo()
|
await fetchSiteInfo()
|
||||||
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
|
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
|
||||||
isLimit.value = true
|
isLimit.value = true
|
||||||
if (siteForm.value.apikey) siteType.value = 'api'
|
if (siteForm.value.apikey || siteForm.value.token) siteType.value = 'api'
|
||||||
}
|
}
|
||||||
await loadDownloaderSetting()
|
await loadDownloaderSetting()
|
||||||
})
|
})
|
||||||
@@ -224,15 +224,15 @@ onMounted(async () => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
|
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
|
||||||
<VTab selected-class="v-tab--selected">
|
<VTab value="cookie" selected-class="v-tab--selected">
|
||||||
<div>
|
<div>
|
||||||
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
|
<VIcon size="20" start icon="mdi-cookie" />
|
||||||
Cookie
|
Cookie
|
||||||
</div>
|
</div>
|
||||||
</VTab>
|
</VTab>
|
||||||
<VTab selected-class="v-tab--selected">
|
<VTab value="api" selected-class="v-tab--selected">
|
||||||
<div>
|
<div>
|
||||||
<VIcon size="20" start icon="mdi-api" value="api" />
|
<VIcon size="20" start icon="mdi-api" />
|
||||||
API
|
API
|
||||||
</div>
|
</div>
|
||||||
</VTab>
|
</VTab>
|
||||||
|
|||||||
@@ -0,0 +1,812 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
|
||||||
|
// 显示器宽度
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
|
// 国际化
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 定义输入参数
|
||||||
|
const props = defineProps<{
|
||||||
|
// 筛选表单
|
||||||
|
filterForm: Record<string, string[]>
|
||||||
|
// 筛选选项
|
||||||
|
filterOptions: Record<string, string[]>
|
||||||
|
// 排序字段
|
||||||
|
sortField: string
|
||||||
|
// 排序方向
|
||||||
|
sortType: 'asc' | 'desc'
|
||||||
|
// 筛选后的总数量
|
||||||
|
totalFilteredCount: number
|
||||||
|
// 过滤项标题映射
|
||||||
|
filterTitles: Record<string, string>
|
||||||
|
// 排序标题映射
|
||||||
|
sortTitles: Record<string, string>
|
||||||
|
// 是否启用滚动动画
|
||||||
|
enableAnimation?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 定义事件
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:sortField': [value: string]
|
||||||
|
'update:sortType': [value: 'asc' | 'desc']
|
||||||
|
'update:filterForm': [key: string, values: string[]]
|
||||||
|
'selectAll': [key: string]
|
||||||
|
'clearFilter': [key: string]
|
||||||
|
'clearAllFilters': []
|
||||||
|
'removeFilter': [key: string, value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 过滤菜单相关
|
||||||
|
const filterMenuOpen = ref(false)
|
||||||
|
const currentFilter = ref('site')
|
||||||
|
const currentFilterTitle = computed(() => props.filterTitles[currentFilter.value])
|
||||||
|
const currentFilterOptions = computed(() => {
|
||||||
|
return props.filterOptions[currentFilter.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加全部筛选菜单相关
|
||||||
|
const allFilterMenuOpen = ref(false)
|
||||||
|
|
||||||
|
// 计算已选择的过滤条件数量
|
||||||
|
const getFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
for (const key in props.filterForm) {
|
||||||
|
count += props.filterForm[key].length
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算已选择的过滤条件
|
||||||
|
const getSelectedFilters = computed(() => {
|
||||||
|
const filters: Record<string, string[]> = {}
|
||||||
|
for (const key in props.filterForm) {
|
||||||
|
if (props.filterForm[key].length > 0) {
|
||||||
|
filters[key] = [...props.filterForm[key]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
})
|
||||||
|
|
||||||
|
// 给定过滤类型返回不同图标
|
||||||
|
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 toggleAllFilterMenu() {
|
||||||
|
allFilterMenuOpen.value = !allFilterMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加toggleFilterMenu函数
|
||||||
|
function toggleFilterMenu(key: string) {
|
||||||
|
if (currentFilter.value === key && filterMenuOpen.value) {
|
||||||
|
filterMenuOpen.value = false
|
||||||
|
} else {
|
||||||
|
currentFilter.value = key
|
||||||
|
filterMenuOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理筛选值变化
|
||||||
|
function handleFilterChange(key: string, values: string[]) {
|
||||||
|
emit('update:filterForm', key, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选某个过滤项
|
||||||
|
function selectAll(key: string) {
|
||||||
|
emit('selectAll', key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除某个过滤项
|
||||||
|
function clearFilter(key: string) {
|
||||||
|
emit('clearFilter', key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有过滤条件
|
||||||
|
function clearAllFilters() {
|
||||||
|
emit('clearAllFilters')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除单个过滤条件
|
||||||
|
function removeFilter(key: string, value: string) {
|
||||||
|
emit('removeFilter', key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动条引用
|
||||||
|
const filterBarRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义平滑滚动
|
||||||
|
* @param element 元素
|
||||||
|
* @param target 目标位置
|
||||||
|
* @param duration 持续时间(ms)
|
||||||
|
*/
|
||||||
|
function smoothScroll(element: HTMLElement, target: number, duration: number) {
|
||||||
|
const start = element.scrollLeft
|
||||||
|
const change = target - start
|
||||||
|
let startTime: number | null = null
|
||||||
|
|
||||||
|
function animate(currentTime: number) {
|
||||||
|
if (startTime === null) startTime = currentTime
|
||||||
|
const timeElapsed = currentTime - startTime
|
||||||
|
const progress = Math.min(timeElapsed / duration, 1)
|
||||||
|
|
||||||
|
// 使用 ease-in-out 缓动函数
|
||||||
|
const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress
|
||||||
|
element.scrollLeft = start + change * ease
|
||||||
|
|
||||||
|
if (timeElapsed < duration) {
|
||||||
|
requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始滚动动画
|
||||||
|
onMounted(() => {
|
||||||
|
if (filterBarRef.value) {
|
||||||
|
useEventListener(filterBarRef, 'wheel', (e: WheelEvent) => {
|
||||||
|
if (e.deltaY !== 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
filterBarRef.value!.scrollLeft += e.deltaY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.enableAnimation === false) return
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = filterBarRef.value
|
||||||
|
if (el && el.clientWidth > 0 && el.scrollWidth > el.clientWidth) {
|
||||||
|
// 检查当前视口范围内的最后一个元素(即右侧边缘处的元素)
|
||||||
|
const children = Array.from(el.children) as HTMLElement[]
|
||||||
|
const lastInViewport = children.filter(c => (c as HTMLElement).offsetLeft < el.clientWidth).pop() as HTMLElement
|
||||||
|
if (lastInViewport) {
|
||||||
|
const visibleWidth = el.clientWidth - lastInViewport.offsetLeft
|
||||||
|
const visibleRatio = visibleWidth / lastInViewport.offsetWidth
|
||||||
|
|
||||||
|
// 如果视口内最后一个元素显示比例超过30%,则不需要滚动提示
|
||||||
|
if (visibleRatio > 0.3) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动到底部 (1100ms)
|
||||||
|
smoothScroll(el, el.scrollWidth - el.clientWidth, 1100)
|
||||||
|
// 短暂停止后滚动回顶部 (1100ms)
|
||||||
|
setTimeout(() => {
|
||||||
|
smoothScroll(el, 0, 1100)
|
||||||
|
}, 1600)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- PC端头部和筛选栏 -->
|
||||||
|
<div class="search-header d-none d-sm-block">
|
||||||
|
<VCard class="view-header mb-3">
|
||||||
|
<div class="d-flex align-center pa-3">
|
||||||
|
<!-- 固定位置:资源数量和排序 -->
|
||||||
|
<div class="d-flex align-center flex-shrink-0">
|
||||||
|
<VChip
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
class="search-count me-3 flex-shrink-0"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
>
|
||||||
|
{{ totalFilteredCount }} {{ t('torrent.resources') }}
|
||||||
|
</VChip>
|
||||||
|
|
||||||
|
<VBtn variant="text" size="small" class="sort-btn" :color="undefined">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
|
||||||
|
</template>
|
||||||
|
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
|
||||||
|
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
|
||||||
|
|
||||||
|
<VMenu activator="parent" transition="slide-y-transition">
|
||||||
|
<VList density="compact" min-width="120" class="sort-menu-list">
|
||||||
|
<!-- 升序/降序 选项 -->
|
||||||
|
<VListItem
|
||||||
|
value="asc"
|
||||||
|
:active="sortType === 'asc'"
|
||||||
|
color="primary"
|
||||||
|
@click="emit('update:sortType', 'asc')"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem
|
||||||
|
value="desc"
|
||||||
|
:active="sortType === 'desc'"
|
||||||
|
color="primary"
|
||||||
|
@click="emit('update:sortType', 'desc')"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
|
<VDivider class="my-1" />
|
||||||
|
|
||||||
|
<!-- 排序字段选项 -->
|
||||||
|
<VListItem
|
||||||
|
v-for="(title, key) in sortTitles"
|
||||||
|
:key="key"
|
||||||
|
:value="key"
|
||||||
|
:active="sortField === key"
|
||||||
|
color="primary"
|
||||||
|
@click="emit('update:sortField', key as string)"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<VListItemTitle>{{ title }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VMenu>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<div class="filter-divider"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 滚动区域:筛选条件 -->
|
||||||
|
<div class="filter-bar" ref="filterBarRef">
|
||||||
|
<!-- 筛选按钮 -->
|
||||||
|
<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)"
|
||||||
|
class="filter-btn"
|
||||||
|
rounded="pill"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||||
|
{{ filterForm[key].length }}
|
||||||
|
</VChip>
|
||||||
|
<VMenu activator="parent" :close-on-content-click="false" scrim>
|
||||||
|
<VCard max-width="20rem">
|
||||||
|
<VCardText class="filter-menu-content">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||||
|
{{ t('torrent.selectAll') }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
v-if="filterForm[key].length > 0"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="clearFilter(key)"
|
||||||
|
>
|
||||||
|
{{ t('torrent.clear') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
<VChipGroup
|
||||||
|
:model-value="filterForm[key]"
|
||||||
|
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
|
||||||
|
column
|
||||||
|
multiple
|
||||||
|
class="filter-options"
|
||||||
|
>
|
||||||
|
<VChip
|
||||||
|
v-for="option in filterOptions[key]"
|
||||||
|
:key="option"
|
||||||
|
:value="option"
|
||||||
|
filter
|
||||||
|
variant="elevated"
|
||||||
|
class="ma-1 filter-chip"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</VChip>
|
||||||
|
</VChipGroup>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VMenu>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<!-- 全部筛选按钮 -->
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
class="filter-btn me-2"
|
||||||
|
prepend-icon="mdi-filter-variant"
|
||||||
|
rounded="pill"
|
||||||
|
@click="toggleAllFilterMenu"
|
||||||
|
>
|
||||||
|
{{ t('torrent.allFilters') }}
|
||||||
|
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||||
|
{{ getFilterCount }}
|
||||||
|
</VChip>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getFilterCount > 0" class="selected-filters">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<div class="d-flex flex-wrap align-center flex-grow-1">
|
||||||
|
<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 as string, value)"
|
||||||
|
>
|
||||||
|
<VIcon size="small" :icon="getFilterIcon(key as string)" class="me-1"></VIcon>
|
||||||
|
<strong>{{ filterTitles[key as string] }}:</strong> {{ value }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VSpacer />
|
||||||
|
|
||||||
|
<!-- 清除全部筛选按钮 -->
|
||||||
|
<VBtn
|
||||||
|
v-if="getFilterCount > 0"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="clearAllFilters"
|
||||||
|
class="ms-2 flex-shrink-0"
|
||||||
|
prepend-icon="mdi-close-circle-outline"
|
||||||
|
>
|
||||||
|
{{ t('torrent.clearFilters') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端头部和筛选区域 -->
|
||||||
|
<VCard class="d-block d-sm-none search-header-mobile mb-3">
|
||||||
|
<div class="view-header">
|
||||||
|
<div class="d-flex align-center flex-wrap pa-2">
|
||||||
|
<div class="d-flex align-center w-100">
|
||||||
|
<VChip
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
size="small"
|
||||||
|
class="search-count me-auto"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
>
|
||||||
|
{{ totalFilteredCount }} {{ t('torrent.resources') }}
|
||||||
|
</VChip>
|
||||||
|
|
||||||
|
<!-- 排序选择 -->
|
||||||
|
<VBtn variant="text" size="small" class="sort-btn mobile-sort-btn" :color="undefined">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
|
||||||
|
</template>
|
||||||
|
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
|
||||||
|
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
|
||||||
|
|
||||||
|
<VMenu activator="parent" transition="slide-y-transition">
|
||||||
|
<VList density="compact" min-width="120" class="sort-menu-list">
|
||||||
|
<!-- 升序/降序 选项 -->
|
||||||
|
<VListItem
|
||||||
|
value="asc"
|
||||||
|
:active="sortType === 'asc'"
|
||||||
|
color="primary"
|
||||||
|
@click="emit('update:sortType', 'asc')"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem
|
||||||
|
value="desc"
|
||||||
|
:active="sortType === 'desc'"
|
||||||
|
color="primary"
|
||||||
|
@click="emit('update:sortType', 'desc')"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
|
<VDivider class="my-1" />
|
||||||
|
|
||||||
|
<!-- 排序字段选项 -->
|
||||||
|
<VListItem
|
||||||
|
v-for="(title, key) in sortTitles"
|
||||||
|
:key="key"
|
||||||
|
:value="key"
|
||||||
|
:active="sortField === key"
|
||||||
|
color="primary"
|
||||||
|
@click="emit('update:sortField', key as string)"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<VListItemTitle>{{ title }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VMenu>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选图标按钮区域 -->
|
||||||
|
<div class="filter-buttons-grid w-100 mt-2">
|
||||||
|
<VBtn
|
||||||
|
v-for="(title, key) in filterTitles"
|
||||||
|
v-show="filterOptions[key].length > 0"
|
||||||
|
:key="key"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
class="filter-btn-mobile"
|
||||||
|
@click="toggleFilterMenu(key)"
|
||||||
|
>
|
||||||
|
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></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>
|
||||||
|
|
||||||
|
<!-- 全部筛选按钮 -->
|
||||||
|
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||||
|
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||||
|
<span class="filter-label">
|
||||||
|
{{ t('torrent.allFilters') }}
|
||||||
|
</span>
|
||||||
|
<VBadge
|
||||||
|
v-if="getFilterCount > 0"
|
||||||
|
:content="getFilterCount"
|
||||||
|
color="primary"
|
||||||
|
location="top end"
|
||||||
|
offset-x="-10"
|
||||||
|
offset-y="-10"
|
||||||
|
></VBadge>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- 全部筛选弹窗 -->
|
||||||
|
<VDialog
|
||||||
|
v-model="allFilterMenuOpen"
|
||||||
|
max-width="50rem"
|
||||||
|
location="center"
|
||||||
|
scrollable
|
||||||
|
:fullscreen="!display.mdAndUp.value"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
|
||||||
|
<VCardTitle class="py-3 d-flex align-center">
|
||||||
|
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
|
||||||
|
<span>{{ t('torrent.allFilters') }}</span>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
v-if="getFilterCount > 0"
|
||||||
|
class="me-10"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="clearAllFilters"
|
||||||
|
>
|
||||||
|
{{ t('torrent.clearAll') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardTitle>
|
||||||
|
<VDivider />
|
||||||
|
<VCardText>
|
||||||
|
<div class="all-filters-grid">
|
||||||
|
<VCard
|
||||||
|
v-for="(title, key) in filterTitles"
|
||||||
|
variant="tonal"
|
||||||
|
:key="key"
|
||||||
|
class="filter-section"
|
||||||
|
v-show="filterOptions[key].length > 0"
|
||||||
|
>
|
||||||
|
<VCardItem class="py-2">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
|
||||||
|
</template>
|
||||||
|
<VCardTitle>{{ title }}</VCardTitle>
|
||||||
|
<template #append>
|
||||||
|
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||||
|
{{ t('torrent.selectAll') }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
v-if="filterForm[key].length > 0"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="clearFilter(key)"
|
||||||
|
>
|
||||||
|
{{ t('torrent.clear') }}
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VCardItem>
|
||||||
|
<VCardText>
|
||||||
|
<VChipGroup
|
||||||
|
:model-value="filterForm[key]"
|
||||||
|
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
|
||||||
|
column
|
||||||
|
multiple
|
||||||
|
class="filter-options"
|
||||||
|
>
|
||||||
|
<VChip
|
||||||
|
v-for="option in filterOptions[key]"
|
||||||
|
:key="option"
|
||||||
|
:value="option"
|
||||||
|
filter
|
||||||
|
variant="elevated"
|
||||||
|
class="ma-1 filter-chip"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</VChip>
|
||||||
|
</VChipGroup>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- 筛选弹窗 -->
|
||||||
|
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="py-3 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)"
|
||||||
|
>
|
||||||
|
{{ t('torrent.clear') }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
|
||||||
|
{{ t('torrent.selectAll') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardTitle>
|
||||||
|
<VDivider />
|
||||||
|
<VCardText>
|
||||||
|
<VChipGroup
|
||||||
|
:model-value="filterForm[currentFilter]"
|
||||||
|
@update:model-value="(val: string[]) => handleFilterChange(currentFilter, val)"
|
||||||
|
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 color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
|
||||||
|
{{ t('torrent.confirm') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
inset-block-start: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header-mobile {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
inset-block-start: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-count {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn {
|
||||||
|
height: 32px !important;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-inline: 12px 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn .v-icon {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn :deep(.v-btn__prepend) {
|
||||||
|
margin-inline-end: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-menu-list {
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {
|
||||||
|
margin-inline-end: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex: 1;
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar > * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-divider {
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
block-size: 24px;
|
||||||
|
inline-size: 1px;
|
||||||
|
margin-block: 0;
|
||||||
|
margin-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
min-inline-size: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-menu-content {
|
||||||
|
max-block-size: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||||
|
margin: 4px;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.v-chip--selected {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||||
|
color: rgb(var(--v-theme-on-primary)) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag {
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-filters {
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||||
|
padding-block: 8px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-buttons-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgba(var(--v-theme-surface), 0.5);
|
||||||
|
block-size: auto;
|
||||||
|
min-block-size: 48px;
|
||||||
|
padding-block: 4px;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-block-end: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-filters-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无限滚动 composable
|
||||||
|
* 用于管理分页显示和无限滚动加载
|
||||||
|
* @param sourceData - 源数据(响应式引用)
|
||||||
|
* @param pageSize - 每页显示数量,默认20
|
||||||
|
*/
|
||||||
|
export function useInfiniteScroll<T>(
|
||||||
|
sourceData: Ref<T[]>,
|
||||||
|
pageSize: number = 20
|
||||||
|
) {
|
||||||
|
// 显示用的数据列表
|
||||||
|
const displayDataList = ref<T[]>([])
|
||||||
|
|
||||||
|
// 剩余数据列表(用于无限滚动)
|
||||||
|
const remainingDataList = ref<T[]>([]) as Ref<T[]>
|
||||||
|
|
||||||
|
// 初始化数据
|
||||||
|
function initData() {
|
||||||
|
if (sourceData.value?.length) {
|
||||||
|
// 显示前 pageSize 个
|
||||||
|
displayDataList.value = sourceData.value.slice(0, pageSize) as T[]
|
||||||
|
// 保存剩余数据
|
||||||
|
remainingDataList.value = sourceData.value.slice(pageSize) as T[]
|
||||||
|
} else {
|
||||||
|
displayDataList.value = []
|
||||||
|
remainingDataList.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
function loadMore({ done }: { done: (status: InfiniteScrollStatus) => void }) {
|
||||||
|
// 从 remainingDataList 中获取最前面的 pageSize 个元素
|
||||||
|
const itemsToMove = remainingDataList.value.splice(0, pageSize) as T[]
|
||||||
|
;(displayDataList.value as T[]).push(...itemsToMove)
|
||||||
|
done('ok')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置数据
|
||||||
|
function reset() {
|
||||||
|
displayDataList.value = []
|
||||||
|
remainingDataList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听源数据变化,重新初始化
|
||||||
|
watch(sourceData, () => {
|
||||||
|
initData()
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayDataList,
|
||||||
|
remainingDataList,
|
||||||
|
initData,
|
||||||
|
loadMore,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import type { Context } from '@/api/types'
|
||||||
|
import { cloneDeepWith } from 'lodash-es'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// 卡片视图的分组数据类型
|
||||||
|
interface SearchTorrent extends Context {
|
||||||
|
more?: Array<Context>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedItem {
|
||||||
|
data: SearchTorrent
|
||||||
|
originalIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选状态类型
|
||||||
|
export interface FilterState {
|
||||||
|
filterForm: Record<string, string[]>
|
||||||
|
filterOptions: Record<string, string[]>
|
||||||
|
sortField: string
|
||||||
|
sortType: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
// useTorrentFilter composable
|
||||||
|
export function useTorrentFilter() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 过滤表单
|
||||||
|
const filterForm: Record<string, string[]> = reactive({
|
||||||
|
site: [] as string[],
|
||||||
|
season: [] as string[],
|
||||||
|
releaseGroup: [] as string[],
|
||||||
|
videoCode: [] as string[],
|
||||||
|
freeState: [] as string[],
|
||||||
|
edition: [] as string[],
|
||||||
|
resolution: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统一存储过滤选项
|
||||||
|
const filterOptions: Record<string, string[]> = reactive({
|
||||||
|
site: [] as string[],
|
||||||
|
season: [] as string[],
|
||||||
|
freeState: [] as string[],
|
||||||
|
edition: [] as string[],
|
||||||
|
resolution: [] as string[],
|
||||||
|
videoCode: [] as string[],
|
||||||
|
releaseGroup: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 排序字段
|
||||||
|
const sortField = ref('default')
|
||||||
|
// 排序方向
|
||||||
|
const sortType = ref<'asc' | 'desc'>('desc')
|
||||||
|
|
||||||
|
// 过滤项映射
|
||||||
|
const filterTitles: Record<string, string> = {
|
||||||
|
site: t('torrent.filterSite'),
|
||||||
|
season: t('torrent.filterSeason'),
|
||||||
|
freeState: t('torrent.filterFreeState'),
|
||||||
|
videoCode: t('torrent.filterVideoCode'),
|
||||||
|
edition: t('torrent.filterEdition'),
|
||||||
|
resolution: t('torrent.filterResolution'),
|
||||||
|
releaseGroup: t('torrent.filterReleaseGroup'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序中文名
|
||||||
|
const sortTitles: Record<string, string> = {
|
||||||
|
default: t('torrent.sortDefault'),
|
||||||
|
site: t('torrent.sortSite'),
|
||||||
|
size: t('torrent.sortSize'),
|
||||||
|
seeder: t('torrent.sortSeeder'),
|
||||||
|
publishTime: t('torrent.sortPublishTime'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选后数据的原始索引列表
|
||||||
|
const filteredIndices = ref<number[]>([])
|
||||||
|
|
||||||
|
// 筛选后的总数量
|
||||||
|
const totalFilteredCount = ref(0)
|
||||||
|
|
||||||
|
// 初始化过滤选项
|
||||||
|
function initOptions(data: Context) {
|
||||||
|
const { torrent_info, meta_info } = data
|
||||||
|
const optionValue = (options: Array<string>, value: string | undefined) => {
|
||||||
|
if (value && !options.includes(value)) {
|
||||||
|
options.push(value)
|
||||||
|
// 如果是season选项,立即触发重新计算
|
||||||
|
if (options === filterOptions.season) {
|
||||||
|
sortSeasonOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionValue(filterOptions.site, torrent_info?.site_name)
|
||||||
|
optionValue(filterOptions.season, meta_info?.season_episode)
|
||||||
|
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
|
||||||
|
optionValue(filterOptions.videoCode, meta_info?.video_encode)
|
||||||
|
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
|
||||||
|
optionValue(filterOptions.edition, meta_info?.edition)
|
||||||
|
optionValue(filterOptions.resolution, meta_info?.resource_pix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接对季集选项进行排序的函数
|
||||||
|
function sortSeasonOptions() {
|
||||||
|
if (filterOptions.season.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedOptions = filterOptions.season.map((option, index) => {
|
||||||
|
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
original: option,
|
||||||
|
seasonNum: 0,
|
||||||
|
episodeNum: 0,
|
||||||
|
maxEpisodeNum: 0,
|
||||||
|
isWholeSeason: false,
|
||||||
|
index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonNum = parseInt(match[1], 10)
|
||||||
|
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
|
||||||
|
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
|
||||||
|
const isWholeSeason = !match[3]
|
||||||
|
|
||||||
|
return {
|
||||||
|
original: option,
|
||||||
|
seasonNum,
|
||||||
|
episodeNum,
|
||||||
|
maxEpisodeNum,
|
||||||
|
isWholeSeason,
|
||||||
|
index,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
|
||||||
|
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
|
||||||
|
|
||||||
|
wholeSeasons.sort((a, b) => {
|
||||||
|
if (a.seasonNum !== b.seasonNum) {
|
||||||
|
return b.seasonNum - a.seasonNum
|
||||||
|
}
|
||||||
|
return a.index - b.index
|
||||||
|
})
|
||||||
|
|
||||||
|
episodes.sort((a, b) => {
|
||||||
|
if (a.seasonNum !== b.seasonNum) {
|
||||||
|
return b.seasonNum - a.seasonNum
|
||||||
|
}
|
||||||
|
const aMaxEp = a.maxEpisodeNum || a.episodeNum
|
||||||
|
const bMaxEp = b.maxEpisodeNum || b.episodeNum
|
||||||
|
if (aMaxEp !== bMaxEp) {
|
||||||
|
return bMaxEp - aMaxEp
|
||||||
|
}
|
||||||
|
if (a.episodeNum !== b.episodeNum) {
|
||||||
|
return b.episodeNum - a.episodeNum
|
||||||
|
}
|
||||||
|
return a.index - b.index
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
|
||||||
|
filterOptions.season = sortedOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配过滤函数
|
||||||
|
const match = (filter: Array<string>, value: string | undefined) =>
|
||||||
|
filter.length === 0 || (value && filter.includes(value))
|
||||||
|
|
||||||
|
// 筛选列表视图数据(不分组)
|
||||||
|
function filterRowData(items: Context[] | undefined): Context[] {
|
||||||
|
// 重置状态
|
||||||
|
filteredIndices.value = []
|
||||||
|
|
||||||
|
// 清空并重新初始化过滤选项
|
||||||
|
for (const key in filterOptions) {
|
||||||
|
filterOptions[key] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items?.length) {
|
||||||
|
totalFilteredCount.value = 0
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首先收集所有过滤选项
|
||||||
|
items.forEach(data => {
|
||||||
|
initOptions(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 筛选数据
|
||||||
|
let filteredData: Context[] = []
|
||||||
|
|
||||||
|
items.forEach((data, index) => {
|
||||||
|
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)
|
||||||
|
) {
|
||||||
|
filteredData.push(data)
|
||||||
|
filteredIndices.value.push(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
totalFilteredCount.value = filteredData.length
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
filteredData = sortData(filteredData)
|
||||||
|
|
||||||
|
// 确保季集选项排序
|
||||||
|
if (filterOptions.season.length > 0) {
|
||||||
|
sortSeasonOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选卡片视图数据(分组)
|
||||||
|
function filterCardData(items: Context[] | undefined): SearchTorrent[] {
|
||||||
|
// 重置状态
|
||||||
|
filteredIndices.value = []
|
||||||
|
|
||||||
|
// 清空并重新初始化过滤选项
|
||||||
|
for (const key in filterOptions) {
|
||||||
|
filterOptions[key] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items?.length) {
|
||||||
|
totalFilteredCount.value = 0
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据分组
|
||||||
|
const groupMap = new Map<string, GroupedItem[]>()
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const { torrent_info, meta_info } = item
|
||||||
|
// init options
|
||||||
|
initOptions(item)
|
||||||
|
// group data
|
||||||
|
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
|
||||||
|
const groupedItem = { data: item, originalIndex: index }
|
||||||
|
if (groupMap.has(key)) {
|
||||||
|
const group = groupMap.get(key)
|
||||||
|
group?.push(groupedItem)
|
||||||
|
} else {
|
||||||
|
groupMap.set(key, [groupedItem])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 筛选数据
|
||||||
|
const filteredData: SearchTorrent[] = []
|
||||||
|
let matchCount = 0
|
||||||
|
// 临时存储:每个分组的第一个原始索引
|
||||||
|
const groupIndexMap = new Map<SearchTorrent, number>()
|
||||||
|
|
||||||
|
groupMap.forEach(value => {
|
||||||
|
if (value.length > 0) {
|
||||||
|
const matchData = value.filter(item => {
|
||||||
|
const { meta_info, torrent_info } = item.data
|
||||||
|
return (
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (matchData.length > 0) {
|
||||||
|
matchCount += matchData.length
|
||||||
|
const firstItem = matchData[0]
|
||||||
|
const firstData = cloneDeepWith(firstItem.data) as SearchTorrent
|
||||||
|
if (matchData.length > 1) firstData.more = matchData.slice(1).map(x => x.data)
|
||||||
|
filteredData.push(firstData)
|
||||||
|
// 存储该分组的第一个原始索引
|
||||||
|
groupIndexMap.set(firstData, firstItem.originalIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
totalFilteredCount.value = matchCount
|
||||||
|
|
||||||
|
// 排序数据
|
||||||
|
const sortedData = sortCardData(filteredData)
|
||||||
|
|
||||||
|
// 在排序后重新构建 filteredIndices,保持与排序后顺序一致
|
||||||
|
filteredIndices.value = sortedData.map(item => groupIndexMap.get(item) || 0)
|
||||||
|
|
||||||
|
// 确保季集选项排序
|
||||||
|
if (filterOptions.season.length > 0) {
|
||||||
|
sortSeasonOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序列表数据
|
||||||
|
function sortData(data: Context[]): Context[] {
|
||||||
|
const sortOrder = sortType.value === 'asc' ? 1 : -1
|
||||||
|
|
||||||
|
return data.sort((a, b) => {
|
||||||
|
let result = 0
|
||||||
|
switch (sortField.value) {
|
||||||
|
case 'site':
|
||||||
|
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
|
||||||
|
break
|
||||||
|
case 'size':
|
||||||
|
result = a.torrent_info.size - b.torrent_info.size
|
||||||
|
break
|
||||||
|
case 'seeder':
|
||||||
|
result = a.torrent_info.seeders - b.torrent_info.seeders
|
||||||
|
break
|
||||||
|
case 'publishTime':
|
||||||
|
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
|
||||||
|
break
|
||||||
|
case 'default':
|
||||||
|
default:
|
||||||
|
result = a.torrent_info.pri_order - b.torrent_info.pri_order
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return result * sortOrder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序卡片数据
|
||||||
|
function sortCardData(data: SearchTorrent[]): SearchTorrent[] {
|
||||||
|
if (sortField.value === 'default') {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
const sortOrder = sortType.value === 'asc' ? 1 : -1
|
||||||
|
return data.sort((a, b) => {
|
||||||
|
let result = 0
|
||||||
|
switch (sortField.value) {
|
||||||
|
case 'site':
|
||||||
|
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
|
||||||
|
break
|
||||||
|
case 'size':
|
||||||
|
result = (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
|
||||||
|
break
|
||||||
|
case 'seeder':
|
||||||
|
result = (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
|
||||||
|
break
|
||||||
|
case 'publishTime':
|
||||||
|
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return result * sortOrder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算已选择的过滤条件数量
|
||||||
|
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 clearFilter(key: string) {
|
||||||
|
filterForm[key] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选某个过滤项
|
||||||
|
function selectAll(key: string) {
|
||||||
|
filterForm[key] = [...filterOptions[key]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给定过滤类型返回不同图标
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理排序图标点击
|
||||||
|
const handleSortIconClick = () => {
|
||||||
|
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取筛选后的原始索引列表
|
||||||
|
function getFilteredIndices() {
|
||||||
|
return filteredIndices.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有活动的筛选条件
|
||||||
|
function hasActiveFilters() {
|
||||||
|
for (const key in filterForm) {
|
||||||
|
if (filterForm[key] && filterForm[key].length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前筛选条件
|
||||||
|
function getFilterForm() {
|
||||||
|
const filters: Record<string, string[]> = {}
|
||||||
|
for (const key in filterForm) {
|
||||||
|
filters[key] = [...filterForm[key]]
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置筛选条件
|
||||||
|
function setFilterForm(filters: Record<string, string[]>) {
|
||||||
|
for (const key in filterForm) {
|
||||||
|
filterForm[key] = filters[key] ? [...filters[key]] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取完整的筛选状态
|
||||||
|
function getFilterState(): FilterState {
|
||||||
|
return {
|
||||||
|
filterForm: getFilterForm(),
|
||||||
|
filterOptions: { ...filterOptions },
|
||||||
|
sortField: sortField.value,
|
||||||
|
sortType: sortType.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置完整的筛选状态
|
||||||
|
function setFilterState(state: FilterState) {
|
||||||
|
setFilterForm(state.filterForm)
|
||||||
|
sortField.value = state.sortField
|
||||||
|
sortType.value = state.sortType
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
filterForm,
|
||||||
|
filterOptions,
|
||||||
|
sortField,
|
||||||
|
sortType,
|
||||||
|
filteredIndices,
|
||||||
|
totalFilteredCount,
|
||||||
|
// 标题映射
|
||||||
|
filterTitles,
|
||||||
|
sortTitles,
|
||||||
|
// 计算属性
|
||||||
|
getFilterCount,
|
||||||
|
getSelectedFilters,
|
||||||
|
// 筛选方法
|
||||||
|
filterRowData,
|
||||||
|
filterCardData,
|
||||||
|
// 操作方法
|
||||||
|
removeFilter,
|
||||||
|
clearAllFilters,
|
||||||
|
clearFilter,
|
||||||
|
selectAll,
|
||||||
|
getFilterIcon,
|
||||||
|
handleSortIconClick,
|
||||||
|
// 状态管理方法
|
||||||
|
getFilteredIndices,
|
||||||
|
hasActiveFilters,
|
||||||
|
getFilterForm,
|
||||||
|
setFilterForm,
|
||||||
|
getFilterState,
|
||||||
|
setFilterState,
|
||||||
|
sortSeasonOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -69,7 +69,9 @@ export default {
|
|||||||
preset: 'Preset',
|
preset: 'Preset',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
swUpdateReady: 'New version is ready, please refresh the page to get the latest features',
|
swUpdateReady: 'New version is ready, please refresh the page to get the latest features',
|
||||||
versionMismatch: 'Browser cache version does not match server version, please try clearing cache',
|
ascending: 'Ascending',
|
||||||
|
descending: 'Descending',
|
||||||
|
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
|
||||||
clearCache: 'Clear Cache',
|
clearCache: 'Clear Cache',
|
||||||
},
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
@@ -958,6 +960,9 @@ export default {
|
|||||||
searching: 'Searching, please wait...',
|
searching: 'Searching, please wait...',
|
||||||
noData: 'No Data',
|
noData: 'No Data',
|
||||||
noResourceFound: 'No resources found',
|
noResourceFound: 'No resources found',
|
||||||
|
aiRecommend: 'AI Recommendation',
|
||||||
|
reRecommend: 'Regenerate Recommendation',
|
||||||
|
aiRecommendError: 'AI Recommendation Failed',
|
||||||
},
|
},
|
||||||
browse: {
|
browse: {
|
||||||
actor: 'Actor',
|
actor: 'Actor',
|
||||||
@@ -1298,6 +1303,12 @@ export default {
|
|||||||
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
|
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
|
||||||
downloaders: 'Downloaders',
|
downloaders: 'Downloaders',
|
||||||
downloadersDesc: 'Only the default downloader will be used by default.',
|
downloadersDesc: 'Only the default downloader will be used by default.',
|
||||||
|
aiRecommendEnabled: 'AI Search Recommendation',
|
||||||
|
aiRecommendEnabledHint: 'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',
|
||||||
|
aiRecommendUserPreference: 'User Preference',
|
||||||
|
aiRecommendUserPreferenceHint: 'Set user preferences for AI recommendation, e.g., 4K WEB-DL Dolby Vision',
|
||||||
|
aiRecommendMaxItems: 'AI Recommendation Analysis Limit',
|
||||||
|
aiRecommendMaxItemsHint: 'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',
|
||||||
mediaServers: 'Media Servers',
|
mediaServers: 'Media Servers',
|
||||||
mediaServersDesc: 'All enabled media servers will be used.',
|
mediaServersDesc: 'All enabled media servers will be used.',
|
||||||
trimeMedia: 'TrimeMedia',
|
trimeMedia: 'TrimeMedia',
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export default {
|
|||||||
preset: '预设',
|
preset: '预设',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
swUpdateReady: '新版本已就绪,请刷新页面以获取最新功能',
|
swUpdateReady: '新版本已就绪,请刷新页面以获取最新功能',
|
||||||
|
ascending: '升序',
|
||||||
|
descending: '降序',
|
||||||
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
||||||
clearCache: '清除缓存',
|
clearCache: '清除缓存',
|
||||||
},
|
},
|
||||||
@@ -955,6 +957,9 @@ export default {
|
|||||||
searching: '正在搜索,请稍候...',
|
searching: '正在搜索,请稍候...',
|
||||||
noData: '没有数据',
|
noData: '没有数据',
|
||||||
noResourceFound: '未搜索到任何资源',
|
noResourceFound: '未搜索到任何资源',
|
||||||
|
aiRecommend: '智能推荐',
|
||||||
|
reRecommend: '重新生成推荐',
|
||||||
|
aiRecommendError: '智能推荐失败',
|
||||||
},
|
},
|
||||||
browse: {
|
browse: {
|
||||||
actor: '演员',
|
actor: '演员',
|
||||||
@@ -1294,6 +1299,12 @@ export default {
|
|||||||
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
|
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
|
||||||
downloaders: '下载器',
|
downloaders: '下载器',
|
||||||
downloadersDesc: '只有默认下载器才会被默认使用。',
|
downloadersDesc: '只有默认下载器才会被默认使用。',
|
||||||
|
aiRecommendEnabled: '搜索结果智能推荐',
|
||||||
|
aiRecommendEnabledHint: '启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
|
||||||
|
aiRecommendUserPreference: '用户偏好',
|
||||||
|
aiRecommendUserPreferenceHint: '设置智能推荐时的用户偏好,例如:4K WEB-DL Dolby Vision',
|
||||||
|
aiRecommendMaxItems: '智能推荐分析条目上限',
|
||||||
|
aiRecommendMaxItemsHint: '限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
|
||||||
mediaServers: '媒体服务器',
|
mediaServers: '媒体服务器',
|
||||||
mediaServersDesc: '所有启用的媒体服务器都会被使用。',
|
mediaServersDesc: '所有启用的媒体服务器都会被使用。',
|
||||||
trimeMedia: '飞牛影视',
|
trimeMedia: '飞牛影视',
|
||||||
|
|||||||
+12
-1
@@ -69,7 +69,9 @@ export default {
|
|||||||
preset: '預設',
|
preset: '預設',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
swUpdateReady: '新版本已就緒,請刷新頁面以獲取最新功能',
|
swUpdateReady: '新版本已就緒,請刷新頁面以獲取最新功能',
|
||||||
versionMismatch: '瀏覽器快取版本與伺服器版本不一致,請嘗試清除快取',
|
ascending: '升序',
|
||||||
|
descending: '降序',
|
||||||
|
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
|
||||||
clearCache: '清除快取',
|
clearCache: '清除快取',
|
||||||
},
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
@@ -942,6 +944,9 @@ export default {
|
|||||||
searching: '正在搜索,請稍候...',
|
searching: '正在搜索,請稍候...',
|
||||||
noData: '沒有數據',
|
noData: '沒有數據',
|
||||||
noResourceFound: '未搜索到任何資源',
|
noResourceFound: '未搜索到任何資源',
|
||||||
|
aiRecommend: '智能推薦',
|
||||||
|
reRecommend: '重新生成推薦',
|
||||||
|
aiRecommendError: '智能推薦失敗',
|
||||||
},
|
},
|
||||||
browse: {
|
browse: {
|
||||||
actor: '演員',
|
actor: '演員',
|
||||||
@@ -1282,6 +1287,12 @@ export default {
|
|||||||
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
|
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
|
||||||
downloaders: '下載器',
|
downloaders: '下載器',
|
||||||
downloadersDesc: '只有默認下載器才會被默認使用。',
|
downloadersDesc: '只有默認下載器才會被默認使用。',
|
||||||
|
aiRecommendEnabled: '搜索結果智能推薦',
|
||||||
|
aiRecommendEnabledHint: '啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
|
||||||
|
aiRecommendUserPreference: '用戶偏好',
|
||||||
|
aiRecommendUserPreferenceHint: '設置智能推薦時的用戶偏好,例如:4K WEB-DL Dolby Vision',
|
||||||
|
aiRecommendMaxItems: '智能推薦分析條目上限',
|
||||||
|
aiRecommendMaxItemsHint: '限制發送給智能助手進行分析的搜索結果數量,數量越多分析越慢且消耗 Token 越多,建議先手動篩選,篩選出大致範圍後再進行智能推薦',
|
||||||
mediaServers: '媒體服務器',
|
mediaServers: '媒體服務器',
|
||||||
mediaServersDesc: '所有啟用的媒體服務器都會被使用。',
|
mediaServersDesc: '所有啟用的媒體服務器都會被使用。',
|
||||||
trimeMedia: '飛牛影視',
|
trimeMedia: '飛牛影視',
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VForm } from 'vuetify/components/VForm'
|
import { VForm } from 'vuetify/components/VForm'
|
||||||
import { useAuthStore, useUserStore } from '@/stores'
|
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||||
import { authState, userState } from '@/stores/types'
|
import { authState, userState } from '@/stores/types'
|
||||||
import { requiredValidator } from '@/@validators'
|
import { requiredValidator } from '@/@validators'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
@@ -20,6 +20,8 @@ const { t } = useI18n()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
//用户 Store
|
//用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
// 全局设置 Store
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 获取有权限的菜单
|
// 获取有权限的菜单
|
||||||
const navMenus = computed(() => getNavMenus(t))
|
const navMenus = computed(() => getNavMenus(t))
|
||||||
@@ -371,6 +373,9 @@ async function handleLoginSuccess(response: any) {
|
|||||||
authStore.login(authPayLoad)
|
authStore.login(authPayLoad)
|
||||||
userStore.loginUser(userPayload)
|
userStore.loginUser(userPayload)
|
||||||
|
|
||||||
|
// 登录后加载用户相关的全局设置
|
||||||
|
await globalSettingsStore.loadUserSettings()
|
||||||
|
|
||||||
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
|
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+662
-102
@@ -3,15 +3,29 @@ import { debounce } from 'lodash-es'
|
|||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Context } from '@/api/types'
|
import type { Context } from '@/api/types'
|
||||||
import TorrentCardListView from '@/views/torrent/TorrentCardListView.vue'
|
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||||
import TorrentRowListView from '@/views/torrent/TorrentRowListView.vue'
|
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||||
|
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores/global'
|
||||||
|
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
||||||
|
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { useProgressSSE } = useBackgroundOptimization()
|
const { useProgressSSE } = useBackgroundOptimization()
|
||||||
|
|
||||||
|
// 提示框
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// 全局设置 Store
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
|
// 使用筛选 composable
|
||||||
|
const torrentFilter = useTorrentFilter()
|
||||||
|
|
||||||
// 路由参数
|
// 路由参数
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -39,11 +53,46 @@ 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)
|
// 从全局设置中获取 AI_RECOMMEND_ENABLED 状态
|
||||||
|
const aiRecommendEnabled = computed(() => {
|
||||||
|
return globalSettingsStore.get('AI_RECOMMEND_ENABLED') === true
|
||||||
|
})
|
||||||
|
const isRecommending = ref(false)
|
||||||
|
const isReRecommending = ref(false) // 是否正在重新推荐
|
||||||
|
const aiRecommended = ref(false) // 是否已执行过智能推荐
|
||||||
|
const showingAiResults = ref(false) // 是否正在显示智能推荐结果
|
||||||
|
const originalDataList = ref<Array<Context>>([]) // 原始搜索结果
|
||||||
|
const aiRecommendedList = ref<Array<Context>>([]) // 智能推荐结果
|
||||||
|
const savedFilterState = ref<FilterState | null>(null) // 保存的筛选状态
|
||||||
|
const aiStatusChecked = ref(false) // 是否已完成首次AI状态检查
|
||||||
|
let aiStatusCheckInterval: ReturnType<typeof setInterval> | null = null // AI状态检查定时器
|
||||||
|
|
||||||
// 数据列表
|
// 是否有搜索标签
|
||||||
const dataList = ref<Array<Context>>([])
|
const hasSearchTags = computed(() => {
|
||||||
|
return !!(keyword || title || year || season)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否启用筛选栏动画
|
||||||
|
const enableFilterAnimation = ref(true)
|
||||||
|
|
||||||
|
// 原始数据列表(未筛选)
|
||||||
|
const rawDataList = ref<Array<Context>>([])
|
||||||
|
|
||||||
|
// 筛选后的数据列表(用于行视图)
|
||||||
|
const filteredRowDataList = ref<Array<Context>>([])
|
||||||
|
|
||||||
|
// 筛选后的数据列表(用于卡片视图)
|
||||||
|
interface SearchTorrent extends Context {
|
||||||
|
more?: Array<Context>
|
||||||
|
}
|
||||||
|
const filteredCardDataList = ref<Array<SearchTorrent>>([])
|
||||||
|
|
||||||
|
// 使用无限滚动 composable(行视图)
|
||||||
|
const rowScroll = useInfiniteScroll(filteredRowDataList)
|
||||||
|
|
||||||
|
// 使用无限滚动 composable(卡片视图)
|
||||||
|
const cardScroll = useInfiniteScroll(filteredCardDataList)
|
||||||
|
|
||||||
// 是否刷新过
|
// 是否刷新过
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
@@ -66,6 +115,49 @@ const errorTitle = ref(t('resource.noData'))
|
|||||||
// 错误描述
|
// 错误描述
|
||||||
const errorDescription = ref(t('resource.noResourceFound'))
|
const errorDescription = ref(t('resource.noResourceFound'))
|
||||||
|
|
||||||
|
// 监听筛选条件变化,重新筛选数据
|
||||||
|
watch(
|
||||||
|
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
|
||||||
|
() => {
|
||||||
|
applyFilter()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 应用筛选
|
||||||
|
function applyFilter() {
|
||||||
|
if (viewType.value === 'row') {
|
||||||
|
filteredRowDataList.value = torrentFilter.filterRowData(rawDataList.value)
|
||||||
|
} else {
|
||||||
|
filteredCardDataList.value = torrentFilter.filterCardData(rawDataList.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理筛选表单更新
|
||||||
|
function handleFilterFormUpdate(key: string, values: string[]) {
|
||||||
|
torrentFilter.filterForm[key] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理全选
|
||||||
|
function handleSelectAll(key: string) {
|
||||||
|
torrentFilter.selectAll(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理清除筛选
|
||||||
|
function handleClearFilter(key: string) {
|
||||||
|
torrentFilter.clearFilter(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理清除所有筛选
|
||||||
|
function handleClearAllFilters() {
|
||||||
|
torrentFilter.clearAllFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理移除单个筛选
|
||||||
|
function handleRemoveFilter(key: string, value: string) {
|
||||||
|
torrentFilter.removeFilter(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
// 添加安全超时,确保进度条不会永远卡住
|
// 添加安全超时,确保进度条不会永远卡住
|
||||||
const watchProgressValue = watch(
|
const watchProgressValue = watch(
|
||||||
progressValue,
|
progressValue,
|
||||||
@@ -116,29 +208,30 @@ function stopLoadingProgress() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
progressValue.value = 0
|
progressValue.value = 0
|
||||||
progressEnabled.value = false
|
progressEnabled.value = false
|
||||||
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
|
}, 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置视图类型
|
// 设置视图类型
|
||||||
function changeViewType(newType: string) {
|
function changeViewType(newType: string) {
|
||||||
if (viewType.value !== newType) {
|
if (viewType.value !== newType) {
|
||||||
isViewChanging.value = true
|
// 立即更新视图类型
|
||||||
viewType.value = newType
|
viewType.value = newType
|
||||||
localStorage.setItem('MPTorrentsViewType', newType)
|
localStorage.setItem('MPTorrentsViewType', newType)
|
||||||
|
|
||||||
// 模拟视图切换的加载过程
|
// 切换视图时重新应用筛选
|
||||||
setTimeout(() => {
|
applyFilter()
|
||||||
isViewChanging.value = false
|
|
||||||
}, 600)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取搜索列表数据
|
// 获取搜索列表数据
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
|
enableFilterAnimation.value = true
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
// 查询上次搜索结果
|
// 查询上次搜索结果
|
||||||
dataList.value = await api.get('search/last')
|
const results = await api.get('search/last')
|
||||||
|
rawDataList.value = (results as unknown as Context[]) || []
|
||||||
|
originalDataList.value = (results as unknown as Context[]) || []
|
||||||
} else {
|
} else {
|
||||||
startLoadingProgress()
|
startLoadingProgress()
|
||||||
let result: { [key: string]: any }
|
let result: { [key: string]: any }
|
||||||
@@ -164,7 +257,12 @@ async function fetchData() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
dataList.value = result.data || []
|
rawDataList.value = result.data || []
|
||||||
|
originalDataList.value = result.data || []
|
||||||
|
// 重置智能推荐状态
|
||||||
|
aiRecommended.value = false
|
||||||
|
showingAiResults.value = false
|
||||||
|
aiRecommendedList.value = []
|
||||||
} else if (result && result.message) {
|
} else if (result && result.message) {
|
||||||
errorDescription.value = result.message
|
errorDescription.value = result.message
|
||||||
}
|
}
|
||||||
@@ -172,6 +270,8 @@ async function fetchData() {
|
|||||||
// 从浏览器历史中删除当前搜索
|
// 从浏览器历史中删除当前搜索
|
||||||
window.history.replaceState(null, '', window.location.pathname)
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
}
|
}
|
||||||
|
// 应用筛选
|
||||||
|
applyFilter()
|
||||||
// 标记已刷新
|
// 标记已刷新
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -182,14 +282,280 @@ async function fetchData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换到智能推荐结果(自动保存筛选条件)
|
||||||
|
async function switchToAiResults() {
|
||||||
|
if (showingAiResults.value) {
|
||||||
|
console.log('已经在显示AI结果')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前筛选状态
|
||||||
|
savedFilterState.value = torrentFilter.getFilterState()
|
||||||
|
|
||||||
|
// 切换数据
|
||||||
|
rawDataList.value = [...aiRecommendedList.value]
|
||||||
|
showingAiResults.value = true
|
||||||
|
console.log('已切换到智能推荐结果')
|
||||||
|
|
||||||
|
// 清空智能推荐筛选条件
|
||||||
|
torrentFilter.clearAllFilters()
|
||||||
|
|
||||||
|
// 重新应用筛选
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换回原始结果(自动还原筛选条件)
|
||||||
|
async function switchToOriginalResults() {
|
||||||
|
if (!showingAiResults.value) {
|
||||||
|
console.log('已经在显示原始结果')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换数据
|
||||||
|
rawDataList.value = [...originalDataList.value]
|
||||||
|
showingAiResults.value = false
|
||||||
|
console.log('已切换到原始结果')
|
||||||
|
|
||||||
|
// 恢复原始筛选条件
|
||||||
|
if (savedFilterState.value) {
|
||||||
|
torrentFilter.setFilterState(savedFilterState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新应用筛选
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能推荐/切换结果
|
||||||
|
async function toggleAiRecommend() {
|
||||||
|
// 如果当前显示AI结果,则切换回原始结果
|
||||||
|
if (showingAiResults.value) {
|
||||||
|
await switchToOriginalResults()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经有智能推荐结果,直接切换
|
||||||
|
if (aiRecommended.value && aiRecommendedList.value.length > 0) {
|
||||||
|
await switchToAiResults()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则启动智能推荐
|
||||||
|
// 保存当前筛选状态,以便切换回原始结果时恢复
|
||||||
|
savedFilterState.value = torrentFilter.getFilterState()
|
||||||
|
console.log('首次智能推荐,已保存筛选状态:', savedFilterState.value)
|
||||||
|
|
||||||
|
startAiRecommend()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动智能推荐(开始轮询)
|
||||||
|
async function startAiRecommend(force: boolean = false) {
|
||||||
|
isRecommending.value = true
|
||||||
|
console.log('启动智能推荐', force ? '(强制)' : '')
|
||||||
|
|
||||||
|
// 首次或强制时,先发送一个启动任务的请求
|
||||||
|
await sendInitialRequest(force)
|
||||||
|
|
||||||
|
// 然后开始 check_only 轮询
|
||||||
|
startAiRecommendPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送初始请求以启动智能推荐任务
|
||||||
|
async function sendInitialRequest(force: boolean = false) {
|
||||||
|
try {
|
||||||
|
const requestBody: any = {}
|
||||||
|
|
||||||
|
// 检查是否有筛选条件
|
||||||
|
const hasFilters = torrentFilter.hasActiveFilters()
|
||||||
|
if (hasFilters) {
|
||||||
|
const indices = torrentFilter.getFilteredIndices()
|
||||||
|
if (indices && indices.length > 0) {
|
||||||
|
requestBody.filtered_indices = indices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是强制模式,添加 force 标志
|
||||||
|
if (force) {
|
||||||
|
requestBody.force = true
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('发送初始请求以启动任务', force ? '(force)' : '')
|
||||||
|
await api.post('search/recommend', requestBody)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送初始请求失败:', error)
|
||||||
|
isRecommending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始轮询智能推荐(使用 check_only 模式)
|
||||||
|
function startAiRecommendPolling() {
|
||||||
|
// 停止可能存在的轮询
|
||||||
|
stopAiRecommendPolling()
|
||||||
|
|
||||||
|
// 立即发送一次 check_only 请求
|
||||||
|
pollAiRecommend()
|
||||||
|
|
||||||
|
// 然后每2秒轮询一次(check_only)
|
||||||
|
aiStatusCheckInterval = setInterval(() => {
|
||||||
|
pollAiRecommend()
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询智能推荐状态(始终使用 check_only 模式)
|
||||||
|
async function pollAiRecommend() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.post('search/recommend', {
|
||||||
|
check_only: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { success, data } = result
|
||||||
|
const status = data?.status
|
||||||
|
|
||||||
|
// 正在运行,继续轮询
|
||||||
|
if (success && status === 'running') {
|
||||||
|
console.log('AI推理中...')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他所有状态均停止轮询
|
||||||
|
stopAiRecommendPolling()
|
||||||
|
isRecommending.value = false
|
||||||
|
|
||||||
|
if (success && status === 'completed') {
|
||||||
|
// 推荐完成
|
||||||
|
if (data.results?.length > 0) {
|
||||||
|
// 加载智能推荐结果
|
||||||
|
loadAiRecommendedResults(data.results)
|
||||||
|
|
||||||
|
// 自动切换到智能推荐结果(会自动保存筛选条件)
|
||||||
|
await switchToAiResults()
|
||||||
|
}
|
||||||
|
} else if (success && status === 'disabled') {
|
||||||
|
// 功能停用
|
||||||
|
console.error('AI功能未启用')
|
||||||
|
} else {
|
||||||
|
// 错误情况(status === 'error' 或 success 为 false)
|
||||||
|
const errMsg = result.message || data?.error || data?.message || 'Unknown error'
|
||||||
|
console.error('智能推荐错误:', errMsg)
|
||||||
|
toast.error(`${t('resource.aiRecommendError')}: ${errMsg}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('智能推荐轮询失败:', error)
|
||||||
|
stopAiRecommendPolling()
|
||||||
|
isRecommending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止轮询智能推荐
|
||||||
|
function stopAiRecommendPolling() {
|
||||||
|
if (aiStatusCheckInterval) {
|
||||||
|
clearInterval(aiStatusCheckInterval)
|
||||||
|
aiStatusCheckInterval = null
|
||||||
|
console.log('停止智能推荐轮询')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载智能推荐结果(从索引数组提取数据)
|
||||||
|
function loadAiRecommendedResults(indices: number[]) {
|
||||||
|
if (!indices || indices.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从原始数据中根据索引提取结果
|
||||||
|
aiRecommendedList.value = indices.map((index: number) => originalDataList.value[index]).filter(Boolean)
|
||||||
|
aiRecommended.value = true
|
||||||
|
console.log(`加载智能推荐结果: ${aiRecommendedList.value.length} 条`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新推荐
|
||||||
|
async function reRecommend() {
|
||||||
|
try {
|
||||||
|
isReRecommending.value = true
|
||||||
|
console.log('重新推荐:重置状态')
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
aiRecommended.value = false
|
||||||
|
aiRecommendedList.value = []
|
||||||
|
|
||||||
|
// 切换回原始结果(会自动还原筛选条件)
|
||||||
|
await switchToOriginalResults()
|
||||||
|
|
||||||
|
// 等待筛选数据还原完成(nextTick确保DOM更新完成)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// 再等待一个微任务,确保筛选逻辑完全执行
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
// 重新启动智能推荐(带 force 标志)
|
||||||
|
startAiRecommend(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重新推荐失败:', error)
|
||||||
|
} finally {
|
||||||
|
isReRecommending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查智能推荐状态(页面初始化时调用一次)
|
||||||
|
async function checkAiRecommendStatus() {
|
||||||
|
try {
|
||||||
|
// 首次检查时使用 check_only 模式
|
||||||
|
const result: { [key: string]: any } = await api.post('search/recommend', {
|
||||||
|
check_only: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { success, data } = result
|
||||||
|
const status = data?.status
|
||||||
|
|
||||||
|
// 只要有数据且状态不是disabled,就标记已检查(允许重试)
|
||||||
|
if (data && status !== 'disabled') {
|
||||||
|
aiStatusChecked.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success && data) {
|
||||||
|
const { results } = data
|
||||||
|
|
||||||
|
// 如果有完成的结果,加载它
|
||||||
|
if (status === 'completed' && results && results.length > 0) {
|
||||||
|
loadAiRecommendedResults(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在运行,启动轮询
|
||||||
|
if (status === 'running') {
|
||||||
|
isRecommending.value = true
|
||||||
|
startAiRecommendPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查AI状态失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算当前显示的数据是否有数据
|
||||||
|
const hasData = computed(() => {
|
||||||
|
if (viewType.value === 'row') {
|
||||||
|
return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0
|
||||||
|
} else {
|
||||||
|
return filteredCardDataList.value.length > 0 || rawDataList.value.length > 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 AI_RECOMMEND_ENABLED 状态和数据加载状态
|
||||||
|
// 使用 watchEffect 确保计算属性变化时立即响应
|
||||||
|
watchEffect(() => {
|
||||||
|
// 需要满足:AI 功能启用、数据已加载、尚未检查
|
||||||
|
if (aiRecommendEnabled.value && originalDataList.value.length > 0 && !aiStatusChecked.value) {
|
||||||
|
checkAiRecommendStatus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 卸载时停止加载进度
|
// 卸载时停止轮询
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
|
stopAiRecommendPolling()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -215,9 +581,10 @@ onUnmounted(() => {
|
|||||||
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
|
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
|
||||||
<div class="search-info-container">
|
<div class="search-info-container">
|
||||||
<div class="search-title text-moviepilot">
|
<div class="search-title text-moviepilot">
|
||||||
{{ t('resource.searchResults') }}
|
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
|
||||||
|
<span class="d-inline d-sm-none">{{ t('navItems.searchResult') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-tags d-flex flex-wrap mt-1">
|
<div v-if="hasSearchTags" class="search-tags d-flex flex-wrap mt-1">
|
||||||
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
|
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
|
||||||
{{ t('resource.keyword') }}: {{ keyword }}
|
{{ t('resource.keyword') }}: {{ keyword }}
|
||||||
</VChip>
|
</VChip>
|
||||||
@@ -232,10 +599,62 @@ onUnmounted(() => {
|
|||||||
</VChip>
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
|
<!-- AI操作按钮组 -->
|
||||||
|
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2">
|
||||||
|
<div class="ai-toggle-buttons">
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
rounded="0"
|
||||||
|
@click="toggleAiRecommend"
|
||||||
|
:disabled="isRecommending || !aiStatusChecked"
|
||||||
|
height="44"
|
||||||
|
class="ps-4 pe-3 ai-recommend-btn"
|
||||||
|
:class="{ 'ai-active': showingAiResults }"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="lucide:sparkles" size="18" class="ai-icon" :class="{ 'ai-icon-active': showingAiResults }" />
|
||||||
|
</template>
|
||||||
|
<span class="ai-text" :class="{ 'ai-text-active': showingAiResults }">
|
||||||
|
{{ t('resource.aiRecommend') }}
|
||||||
|
</span>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VExpandXTransition>
|
||||||
|
<div v-if="aiRecommended || isRecommending" class="d-flex align-center">
|
||||||
|
<div class="ai-divider" :style="{ opacity: showingAiResults ? 0 : 1 }"></div>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
rounded="0"
|
||||||
|
:disabled="isRecommending || !aiStatusChecked"
|
||||||
|
@click="reRecommend"
|
||||||
|
height="44"
|
||||||
|
min-width="38"
|
||||||
|
class="px-0"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
:icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-refresh'"
|
||||||
|
size="18"
|
||||||
|
class="ai-refresh-icon"
|
||||||
|
:class="{ 'text-primary': isRecommending }"
|
||||||
|
/>
|
||||||
|
<VTooltip activator="parent" location="top">
|
||||||
|
{{ t('resource.reRecommend') }}
|
||||||
|
</VTooltip>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VExpandXTransition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 重新设计的视图切换按钮 -->
|
<!-- 重新设计的视图切换按钮 -->
|
||||||
<div class="view-toggle-container">
|
<div class="view-toggle-container">
|
||||||
<div class="view-toggle-buttons">
|
<div class="view-toggle-buttons">
|
||||||
|
<div class="active-indicator" :class="viewType"></div>
|
||||||
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
|
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
|
||||||
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
|
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
|
||||||
</button>
|
</button>
|
||||||
@@ -246,39 +665,91 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
<!-- 视图切换加载状态 -->
|
|
||||||
<VFadeTransition>
|
|
||||||
<div v-if="isRefreshed && isViewChanging" class="view-changing-container rounded-lg">
|
|
||||||
<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">{{ t('resource.switchingView') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VFadeTransition>
|
|
||||||
|
|
||||||
<!-- 搜索结果 -->
|
<!-- 搜索结果 -->
|
||||||
<div v-if="isRefreshed && dataList.length > 0 && !isViewChanging" class="search-results-container">
|
<div v-if="isRefreshed && hasData" class="search-results-container">
|
||||||
<!-- 卡片视图模式 -->
|
<!-- 筛选栏 -->
|
||||||
<VFadeTransition>
|
<TorrentFilterBar
|
||||||
<div>
|
:filter-form="torrentFilter.filterForm"
|
||||||
<TorrentCardListView v-if="viewType === 'card'" :items="dataList" />
|
:filter-options="torrentFilter.filterOptions"
|
||||||
</div>
|
:sort-field="torrentFilter.sortField.value"
|
||||||
</VFadeTransition>
|
:sort-type="torrentFilter.sortType.value"
|
||||||
|
:total-filtered-count="torrentFilter.totalFilteredCount.value"
|
||||||
|
:filter-titles="torrentFilter.filterTitles"
|
||||||
|
:sort-titles="torrentFilter.sortTitles"
|
||||||
|
:enable-animation="enableFilterAnimation"
|
||||||
|
@update:sort-field="val => (torrentFilter.sortField.value = val)"
|
||||||
|
@update:sort-type="val => (torrentFilter.sortType.value = val)"
|
||||||
|
@update:filter-form="handleFilterFormUpdate"
|
||||||
|
@select-all="handleSelectAll"
|
||||||
|
@clear-filter="handleClearFilter"
|
||||||
|
@clear-all-filters="handleClearAllFilters"
|
||||||
|
@remove-filter="handleRemoveFilter"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 列表视图模式 -->
|
<!-- 视图切换区域 -->
|
||||||
<VFadeTransition>
|
<VFadeTransition mode="out-in">
|
||||||
<div>
|
<!-- 卡片视图模式 -->
|
||||||
<TorrentRowListView v-if="viewType === 'row'" :items="dataList" />
|
<div v-if="viewType === 'card'" key="card">
|
||||||
|
<!-- 资源列表 -->
|
||||||
|
<VInfiniteScroll
|
||||||
|
mode="intersect"
|
||||||
|
side="end"
|
||||||
|
:items="cardScroll.displayDataList.value"
|
||||||
|
class="overflow-visible"
|
||||||
|
@load="cardScroll.loadMore"
|
||||||
|
>
|
||||||
|
<template #loading />
|
||||||
|
<template #empty />
|
||||||
|
<div class="grid gap-4 grid-torrent-card items-start">
|
||||||
|
<TorrentCard
|
||||||
|
v-for="item in cardScroll.displayDataList.value"
|
||||||
|
:key="`${item.torrent_info.page_url}`"
|
||||||
|
:torrent="item"
|
||||||
|
:more="item.more"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VInfiniteScroll>
|
||||||
|
<!-- 无结果时显示 -->
|
||||||
|
<div v-if="cardScroll.displayDataList.value.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">{{ t('torrent.noResults') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表视图模式 -->
|
||||||
|
<div v-else-if="viewType === 'row'" key="row">
|
||||||
|
<VCard class="resource-list-container">
|
||||||
|
<!-- 无结果时显示 -->
|
||||||
|
<div v-if="rowScroll.displayDataList.value.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">{{ t('torrent.noResults') }}</div>
|
||||||
|
</div>
|
||||||
|
<!-- 资源列表 -->
|
||||||
|
<VInfiniteScroll
|
||||||
|
v-else
|
||||||
|
mode="intersect"
|
||||||
|
side="end"
|
||||||
|
:items="rowScroll.displayDataList.value"
|
||||||
|
class="resource-list overflow-visible"
|
||||||
|
@load="rowScroll.loadMore"
|
||||||
|
>
|
||||||
|
<template #loading />
|
||||||
|
<template #empty />
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in rowScroll.displayDataList.value"
|
||||||
|
:key="`${item.torrent_info?.enclosure || ''}-${index}`"
|
||||||
|
>
|
||||||
|
<TorrentItem :torrent="item" />
|
||||||
|
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
|
||||||
|
</div>
|
||||||
|
</VInfiniteScroll>
|
||||||
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</VFadeTransition>
|
</VFadeTransition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 无数据显示 -->
|
<!-- 无数据显示 -->
|
||||||
<div v-else-if="isRefreshed && !isViewChanging" class="d-flex flex-column align-center justify-center py-8">
|
<div v-else-if="isRefreshed" class="d-flex flex-column align-center justify-center py-8">
|
||||||
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" />
|
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" />
|
||||||
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/">
|
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/">
|
||||||
{{ t('resource.backToHome') }}
|
{{ t('resource.backToHome') }}
|
||||||
@@ -343,8 +814,8 @@ onUnmounted(() => {
|
|||||||
/* 精简标题栏样式 */
|
/* 精简标题栏样式 */
|
||||||
.search-header {
|
.search-header {
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
padding-block: 12px;
|
padding-block: 8px;
|
||||||
padding-inline: 16px;
|
padding-inline: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-info-container {
|
.search-info-container {
|
||||||
@@ -374,6 +845,25 @@ onUnmounted(() => {
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate; /* Create new stacking context */
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
width: 40px;
|
||||||
|
height: 36px;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-indicator.row {
|
||||||
|
transform: translateX(40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-toggle-btn {
|
.view-toggle-btn {
|
||||||
@@ -381,79 +871,90 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
block-size: 36px;
|
block-size: 36px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
inline-size: 40px;
|
inline-size: 40px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
z-index: 2; /* Sit on top of indicator */
|
||||||
|
position: relative;
|
||||||
.view-toggle-btn.active {
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 10%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-toggle-btn:hover:not(.active) {
|
.view-toggle-btn:hover:not(.active) {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 视图切换加载状态 */
|
/* AI按钮组样式 */
|
||||||
.view-changing-container {
|
.ai-toggle-container {
|
||||||
position: absolute;
|
position: relative;
|
||||||
z-index: 10;
|
}
|
||||||
|
|
||||||
|
.ai-toggle-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
padding: 0;
|
||||||
backdrop-filter: blur(8px);
|
border-radius: 8px;
|
||||||
inset: 0;
|
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-changing-content {
|
.ai-recommend-btn {
|
||||||
display: flex;
|
transition: all 0.3s ease;
|
||||||
flex-direction: column;
|
margin: 0;
|
||||||
align-items: center;
|
height: 100% !important;
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-loader {
|
/* 仅为激活的按钮添加背景 */
|
||||||
display: flex;
|
.ai-recommend-btn.ai-active {
|
||||||
gap: 8px;
|
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-circle {
|
/* 图标基础样式 */
|
||||||
border-radius: 50%;
|
.ai-icon {
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
background-color: rgb(var(--v-theme-primary));
|
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
block-size: 12px;
|
|
||||||
inline-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-circle:nth-child(2) {
|
/* 激活状态图标:变色 + 辉光 (移除动画) */
|
||||||
animation-delay: 0.2s;
|
.ai-icon-active {
|
||||||
}
|
|
||||||
|
|
||||||
.pulse-circle:nth-child(3) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.5;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-changing-text {
|
|
||||||
color: rgb(var(--v-theme-primary));
|
color: rgb(var(--v-theme-primary));
|
||||||
font-size: 0.9rem;
|
filter: drop-shadow(0 0 4px rgba(var(--v-theme-primary), 0.5));
|
||||||
font-weight: 500;
|
}
|
||||||
letter-spacing: 1px;
|
|
||||||
|
/* 文字基础样式 */
|
||||||
|
.ai-text {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
|
font-weight: 600; /* 保持一致的字重防止位移 */
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 激活状态文字:渐变流光 (调整为更柔和的蓝紫色) */
|
||||||
|
.ai-text-active {
|
||||||
|
background: linear-gradient(90deg, rgb(var(--v-theme-primary)) 0%, #8b5cf6 100%);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
/* 备用颜色,防止浏览器不支持 background-clip */
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 刷新图标样式 */
|
||||||
|
.ai-refresh-icon {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-divider {
|
||||||
|
width: 0; /* 宽度设为0,不占用空间 */
|
||||||
|
height: 20px;
|
||||||
|
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.12); /* 使用边框显示线条 */
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-results-container {
|
.search-results-container {
|
||||||
@@ -461,27 +962,52 @@ onUnmounted(() => {
|
|||||||
min-block-size: 50vh;
|
min-block-size: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 卡片网格布局 */
|
||||||
|
.grid-torrent-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表视图样式 */
|
||||||
|
.resource-list-container {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 无结果提示 */
|
||||||
|
.no-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-block-size: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (width <= 600px) {
|
@media (width <= 600px) {
|
||||||
.search-header {
|
.search-header {
|
||||||
padding-block: 8px;
|
padding-block: 6px;
|
||||||
padding-inline: 12px;
|
padding-inline: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-title {
|
.search-title {
|
||||||
font-size: 1.2rem;
|
font-size: 1.1rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-info-container {
|
.search-info-container {
|
||||||
overflow: hidden;
|
|
||||||
flex: 1;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-tags {
|
.search-tags {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
margin-inline-end: 8px;
|
margin-inline-end: 4px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
@@ -498,9 +1024,43 @@ onUnmounted(() => {
|
|||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-indicator {
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 36px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-indicator.row {
|
||||||
|
transform: translateX(36px);
|
||||||
|
}
|
||||||
|
|
||||||
.view-toggle-btn {
|
.view-toggle-btn {
|
||||||
block-size: 32px;
|
block-size: 32px;
|
||||||
inline-size: 36px;
|
inline-size: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-toggle-buttons {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-recommend-btn,
|
||||||
|
.ai-toggle-buttons .v-btn {
|
||||||
|
height: 36px !important;
|
||||||
|
min-width: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-recommend-btn {
|
||||||
|
padding-inline-start: 12px !important;
|
||||||
|
padding-inline-end: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-toggle-buttons .v-btn:last-child {
|
||||||
|
min-width: 32px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import type { globalSettingsState } from '@/stores/types'
|
import type { globalSettingsState } from '@/stores/types'
|
||||||
import { fetchGlobalSettings } from '@/utils/globalSetting'
|
import { fetchGlobalSettings } from '@/utils/globalSetting'
|
||||||
import { useVersionChecker } from '@/composables/useVersionChecker'
|
import { useVersionChecker } from '@/composables/useVersionChecker'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
export const useGlobalSettingsStore = defineStore('globalSettings', {
|
export const useGlobalSettingsStore = defineStore('globalSettings', {
|
||||||
state: (): globalSettingsState => ({
|
state: (): globalSettingsState => ({
|
||||||
@@ -32,6 +33,19 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 登录后加载用户相关设置
|
||||||
|
async loadUserSettings() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/global/user')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
// 合并用户设置到现有数据
|
||||||
|
this.data = { ...this.data, ...result.data }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user settings', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
setData(data: { [key: string]: any }) {
|
setData(data: { [key: string]: any }) {
|
||||||
this.data = data
|
this.data = data
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const SystemSettings = ref<any>({
|
|||||||
LLM_MODEL: 'deepseek-chat',
|
LLM_MODEL: 'deepseek-chat',
|
||||||
LLM_API_KEY: null,
|
LLM_API_KEY: null,
|
||||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||||
|
AI_RECOMMEND_ENABLED: false,
|
||||||
|
AI_RECOMMEND_USER_PREFERENCE: null,
|
||||||
|
AI_RECOMMEND_MAX_ITEMS: 50,
|
||||||
},
|
},
|
||||||
// 高级系统设置
|
// 高级系统设置
|
||||||
Advanced: {
|
Advanced: {
|
||||||
@@ -716,6 +719,35 @@ onDeactivated(() => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||||
|
<VSwitch
|
||||||
|
v-model="SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||||
|
:label="t('setting.system.aiRecommendEnabled')"
|
||||||
|
:hint="t('setting.system.aiRecommendEnabledHint')"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED" cols="12">
|
||||||
|
<VTextarea
|
||||||
|
v-model="SystemSettings.Basic.AI_RECOMMEND_USER_PREFERENCE"
|
||||||
|
:label="t('setting.system.aiRecommendUserPreference')"
|
||||||
|
:hint="t('setting.system.aiRecommendUserPreferenceHint')"
|
||||||
|
persistent-hint
|
||||||
|
rows="1"
|
||||||
|
auto-grow
|
||||||
|
prepend-inner-icon="mdi-account-heart"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED" cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model.number="SystemSettings.Basic.AI_RECOMMEND_MAX_ITEMS"
|
||||||
|
:label="t('setting.system.aiRecommendMaxItems')"
|
||||||
|
:hint="t('setting.system.aiRecommendMaxItemsHint')"
|
||||||
|
persistent-hint
|
||||||
|
type="number"
|
||||||
|
prepend-inner-icon="mdi-format-list-numbered"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|||||||
@@ -1,918 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { cloneDeepWith } from 'lodash-es'
|
|
||||||
import type { Context } from '@/api/types'
|
|
||||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
|
|
||||||
// 显示器宽度
|
|
||||||
const display = useDisplay()
|
|
||||||
|
|
||||||
// 国际化
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
interface SearchTorrent extends Context {
|
|
||||||
more?: Array<Context>
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义输入参数
|
|
||||||
const props = defineProps({
|
|
||||||
// 数据列表
|
|
||||||
items: Array as PropType<SearchTorrent[]>,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 过滤表单
|
|
||||||
const filterForm: Record<string, string[]> = reactive({
|
|
||||||
// 站点
|
|
||||||
site: [] as string[],
|
|
||||||
// 季
|
|
||||||
season: [] as string[],
|
|
||||||
// 制作组
|
|
||||||
releaseGroup: [] as string[],
|
|
||||||
// 视频编码
|
|
||||||
videoCode: [] as string[],
|
|
||||||
// 促销状态
|
|
||||||
freeState: [] as string[],
|
|
||||||
// 质量
|
|
||||||
edition: [] as string[],
|
|
||||||
// 分辨率
|
|
||||||
resolution: [] as string[],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 排序选项
|
|
||||||
const sortField = ref('default')
|
|
||||||
// 降序
|
|
||||||
const sortType = ref<'asc' | 'desc'>('desc')
|
|
||||||
|
|
||||||
const sortTitles: Record<string, string> = {
|
|
||||||
default: t('torrent.sortDefault'),
|
|
||||||
site: t('torrent.sortSite'),
|
|
||||||
size: t('torrent.sortSize'),
|
|
||||||
seeder: t('torrent.sortSeeder'),
|
|
||||||
publishTime: t('torrent.sortPublishTime'),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤项映射
|
|
||||||
const filterTitles: Record<string, string> = {
|
|
||||||
site: t('torrent.filterSite'),
|
|
||||||
season: t('torrent.filterSeason'),
|
|
||||||
freeState: t('torrent.filterFreeState'),
|
|
||||||
videoCode: t('torrent.filterVideoCode'),
|
|
||||||
edition: t('torrent.filterEdition'),
|
|
||||||
resolution: t('torrent.filterResolution'),
|
|
||||||
releaseGroup: t('torrent.filterReleaseGroup'),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一存储过滤选项
|
|
||||||
const filterOptions: Record<string, string[]> = reactive({
|
|
||||||
site: [] as string[],
|
|
||||||
season: [] as string[],
|
|
||||||
freeState: [] as string[],
|
|
||||||
edition: [] as string[],
|
|
||||||
resolution: [] as string[],
|
|
||||||
videoCode: [] as string[],
|
|
||||||
releaseGroup: [] as string[],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 完整的数据列表
|
|
||||||
let dataList: SearchTorrent[]
|
|
||||||
|
|
||||||
// 显示用的数据列表
|
|
||||||
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(() => {
|
|
||||||
return filterOptions[currentFilter.value]
|
|
||||||
})
|
|
||||||
|
|
||||||
// 添加全部筛选菜单相关
|
|
||||||
const allFilterMenuOpen = ref(false)
|
|
||||||
|
|
||||||
// 初始化过滤选项
|
|
||||||
function initOptions(data: Context) {
|
|
||||||
const { torrent_info, meta_info } = data
|
|
||||||
const optionValue = (options: Array<string>, value: string | undefined) => {
|
|
||||||
if (value && !options.includes(value)) {
|
|
||||||
options.push(value)
|
|
||||||
// 如果是season选项,立即进行排序
|
|
||||||
if (options === filterOptions.season) {
|
|
||||||
sortSeasonOptions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
optionValue(filterOptions.site, torrent_info?.site_name)
|
|
||||||
optionValue(filterOptions.season, meta_info?.season_episode)
|
|
||||||
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
|
|
||||||
optionValue(filterOptions.videoCode, meta_info?.video_encode)
|
|
||||||
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
|
|
||||||
optionValue(filterOptions.edition, meta_info?.edition)
|
|
||||||
optionValue(filterOptions.resolution, meta_info?.resource_pix)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接对季集选项进行排序的函数
|
|
||||||
function sortSeasonOptions() {
|
|
||||||
if (filterOptions.season.length <= 1) {
|
|
||||||
return // 不需要排序
|
|
||||||
}
|
|
||||||
|
|
||||||
// 预解析所有选项
|
|
||||||
const parsedOptions = filterOptions.season.map((option, index) => {
|
|
||||||
// 修改正则表达式以适配 "S01 E07" 格式(注意季号和集号之间的空格)
|
|
||||||
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
// 格式不符合规范的放到最后
|
|
||||||
return {
|
|
||||||
original: option,
|
|
||||||
seasonNum: 0,
|
|
||||||
episodeNum: 0,
|
|
||||||
maxEpisodeNum: 0,
|
|
||||||
isWholeSeason: false,
|
|
||||||
index,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seasonNum = parseInt(match[1], 10)
|
|
||||||
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
|
|
||||||
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
|
|
||||||
const isWholeSeason = !match[3] // 没有E部分表示整季
|
|
||||||
|
|
||||||
return {
|
|
||||||
original: option,
|
|
||||||
seasonNum,
|
|
||||||
episodeNum,
|
|
||||||
maxEpisodeNum,
|
|
||||||
isWholeSeason,
|
|
||||||
index,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 先对所有项进行分类
|
|
||||||
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
|
|
||||||
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
|
|
||||||
|
|
||||||
// 对整季按季号降序排序
|
|
||||||
wholeSeasons.sort((a, b) => {
|
|
||||||
if (a.seasonNum !== b.seasonNum) {
|
|
||||||
return b.seasonNum - a.seasonNum // 季号降序
|
|
||||||
}
|
|
||||||
return a.index - b.index // 相同季号按原始索引
|
|
||||||
})
|
|
||||||
|
|
||||||
// 对单集先按季号降序排序,季号相同时按集号降序排序
|
|
||||||
episodes.sort((a, b) => {
|
|
||||||
if (a.seasonNum !== b.seasonNum) {
|
|
||||||
return b.seasonNum - a.seasonNum // 季号降序
|
|
||||||
}
|
|
||||||
// 使用最大集号进行排序 (对于范围如 E01-E06)
|
|
||||||
const aMaxEp = a.maxEpisodeNum || a.episodeNum
|
|
||||||
const bMaxEp = b.maxEpisodeNum || b.episodeNum
|
|
||||||
if (aMaxEp !== bMaxEp) {
|
|
||||||
return bMaxEp - aMaxEp // 集号降序
|
|
||||||
}
|
|
||||||
// 如果最大集号相同,再比较起始集号
|
|
||||||
if (a.episodeNum !== b.episodeNum) {
|
|
||||||
return b.episodeNum - a.episodeNum
|
|
||||||
}
|
|
||||||
return a.index - b.index // 都相同时按原始索引
|
|
||||||
})
|
|
||||||
|
|
||||||
// 合并结果:整季在前,单集在后
|
|
||||||
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
|
|
||||||
|
|
||||||
// 直接更新 filterOptions.season
|
|
||||||
filterOptions.season = sortedOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算分组后的列表
|
|
||||||
onMounted(() => {
|
|
||||||
// 数据分组
|
|
||||||
const groupMap = new Map<string, Context[]>()
|
|
||||||
// 遍历数据
|
|
||||||
props.items?.forEach(item => {
|
|
||||||
const { torrent_info, meta_info } = item
|
|
||||||
// init options
|
|
||||||
initOptions(item)
|
|
||||||
// group data
|
|
||||||
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
|
|
||||||
if (groupMap.has(key)) {
|
|
||||||
// 已入库相同标题和大小的分组,将当前上下文信息添加到分组中
|
|
||||||
const group = groupMap.get(key)
|
|
||||||
group?.push(item)
|
|
||||||
} else {
|
|
||||||
// 创建新的分组,并将当前上下文信息添加到分组中
|
|
||||||
groupMap.set(key, [item])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
groupedDataList.value = groupMap
|
|
||||||
|
|
||||||
// 确保季集选项排序
|
|
||||||
if (filterOptions.season.length > 0) {
|
|
||||||
sortSeasonOptions()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 修改watch监听,同时监听排序字段的变化
|
|
||||||
watch([filterForm, groupedDataList, sortField, sortType], filterData)
|
|
||||||
|
|
||||||
function filterData() {
|
|
||||||
// 清空列表
|
|
||||||
dataList = []
|
|
||||||
displayDataList.value = []
|
|
||||||
// 匹配过滤函数,filter中有任一值包含value则返回true
|
|
||||||
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 => {
|
|
||||||
const { meta_info, torrent_info } = data
|
|
||||||
// 季、制作组、视频编码
|
|
||||||
return (
|
|
||||||
// 站点过滤
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
if (matchData.length > 0) {
|
|
||||||
const firstData = cloneDeepWith(matchData[0]) as SearchTorrent
|
|
||||||
if (matchData.length > 1) firstData.more = matchData.slice(1)
|
|
||||||
filteredData.push(firstData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 排序数据
|
|
||||||
if (sortField.value !== 'default') {
|
|
||||||
filteredData.sort((a, b) => {
|
|
||||||
if (sortType.value === 'desc') {
|
|
||||||
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)
|
|
||||||
} else if (sortField.value === 'publishTime') {
|
|
||||||
// 按发布时间排序(降序,最新的在前)
|
|
||||||
return new Date(b.torrent_info.pubdate || 0).getTime() - new Date(a.torrent_info.pubdate || 0).getTime()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (sortField.value === 'site') {
|
|
||||||
// 按站点名称排序
|
|
||||||
return (b.torrent_info.site_name || '').localeCompare(a.torrent_info.site_name || '')
|
|
||||||
} else if (sortField.value === 'size') {
|
|
||||||
// 按文件大小排序(降序)
|
|
||||||
return (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
|
|
||||||
} else if (sortField.value === 'seeder') {
|
|
||||||
// 按做种数排序(降序)
|
|
||||||
return (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
|
|
||||||
} else if (sortField.value === 'publishTime') {
|
|
||||||
// 按发布时间排序(升序,最旧的在前)
|
|
||||||
return new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
// 如果是季集选项,确保已排序
|
|
||||||
if (key === 'season' && filterOptions.season.length > 0) {
|
|
||||||
sortSeasonOptions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开关全部筛选菜单
|
|
||||||
function toggleAllFilterMenu() {
|
|
||||||
allFilterMenuOpen.value = !allFilterMenuOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除所有过滤条件
|
|
||||||
function clearAllFilters() {
|
|
||||||
for (const key in filterForm) {
|
|
||||||
filterForm[key] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除某个过滤项
|
|
||||||
function clearFilter(key: string) {
|
|
||||||
filterForm[key] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全选某个过滤项
|
|
||||||
function selectAll(key: string) {
|
|
||||||
// 不再需要特殊处理季集选项
|
|
||||||
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 }) {
|
|
||||||
// 从 dataList 中获取最前面的 20 个元素
|
|
||||||
const itemsToMove = dataList.splice(0, 20)
|
|
||||||
displayDataList.value.push(...itemsToMove)
|
|
||||||
done('ok')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理图标点击
|
|
||||||
const handleSortIconClick = () => {
|
|
||||||
// 切换排序方向
|
|
||||||
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="search-header d-none d-sm-flex mb-3">
|
|
||||||
<!-- 页面头部和筛选栏 -->
|
|
||||||
<VCard class="view-header 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 }} {{ t('torrent.resources') }}
|
|
||||||
</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"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
class="sort-select"
|
|
||||||
variant="plain"
|
|
||||||
>
|
|
||||||
<template #prepend-inner>
|
|
||||||
<!-- 添加排序点击事件 -->
|
|
||||||
<VIcon @mousedown.stop.prevent="handleSortIconClick">
|
|
||||||
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
|
|
||||||
</VIcon>
|
|
||||||
</template>
|
|
||||||
</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)"
|
|
||||||
class="filter-btn"
|
|
||||||
rounded="pill"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
|
||||||
{{ filterForm[key].length }}
|
|
||||||
</VChip>
|
|
||||||
<VMenu activator="parent" :close-on-content-click="false" scrim>
|
|
||||||
<VCard max-width="25rem">
|
|
||||||
<VCardText class="filter-menu-content">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
|
||||||
{{ t('torrent.selectAll') }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn
|
|
||||||
v-if="filterForm[key].length > 0"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
@click="clearFilter(key)"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clear') }}
|
|
||||||
</VBtn>
|
|
||||||
</div>
|
|
||||||
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
|
|
||||||
<VChip
|
|
||||||
v-for="option in filterOptions[key]"
|
|
||||||
:key="option"
|
|
||||||
:value="option"
|
|
||||||
filter
|
|
||||||
variant="elevated"
|
|
||||||
class="ma-1 filter-chip"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{ option }}
|
|
||||||
</VChip>
|
|
||||||
</VChipGroup>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VMenu>
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<!-- 全部筛选按钮 -->
|
|
||||||
<VBtn
|
|
||||||
variant="tonal"
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
class="filter-btn ms-2"
|
|
||||||
prepend-icon="mdi-filter-variant"
|
|
||||||
rounded="pill"
|
|
||||||
@click="toggleAllFilterMenu"
|
|
||||||
>
|
|
||||||
{{ t('torrent.allFilters') }}
|
|
||||||
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
|
||||||
{{ getFilterCount }}
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clearFilters') }}
|
|
||||||
</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="small" :icon="getFilterIcon(key)" class="me-1"></VIcon>
|
|
||||||
<strong>{{ filterTitles[key] }}:</strong> {{ value }}
|
|
||||||
</VChip>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 移动端头部和筛选区域 -->
|
|
||||||
<VCard class="d-block d-sm-none search-header-mobile mb-3">
|
|
||||||
<!-- 移动端头部 -->
|
|
||||||
<div class="view-header">
|
|
||||||
<div class="d-flex align-center flex-wrap pa-2">
|
|
||||||
<div class="d-flex align-center w-100 mb-2">
|
|
||||||
<VChip
|
|
||||||
color="primary"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="search-count me-auto"
|
|
||||||
prepend-icon="mdi-magnify"
|
|
||||||
>
|
|
||||||
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
|
|
||||||
</VChip>
|
|
||||||
|
|
||||||
<!-- 排序选择 -->
|
|
||||||
<VSelect
|
|
||||||
v-model="sortField"
|
|
||||||
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
|
||||||
item-title="title"
|
|
||||||
item-value="value"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
class="mobile-sort-select"
|
|
||||||
variant="plain"
|
|
||||||
>
|
|
||||||
<template #prepend-inner>
|
|
||||||
<!-- 添加排序点击事件 -->
|
|
||||||
<VIcon @mousedown.stop.prevent="handleSortIconClick">
|
|
||||||
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
|
|
||||||
</VIcon>
|
|
||||||
</template>
|
|
||||||
</VSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 筛选图标按钮区域 -->
|
|
||||||
<div class="filter-buttons-grid w-100 mt-2">
|
|
||||||
<!-- 全部筛选按钮 -->
|
|
||||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
|
||||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
|
||||||
<span class="filter-label">
|
|
||||||
{{ t('torrent.allFilters') }}
|
|
||||||
</span>
|
|
||||||
<VBadge
|
|
||||||
v-if="getFilterCount > 0"
|
|
||||||
:content="getFilterCount"
|
|
||||||
color="primary"
|
|
||||||
location="top end"
|
|
||||||
offset-x="-10"
|
|
||||||
offset-y="-10"
|
|
||||||
></VBadge>
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<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 me-1"></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>
|
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<!-- 全部筛选弹窗 -->
|
|
||||||
<VDialog
|
|
||||||
v-model="allFilterMenuOpen"
|
|
||||||
max-width="50rem"
|
|
||||||
location="center"
|
|
||||||
scrollable
|
|
||||||
:fullscreen="!display.mdAndUp.value"
|
|
||||||
>
|
|
||||||
<VCard>
|
|
||||||
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
|
|
||||||
<VCardTitle class="py-3 d-flex align-center">
|
|
||||||
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
|
|
||||||
<span>{{ t('torrent.allFilters') }}</span>
|
|
||||||
<VSpacer />
|
|
||||||
<VBtn
|
|
||||||
v-if="getFilterCount > 0"
|
|
||||||
class="me-10"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
@click="clearAllFilters"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clearAll') }}
|
|
||||||
</VBtn>
|
|
||||||
</VCardTitle>
|
|
||||||
<VDivider />
|
|
||||||
<VCardText>
|
|
||||||
<div class="all-filters-grid">
|
|
||||||
<VCard
|
|
||||||
v-for="(title, key) in filterTitles"
|
|
||||||
variant="tonal"
|
|
||||||
:key="key"
|
|
||||||
class="filter-section"
|
|
||||||
v-show="filterOptions[key].length > 0"
|
|
||||||
>
|
|
||||||
<VCardItem class="py-2">
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ title }}</VCardTitle>
|
|
||||||
<template #append>
|
|
||||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
|
||||||
{{ t('torrent.selectAll') }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn
|
|
||||||
v-if="filterForm[key].length > 0"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
@click="clearFilter(key)"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clear') }}
|
|
||||||
</VBtn>
|
|
||||||
</template>
|
|
||||||
</VCardItem>
|
|
||||||
<VCardText>
|
|
||||||
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
|
|
||||||
<VChip
|
|
||||||
v-for="option in filterOptions[key]"
|
|
||||||
:key="option"
|
|
||||||
:value="option"
|
|
||||||
filter
|
|
||||||
variant="elevated"
|
|
||||||
class="ma-1 filter-chip"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{ option }}
|
|
||||||
</VChip>
|
|
||||||
</VChipGroup>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
|
|
||||||
<!-- 筛选弹窗 -->
|
|
||||||
<VDialog v-model="filterMenuOpen" max-width="25rem" location="center" max-height="85vh" scrollable>
|
|
||||||
<VCard>
|
|
||||||
<VCardTitle class="py-3 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)"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clear') }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
|
|
||||||
{{ t('torrent.selectAll') }}
|
|
||||||
</VBtn>
|
|
||||||
</VCardTitle>
|
|
||||||
<VDivider />
|
|
||||||
<VCardText>
|
|
||||||
<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 color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
|
|
||||||
{{ t('torrent.confirm') }}
|
|
||||||
</VBtn>
|
|
||||||
</VCardActions>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
|
|
||||||
<!-- 资源列表 -->
|
|
||||||
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-visible" @load="loadMore">
|
|
||||||
<template #loading />
|
|
||||||
<template #empty />
|
|
||||||
<div class="grid gap-4 grid-torrent-card items-start">
|
|
||||||
<TorrentCard
|
|
||||||
v-for="item in displayDataList"
|
|
||||||
:key="`${item.torrent_info.page_url}`"
|
|
||||||
:torrent="item"
|
|
||||||
:more="item.more"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</VInfiniteScroll>
|
|
||||||
|
|
||||||
<!-- 无结果时显示 -->
|
|
||||||
<div v-if="displayDataList.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">{{ t('torrent.noResults') }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.search-header {
|
|
||||||
position: sticky;
|
|
||||||
z-index: 10;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
inset-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-header {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-container {
|
|
||||||
border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
|
||||||
padding-inline-end: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
min-inline-size: 0;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-filters {
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 0 0 12px 12px;
|
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-menu-content {
|
|
||||||
max-block-size: 50vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-options {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip {
|
|
||||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
|
||||||
margin: 4px;
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
|
||||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip:hover {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip.v-chip--selected {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
|
||||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
|
||||||
color: rgb(var(--v-theme-on-primary)) !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width <= 600px) {
|
|
||||||
.filter-btn {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-container {
|
|
||||||
border-inline-end: none;
|
|
||||||
inline-size: 100%;
|
|
||||||
margin-block-end: 8px;
|
|
||||||
padding-inline-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar {
|
|
||||||
inline-size: 100%;
|
|
||||||
margin-block-start: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-sort-select {
|
|
||||||
max-inline-size: 130px;
|
|
||||||
min-inline-size: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-buttons-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn-mobile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: rgba(var(--v-theme-surface), 0.5);
|
|
||||||
block-size: auto;
|
|
||||||
min-block-size: 48px;
|
|
||||||
padding-block: 4px;
|
|
||||||
padding-inline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-block-end: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-header-mobile {
|
|
||||||
position: sticky;
|
|
||||||
z-index: 10;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
background-color: rgba(var(--v-theme-background), 0.95);
|
|
||||||
inset-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.all-filters-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 24px;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,910 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import type { Context } from '@/api/types'
|
|
||||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
|
|
||||||
// 显示器宽度
|
|
||||||
const display = useDisplay()
|
|
||||||
|
|
||||||
// 国际化
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// 定义输入参数
|
|
||||||
const props = defineProps({
|
|
||||||
items: Array as PropType<Context[]>,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 过滤表单
|
|
||||||
const filterForm: Record<string, string[]> = reactive({
|
|
||||||
// 站点
|
|
||||||
site: [] as string[],
|
|
||||||
// 季
|
|
||||||
season: [] as string[],
|
|
||||||
// 制作组
|
|
||||||
releaseGroup: [] as string[],
|
|
||||||
// 视频编码
|
|
||||||
videoCode: [] as string[],
|
|
||||||
// 促销状态
|
|
||||||
freeState: [] as string[],
|
|
||||||
// 质量
|
|
||||||
edition: [] as string[],
|
|
||||||
// 分辨率
|
|
||||||
resolution: [] as string[],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 过滤项映射(保持中文标题)
|
|
||||||
const filterTitles: Record<string, string> = {
|
|
||||||
site: t('torrent.filterSite'),
|
|
||||||
season: t('torrent.filterSeason'),
|
|
||||||
freeState: t('torrent.filterFreeState'),
|
|
||||||
videoCode: t('torrent.filterVideoCode'),
|
|
||||||
edition: t('torrent.filterEdition'),
|
|
||||||
resolution: t('torrent.filterResolution'),
|
|
||||||
releaseGroup: t('torrent.filterReleaseGroup'),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 排序中文名
|
|
||||||
const sortTitles: Record<string, string> = {
|
|
||||||
default: t('torrent.sortDefault'),
|
|
||||||
site: t('torrent.sortSite'),
|
|
||||||
size: t('torrent.sortSize'),
|
|
||||||
seeder: t('torrent.sortSeeder'),
|
|
||||||
publishTime: t('torrent.sortPublishTime'),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一存储过滤选项
|
|
||||||
const filterOptions: Record<string, string[]> = reactive({
|
|
||||||
site: [] as string[],
|
|
||||||
season: [] as string[],
|
|
||||||
freeState: [] as string[],
|
|
||||||
edition: [] as string[],
|
|
||||||
resolution: [] as string[],
|
|
||||||
videoCode: [] as string[],
|
|
||||||
releaseGroup: [] as string[],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 排序字段
|
|
||||||
const sortField = ref('default')
|
|
||||||
// 降序
|
|
||||||
const sortType = ref<'asc' | 'desc'>('desc')
|
|
||||||
|
|
||||||
// 数据列表
|
|
||||||
const dataList = ref<Array<Context>>([])
|
|
||||||
|
|
||||||
// 显示用的数据列表
|
|
||||||
const displayDataList = 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
|
|
||||||
const optionValue = (options: Array<string>, value: string | undefined) => {
|
|
||||||
if (value && !options.includes(value)) {
|
|
||||||
options.push(value)
|
|
||||||
// 如果是season选项,立即触发重新计算
|
|
||||||
if (options === filterOptions.season) {
|
|
||||||
// 季集选项排序
|
|
||||||
sortSeasonOptions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
optionValue(filterOptions.site, torrent_info?.site_name)
|
|
||||||
optionValue(filterOptions.season, meta_info?.season_episode)
|
|
||||||
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
|
|
||||||
optionValue(filterOptions.videoCode, meta_info?.video_encode)
|
|
||||||
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
|
|
||||||
optionValue(filterOptions.edition, meta_info?.edition)
|
|
||||||
optionValue(filterOptions.resolution, meta_info?.resource_pix)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接在组件中添加季集排序函数,而不是用计算属性
|
|
||||||
function sortSeasonOptions() {
|
|
||||||
if (filterOptions.season.length <= 1) {
|
|
||||||
return // 不需要排序
|
|
||||||
}
|
|
||||||
|
|
||||||
// 预解析所有选项
|
|
||||||
const parsedOptions = filterOptions.season.map((option, index) => {
|
|
||||||
// 修改正则表达式以适配 "S01 E07" 格式(注意季号和集号之间的空格)
|
|
||||||
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
// 格式不符合规范的放到最后
|
|
||||||
return {
|
|
||||||
original: option,
|
|
||||||
seasonNum: 0,
|
|
||||||
episodeNum: 0,
|
|
||||||
maxEpisodeNum: 0,
|
|
||||||
isWholeSeason: false,
|
|
||||||
index,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seasonNum = parseInt(match[1], 10)
|
|
||||||
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
|
|
||||||
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
|
|
||||||
const isWholeSeason = !match[3] // 没有E部分表示整季
|
|
||||||
|
|
||||||
return {
|
|
||||||
original: option,
|
|
||||||
seasonNum,
|
|
||||||
episodeNum,
|
|
||||||
maxEpisodeNum,
|
|
||||||
isWholeSeason,
|
|
||||||
index,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 先对所有项进行分类
|
|
||||||
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
|
|
||||||
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
|
|
||||||
|
|
||||||
// 对整季按季号降序排序
|
|
||||||
wholeSeasons.sort((a, b) => {
|
|
||||||
if (a.seasonNum !== b.seasonNum) {
|
|
||||||
return b.seasonNum - a.seasonNum // 季号降序
|
|
||||||
}
|
|
||||||
return a.index - b.index // 相同季号按原始索引
|
|
||||||
})
|
|
||||||
|
|
||||||
// 对单集先按季号降序排序,季号相同时按集号降序排序
|
|
||||||
episodes.sort((a, b) => {
|
|
||||||
if (a.seasonNum !== b.seasonNum) {
|
|
||||||
return b.seasonNum - a.seasonNum // 季号降序
|
|
||||||
}
|
|
||||||
// 使用最大集号进行排序 (对于范围如 E01-E06)
|
|
||||||
const aMaxEp = a.maxEpisodeNum || a.episodeNum
|
|
||||||
const bMaxEp = b.maxEpisodeNum || b.episodeNum
|
|
||||||
if (aMaxEp !== bMaxEp) {
|
|
||||||
return bMaxEp - aMaxEp // 集号降序
|
|
||||||
}
|
|
||||||
// 如果最大集号相同,再比较起始集号
|
|
||||||
if (a.episodeNum !== b.episodeNum) {
|
|
||||||
return b.episodeNum - a.episodeNum
|
|
||||||
}
|
|
||||||
return a.index - b.index // 都相同时按原始索引
|
|
||||||
})
|
|
||||||
|
|
||||||
// 合并结果:整季在前,单集在后
|
|
||||||
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
|
|
||||||
|
|
||||||
// 直接更新 filterOptions.season
|
|
||||||
filterOptions.season = sortedOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改watch监听,同时监听排序字段的变化
|
|
||||||
watch([filterForm, sortField, sortType], filterData)
|
|
||||||
|
|
||||||
// 计算过滤后的列表
|
|
||||||
function filterData() {
|
|
||||||
// 清空列表
|
|
||||||
dataList.value = []
|
|
||||||
displayDataList.value = []
|
|
||||||
// 匹配过滤函数
|
|
||||||
const match = (filter: Array<string>, value: string | undefined) =>
|
|
||||||
filter.length === 0 || (value && filter.includes(value))
|
|
||||||
|
|
||||||
// 先收集所有过滤选项,再过滤数据
|
|
||||||
if (props.items?.length) {
|
|
||||||
// 首先收集所有过滤选项
|
|
||||||
props.items.forEach(data => {
|
|
||||||
initOptions(data)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 筛选数据
|
|
||||||
let filteredData: Context[] = []
|
|
||||||
|
|
||||||
// 然后根据过滤条件筛选数据
|
|
||||||
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)
|
|
||||||
) {
|
|
||||||
filteredData.push(data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 排序
|
|
||||||
if (sortType.value === 'desc') {
|
|
||||||
if (sortField.value === 'default') {
|
|
||||||
filteredData = filteredData.sort((a, b) => b.torrent_info.pri_order - a.torrent_info.pri_order)
|
|
||||||
} else if (sortField.value === 'site') {
|
|
||||||
filteredData = filteredData.sort((a, b) =>
|
|
||||||
(a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || ''),
|
|
||||||
)
|
|
||||||
} else if (sortField.value === 'size') {
|
|
||||||
filteredData = filteredData.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
|
|
||||||
} else if (sortField.value === 'seeder') {
|
|
||||||
filteredData = filteredData.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
|
|
||||||
} else if (sortField.value === 'publishTime') {
|
|
||||||
// 按发布时间排序(降序,最新的在前)
|
|
||||||
filteredData = filteredData.sort(
|
|
||||||
(a, b) => new Date(b.torrent_info.pubdate || 0).getTime() - new Date(a.torrent_info.pubdate || 0).getTime(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (sortField.value === 'default') {
|
|
||||||
filteredData = filteredData.sort((a, b) => a.torrent_info.pri_order - b.torrent_info.pri_order)
|
|
||||||
} else if (sortField.value === 'site') {
|
|
||||||
filteredData = filteredData.sort((a, b) =>
|
|
||||||
(b.torrent_info.site_name || '').localeCompare(a.torrent_info.site_name || ''),
|
|
||||||
)
|
|
||||||
} else if (sortField.value === 'size') {
|
|
||||||
filteredData = filteredData.sort((a, b) => a.torrent_info.size - b.torrent_info.size)
|
|
||||||
} else if (sortField.value === 'seeder') {
|
|
||||||
filteredData = filteredData.sort((a, b) => a.torrent_info.seeders - b.torrent_info.seeders)
|
|
||||||
} else if (sortField.value === 'publishTime') {
|
|
||||||
// 按发布时间排序(升序,最旧的在前)
|
|
||||||
filteredData = filteredData.sort(
|
|
||||||
(a, b) => new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示前20个
|
|
||||||
displayDataList.value = filteredData.slice(0, 20)
|
|
||||||
// 保存剩余数据
|
|
||||||
dataList.value = filteredData.slice(20)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保在数据筛选完成后重新排序季集选项
|
|
||||||
if (filterOptions.season.length > 0) {
|
|
||||||
// 直接排序,不再使用延时
|
|
||||||
sortSeasonOptions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤菜单相关
|
|
||||||
const filterMenuOpen = ref(false)
|
|
||||||
const currentFilter = ref('site')
|
|
||||||
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
|
|
||||||
const currentFilterOptions = computed(() => {
|
|
||||||
return filterOptions[currentFilter.value]
|
|
||||||
})
|
|
||||||
|
|
||||||
// 添加全部筛选菜单相关
|
|
||||||
const allFilterMenuOpen = ref(false)
|
|
||||||
|
|
||||||
// 开关全部筛选菜单
|
|
||||||
function toggleAllFilterMenu() {
|
|
||||||
allFilterMenuOpen.value = !allFilterMenuOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 给定过滤类型返回不同图标
|
|
||||||
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] = [...filterOptions[key]]
|
|
||||||
} 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
|
|
||||||
|
|
||||||
// 如果是季集选项,确保已排序
|
|
||||||
if (key === 'season' && filterOptions.season.length > 0) {
|
|
||||||
sortSeasonOptions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMore({ done }: { done: any }) {
|
|
||||||
// 从 dataList 中获取最前面的 20 个元素
|
|
||||||
const itemsToMove = dataList.value.splice(0, 20)
|
|
||||||
displayDataList.value.push(...itemsToMove)
|
|
||||||
done('ok')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理图标点击
|
|
||||||
const handleSortIconClick = () => {
|
|
||||||
// 切换排序方向
|
|
||||||
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
filterData()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="torrent-view">
|
|
||||||
<!-- 搜索头部容器 - 新增,用于固定在顶部 -->
|
|
||||||
<div class="search-header d-none d-sm-block">
|
|
||||||
<!-- PC端页面头部和筛选栏 -->
|
|
||||||
<VCard class="view-header mb-3">
|
|
||||||
<div class="d-flex align-center flex-wrap pa-3">
|
|
||||||
<VChip color="primary" variant="flat" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
|
|
||||||
{{ dataList.length }} {{ t('torrent.resources') }}
|
|
||||||
</VChip>
|
|
||||||
<div class="filter-bar">
|
|
||||||
<!-- 排序选择 -->
|
|
||||||
<VSelect
|
|
||||||
v-model="sortField"
|
|
||||||
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
|
||||||
item-title="title"
|
|
||||||
item-value="value"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
class="sort-select"
|
|
||||||
variant="plain"
|
|
||||||
>
|
|
||||||
<template #prepend-inner>
|
|
||||||
<!-- 添加排序点击事件 -->
|
|
||||||
<VIcon @mousedown.stop.prevent="handleSortIconClick">
|
|
||||||
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
|
|
||||||
</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)"
|
|
||||||
class="filter-btn"
|
|
||||||
rounded="pill"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
|
||||||
{{ filterForm[key].length }}
|
|
||||||
</VChip>
|
|
||||||
<VMenu activator="parent" :close-on-content-click="false" scrim>
|
|
||||||
<VCard max-width="20rem">
|
|
||||||
<VCardText class="filter-menu-content">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
|
||||||
{{ t('torrent.selectAll') }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn
|
|
||||||
v-if="filterForm[key].length > 0"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
@click="clearFilter(key)"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clear') }}
|
|
||||||
</VBtn>
|
|
||||||
</div>
|
|
||||||
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
|
|
||||||
<VChip
|
|
||||||
v-for="option in filterOptions[key]"
|
|
||||||
:key="option"
|
|
||||||
:value="option"
|
|
||||||
filter
|
|
||||||
variant="elevated"
|
|
||||||
class="ma-1 filter-chip"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{ option }}
|
|
||||||
</VChip>
|
|
||||||
</VChipGroup>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VMenu>
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<!-- 全部筛选按钮 -->
|
|
||||||
<VBtn
|
|
||||||
variant="tonal"
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
class="filter-btn me-2"
|
|
||||||
prepend-icon="mdi-filter-variant"
|
|
||||||
rounded="pill"
|
|
||||||
@click="toggleAllFilterMenu"
|
|
||||||
>
|
|
||||||
{{ t('torrent.allFilters') }}
|
|
||||||
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
|
||||||
{{ getFilterCount }}
|
|
||||||
</VChip>
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<!-- 清除全部筛选按钮 -->
|
|
||||||
<VBtn
|
|
||||||
v-if="getFilterCount > 0"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
@click="clearAllFilters"
|
|
||||||
class="filter-btn"
|
|
||||||
prepend-icon="mdi-close-circle-outline"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clearFilters') }}
|
|
||||||
</VBtn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 已选择的过滤项显示 -->
|
|
||||||
<div v-if="getFilterCount > 0" class="selected-filters">
|
|
||||||
<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="small" :icon="getFilterIcon(key)" class="me-1"></VIcon>
|
|
||||||
<strong>{{ filterTitles[key] }}:</strong> {{ value }}
|
|
||||||
</VChip>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 移动端头部和筛选区域 -->
|
|
||||||
<VCard class="d-block d-sm-none search-header-mobile mb-3">
|
|
||||||
<!-- 移动端头部 -->
|
|
||||||
<div class="view-header">
|
|
||||||
<div class="d-flex align-center flex-wrap pa-2">
|
|
||||||
<div class="d-flex align-center w-100">
|
|
||||||
<VChip
|
|
||||||
color="primary"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="search-count me-auto"
|
|
||||||
prepend-icon="mdi-magnify"
|
|
||||||
>
|
|
||||||
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
|
|
||||||
</VChip>
|
|
||||||
|
|
||||||
<!-- 排序选择 -->
|
|
||||||
<VSelect
|
|
||||||
v-model="sortField"
|
|
||||||
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
|
||||||
item-title="title"
|
|
||||||
item-value="value"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
class="mobile-sort-select"
|
|
||||||
variant="plain"
|
|
||||||
>
|
|
||||||
<template #prepend-inner>
|
|
||||||
<!-- 添加排序点击事件 -->
|
|
||||||
<VIcon @mousedown.stop.prevent="handleSortIconClick">
|
|
||||||
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
|
|
||||||
</VIcon>
|
|
||||||
</template>
|
|
||||||
</VSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 筛选图标按钮区域 -->
|
|
||||||
<div class="filter-buttons-grid w-100 mt-2">
|
|
||||||
<!-- 全部筛选按钮 -->
|
|
||||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
|
||||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
|
||||||
<span class="filter-label">
|
|
||||||
{{ t('torrent.allFilters') }}
|
|
||||||
</span>
|
|
||||||
<VBadge
|
|
||||||
v-if="getFilterCount > 0"
|
|
||||||
:content="getFilterCount"
|
|
||||||
color="primary"
|
|
||||||
location="top end"
|
|
||||||
offset-x="-10"
|
|
||||||
offset-y="-10"
|
|
||||||
></VBadge>
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<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 me-1"></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>
|
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<!-- 全部筛选弹窗 -->
|
|
||||||
<VDialog
|
|
||||||
v-model="allFilterMenuOpen"
|
|
||||||
max-width="50rem"
|
|
||||||
location="center"
|
|
||||||
scrollable
|
|
||||||
:fullscreen="!display.mdAndUp.value"
|
|
||||||
>
|
|
||||||
<VCard>
|
|
||||||
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
|
|
||||||
<VCardTitle class="py-3 d-flex align-center">
|
|
||||||
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
|
|
||||||
<span>{{ t('torrent.allFilters') }}</span>
|
|
||||||
<VSpacer />
|
|
||||||
<VBtn
|
|
||||||
v-if="getFilterCount > 0"
|
|
||||||
class="me-10"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
@click="clearAllFilters"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clearAll') }}
|
|
||||||
</VBtn>
|
|
||||||
</VCardTitle>
|
|
||||||
<VDivider />
|
|
||||||
<VCardText>
|
|
||||||
<div class="all-filters-grid">
|
|
||||||
<VCard
|
|
||||||
v-for="(title, key) in filterTitles"
|
|
||||||
variant="tonal"
|
|
||||||
:key="key"
|
|
||||||
class="filter-section"
|
|
||||||
v-show="filterOptions[key].length > 0"
|
|
||||||
>
|
|
||||||
<VCardItem class="py-2">
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ title }}</VCardTitle>
|
|
||||||
<template #append>
|
|
||||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
|
||||||
{{ t('torrent.selectAll') }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn
|
|
||||||
v-if="filterForm[key].length > 0"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
@click="clearFilter(key)"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clear') }}
|
|
||||||
</VBtn>
|
|
||||||
</template>
|
|
||||||
</VCardItem>
|
|
||||||
<VCardText>
|
|
||||||
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
|
|
||||||
<VChip
|
|
||||||
v-for="option in filterOptions[key]"
|
|
||||||
:key="option"
|
|
||||||
:value="option"
|
|
||||||
filter
|
|
||||||
variant="elevated"
|
|
||||||
class="ma-1 filter-chip"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{ option }}
|
|
||||||
</VChip>
|
|
||||||
</VChipGroup>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
|
|
||||||
<!-- 筛选弹窗 -->
|
|
||||||
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
|
|
||||||
<VCard>
|
|
||||||
<VCardTitle class="py-3 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)"
|
|
||||||
>
|
|
||||||
{{ t('torrent.clear') }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
|
|
||||||
{{ t('torrent.selectAll') }}
|
|
||||||
</VBtn>
|
|
||||||
</VCardTitle>
|
|
||||||
<VDivider />
|
|
||||||
<VCardText>
|
|
||||||
<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 color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
|
|
||||||
{{ t('torrent.confirm') }}
|
|
||||||
</VBtn>
|
|
||||||
</VCardActions>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
|
|
||||||
<!-- 资源列表容器 -->
|
|
||||||
<VCard class="resource-list-container">
|
|
||||||
<!-- 无结果时显示 -->
|
|
||||||
<div v-if="displayDataList.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">{{ t('torrent.noResults') }}</div>
|
|
||||||
</div>
|
|
||||||
<!-- 资源列表 -->
|
|
||||||
<VInfiniteScroll
|
|
||||||
v-else
|
|
||||||
mode="intersect"
|
|
||||||
side="end"
|
|
||||||
:items="displayDataList"
|
|
||||||
class="resource-list overflow-visible"
|
|
||||||
@load="loadMore"
|
|
||||||
>
|
|
||||||
<template #loading />
|
|
||||||
<template #empty />
|
|
||||||
<div v-for="(item, index) in displayDataList" :key="`${item.torrent_info?.enclosure || ''}-${index}`">
|
|
||||||
<TorrentItem :torrent="item" />
|
|
||||||
<VDivider v-if="index < displayDataList.length - 1" class="my-2" />
|
|
||||||
</div>
|
|
||||||
</VInfiniteScroll>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.torrent-view {
|
|
||||||
position: relative;
|
|
||||||
block-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-header {
|
|
||||||
position: sticky;
|
|
||||||
z-index: 10;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
inset-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-header-mobile {
|
|
||||||
position: sticky;
|
|
||||||
z-index: 10;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
inset-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-header {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-count {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-divider {
|
|
||||||
background-color: rgba(var(--v-theme-on-surface), 0.12);
|
|
||||||
block-size: 24px;
|
|
||||||
inline-size: 1px;
|
|
||||||
margin-block: 0;
|
|
||||||
margin-inline: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
min-inline-size: 0;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-menu-content {
|
|
||||||
max-block-size: 50vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-options {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip {
|
|
||||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
|
||||||
margin: 4px;
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
|
||||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip:hover {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip.v-chip--selected {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
|
||||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
|
||||||
color: rgb(var(--v-theme-on-primary)) !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tag {
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tag:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-filters {
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
|
||||||
padding-block: 8px;
|
|
||||||
padding-inline: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-list-container {
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-results {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-block-size: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-buttons-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn-mobile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: rgba(var(--v-theme-surface), 0.5);
|
|
||||||
block-size: auto;
|
|
||||||
min-block-size: 48px;
|
|
||||||
padding-block: 4px;
|
|
||||||
padding-inline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-block-end: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-sort-select {
|
|
||||||
max-inline-size: 130px;
|
|
||||||
min-inline-size: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.all-filters-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 24px;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1145,6 +1145,27 @@
|
|||||||
resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz"
|
resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz"
|
||||||
integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==
|
integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==
|
||||||
|
|
||||||
|
"@iconify-json/line-md@^1.2.13":
|
||||||
|
version "1.2.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/@iconify-json/line-md/-/line-md-1.2.13.tgz#19714b8471ebac5871e20036512eaffa869a04b7"
|
||||||
|
integrity sha512-XFXThXsEQ2Wzzn+ze2T1d+JHkkFvI1AxiVKnOox4qFbdR9EVikckZlUK+/DUsV4zSy6pMQAgXpIk+1xG8qFYPQ==
|
||||||
|
dependencies:
|
||||||
|
"@iconify/types" "*"
|
||||||
|
|
||||||
|
"@iconify-json/lucide@^1.2.85":
|
||||||
|
version "1.2.85"
|
||||||
|
resolved "https://registry.yarnpkg.com/@iconify-json/lucide/-/lucide-1.2.85.tgz#0074b64f50798da4b89f9f74e4db5a4e56c640b1"
|
||||||
|
integrity sha512-VXUWT6KRDiVK4Ty/7Ypu+U0KnSbHzDAOOiSgLLPhU8u3ES5IusP1X7ahZb1iwiVKGWRG6gkKywaRUIZLgYWXyA==
|
||||||
|
dependencies:
|
||||||
|
"@iconify/types" "*"
|
||||||
|
|
||||||
|
"@iconify-json/material-symbols@^1.2.51":
|
||||||
|
version "1.2.51"
|
||||||
|
resolved "https://registry.yarnpkg.com/@iconify-json/material-symbols/-/material-symbols-1.2.51.tgz#270862a21bb65a8632de4943146096b5a58863ae"
|
||||||
|
integrity sha512-GkxlK8ocHi3NVVozaW62jm3qR9fNY3xX2penFtIRvoe1OtNhJ2KD4KRzv8x34pugMOAZYK8sALMcU30gDgCi1A==
|
||||||
|
dependencies:
|
||||||
|
"@iconify/types" "*"
|
||||||
|
|
||||||
"@iconify-json/mdi@^1.1.52":
|
"@iconify-json/mdi@^1.1.52":
|
||||||
version "1.2.3"
|
version "1.2.3"
|
||||||
resolved "https://registry.npmjs.org/@iconify-json/mdi/-/mdi-1.2.3.tgz"
|
resolved "https://registry.npmjs.org/@iconify-json/mdi/-/mdi-1.2.3.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user