mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
feat(search): 添加AI推荐功能并优化相关逻辑
This commit is contained in:
+662
-102
@@ -3,15 +3,29 @@ import { debounce } from 'lodash-es'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentCardListView from '@/views/torrent/TorrentCardListView.vue'
|
||||
import TorrentRowListView from '@/views/torrent/TorrentRowListView.vue'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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 { useProgressSSE } = useBackgroundOptimization()
|
||||
|
||||
// 提示框
|
||||
const toast = useToast()
|
||||
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 使用筛选 composable
|
||||
const torrentFilter = useTorrentFilter()
|
||||
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
|
||||
@@ -39,11 +53,46 @@ const sites = route.query?.sites?.toString() ?? ''
|
||||
// 视图类型,从localStorage中读取
|
||||
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)
|
||||
@@ -66,6 +115,49 @@ const errorTitle = ref(t('resource.noData'))
|
||||
// 错误描述
|
||||
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(
|
||||
progressValue,
|
||||
@@ -116,29 +208,30 @@ function stopLoadingProgress() {
|
||||
setTimeout(() => {
|
||||
progressValue.value = 0
|
||||
progressEnabled.value = false
|
||||
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 设置视图类型
|
||||
function changeViewType(newType: string) {
|
||||
if (viewType.value !== newType) {
|
||||
isViewChanging.value = true
|
||||
// 立即更新视图类型
|
||||
viewType.value = newType
|
||||
localStorage.setItem('MPTorrentsViewType', newType)
|
||||
|
||||
// 模拟视图切换的加载过程
|
||||
setTimeout(() => {
|
||||
isViewChanging.value = false
|
||||
}, 600)
|
||||
// 切换视图时重新应用筛选
|
||||
applyFilter()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取搜索列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
enableFilterAnimation.value = true
|
||||
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 {
|
||||
startLoadingProgress()
|
||||
let result: { [key: string]: any }
|
||||
@@ -164,7 +257,12 @@ async function fetchData() {
|
||||
})
|
||||
}
|
||||
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) {
|
||||
errorDescription.value = result.message
|
||||
}
|
||||
@@ -172,6 +270,8 @@ async function fetchData() {
|
||||
// 从浏览器历史中删除当前搜索
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
// 应用筛选
|
||||
applyFilter()
|
||||
// 标记已刷新
|
||||
isRefreshed.value = true
|
||||
} 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()
|
||||
})
|
||||
|
||||
// 卸载时停止加载进度
|
||||
// 卸载时停止轮询
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
stopAiRecommendPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -215,9 +581,10 @@ onUnmounted(() => {
|
||||
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
|
||||
<div class="search-info-container">
|
||||
<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 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">
|
||||
{{ t('resource.keyword') }}: {{ keyword }}
|
||||
</VChip>
|
||||
@@ -232,10 +599,62 @@ onUnmounted(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-buttons">
|
||||
<div class="active-indicator" :class="viewType"></div>
|
||||
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
|
||||
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
|
||||
</button>
|
||||
@@ -246,39 +665,91 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</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">
|
||||
<!-- 卡片视图模式 -->
|
||||
<VFadeTransition>
|
||||
<div>
|
||||
<TorrentCardListView v-if="viewType === 'card'" :items="dataList" />
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
<div v-if="isRefreshed && hasData" class="search-results-container">
|
||||
<!-- 筛选栏 -->
|
||||
<TorrentFilterBar
|
||||
:filter-form="torrentFilter.filterForm"
|
||||
:filter-options="torrentFilter.filterOptions"
|
||||
:sort-field="torrentFilter.sortField.value"
|
||||
: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>
|
||||
<div>
|
||||
<TorrentRowListView v-if="viewType === 'row'" :items="dataList" />
|
||||
<!-- 视图切换区域 -->
|
||||
<VFadeTransition mode="out-in">
|
||||
<!-- 卡片视图模式 -->
|
||||
<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>
|
||||
</VFadeTransition>
|
||||
</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" />
|
||||
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/">
|
||||
{{ t('resource.backToHome') }}
|
||||
@@ -343,8 +814,8 @@ onUnmounted(() => {
|
||||
/* 精简标题栏样式 */
|
||||
.search-header {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.search-info-container {
|
||||
@@ -374,6 +845,25 @@ onUnmounted(() => {
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
@@ -381,79 +871,90 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
block-size: 36px;
|
||||
cursor: pointer;
|
||||
inline-size: 40px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.view-toggle-btn.active {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 10%);
|
||||
z-index: 2; /* Sit on top of indicator */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.view-toggle-btn:hover:not(.active) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 视图切换加载状态 */
|
||||
.view-changing-container {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
/* AI按钮组样式 */
|
||||
.ai-toggle-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-toggle-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(8px);
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
.ai-recommend-btn {
|
||||
transition: all 0.3s ease;
|
||||
margin: 0;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.pulse-loader {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
/* 仅为激活的按钮添加背景 */
|
||||
.ai-recommend-btn.ai-active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pulse-circle {
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
block-size: 12px;
|
||||
inline-size: 12px;
|
||||
/* 图标基础样式 */
|
||||
.ai-icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.pulse-circle:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* 激活状态图标:变色 + 辉光 (移除动画) */
|
||||
.ai-icon-active {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
filter: drop-shadow(0 0 4px rgba(var(--v-theme-primary), 0.5));
|
||||
}
|
||||
|
||||
/* 文字基础样式 */
|
||||
.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 {
|
||||
@@ -461,27 +962,52 @@ onUnmounted(() => {
|
||||
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) {
|
||||
.search-header {
|
||||
padding-block: 8px;
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-info-container {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.search-tags {
|
||||
flex-wrap: nowrap;
|
||||
margin-inline-end: 8px;
|
||||
margin-inline-end: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -498,9 +1024,43 @@ onUnmounted(() => {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.active-indicator.row {
|
||||
transform: translateX(36px);
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
block-size: 32px;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user