mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 01:50:10 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a225ba6075 | ||
|
|
303fe39c01 | ||
|
|
d343cbcf71 | ||
|
|
0eef8c5174 | ||
|
|
46fe257585 | ||
|
|
f69a57863e | ||
|
|
8876aadcfa | ||
|
|
485e9691a0 | ||
|
|
a0e7283ae6 | ||
|
|
b44c0647f1 | ||
|
|
7e60ab9064 | ||
|
|
f05c1f42b5 | ||
|
|
672bbb4265 | ||
|
|
10c1041b06 | ||
|
|
59c73facfe | ||
|
|
ba7d4cd392 | ||
|
|
d76a50c216 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.9.27",
|
||||
"version": "2.9.31",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -237,14 +237,6 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
// 对于总集数为 0 的电视剧季(TMDB 未返回有效集数),不展示“已入库”角标,避免误判
|
||||
const totalEpisode = props.media?.total_episode ?? props.media?.episode_count ?? props.media?.number_of_episodes ?? 0
|
||||
|
||||
if (props.media?.type === '电视剧' && totalEpisode === 0) {
|
||||
isExists.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Site } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 响应式断点
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -23,6 +26,30 @@ const selectCategory = ref<number[]>([])
|
||||
// 全部分类
|
||||
const siteCategoryList = ref<SiteCategory[]>()
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 当前页
|
||||
const resourcePage = ref(1)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 移动端搜索栏是否展开
|
||||
const mobileSearchExpanded = ref(false)
|
||||
|
||||
// 种子元数据
|
||||
const torrent = ref<TorrentInfo>()
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
return siteCategoryList.value?.map(item => {
|
||||
@@ -30,77 +57,85 @@ const categoryOptions = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 种子元数据
|
||||
const torrent = ref<TorrentInfo>()
|
||||
const resourceTotalItems = computed(() => resourceDataList.value.length)
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
const resourceHeaders = computed(() => [
|
||||
{ title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },
|
||||
{ title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },
|
||||
{ title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },
|
||||
{ title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },
|
||||
{ title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
])
|
||||
|
||||
// 输入框标签
|
||||
const keywordFieldLabel = computed(() => {
|
||||
return keyword.value ? '' : t('dialog.siteResource.searchKeyword')
|
||||
})
|
||||
|
||||
const categoryFieldLabel = computed(() => {
|
||||
return selectCategory.value.length > 0 ? '' : t('dialog.siteResource.resourceCategory')
|
||||
})
|
||||
|
||||
// 结果统计文案
|
||||
const resultSummaryText = computed(() => {
|
||||
if (locale.value.startsWith('zh')) {
|
||||
return `共 ${resourceTotalItems.value} 条结果`
|
||||
}
|
||||
|
||||
return `${resourceTotalItems.value} results`
|
||||
})
|
||||
|
||||
// 是否小屏幕
|
||||
const isMobileLayout = computed(() => display.smAndDown.value)
|
||||
|
||||
// 移动端分页数据
|
||||
const mobileResourceList = computed(() => resourceDataList.value)
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
if (!page_url) return
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
if (!enclosure) return
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_torrent: any) {
|
||||
async function addDownload(_torrent: TorrentInfo) {
|
||||
torrent.value = _torrent
|
||||
addDownloadDialog.value = true
|
||||
}
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
function addDownloadSuccess(_url: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
function addDownloadError(_error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
resourcePage.value = 1
|
||||
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
||||
params: {
|
||||
@@ -111,7 +146,12 @@ async function getResourceList() {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
resourceLoading.value = false
|
||||
|
||||
if (isMobileLayout.value) {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载站点分类
|
||||
@@ -123,16 +163,44 @@ async function getSiteCategoryList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
watch([resourceItemsPerPage, resourceTotalItems, () => display.mdAndUp.value], () => {
|
||||
if (display.mdAndUp.value) {
|
||||
const maxPage = Math.max(1, Math.ceil(resourceTotalItems.value / resourceItemsPerPage.value))
|
||||
if (resourcePage.value > maxPage) {
|
||||
resourcePage.value = maxPage
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => display.mdAndUp.value,
|
||||
isDesktop => {
|
||||
if (isDesktop) {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function toggleMobileSearch() {
|
||||
mobileSearchExpanded.value = !mobileSearchExpanded.value
|
||||
}
|
||||
|
||||
function closeMobileSearch() {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
|
||||
// 装载时查询站点分类和资源
|
||||
onMounted(() => {
|
||||
getSiteCategoryList()
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<VDialog scrollable :fullscreen="display.smAndDown.value" max-width="92rem" transition="dialog-bottom-transition">
|
||||
<VCard class="site-resource-dialog">
|
||||
<div>
|
||||
<VToolbar color="primary" density="comfortable">
|
||||
<VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>
|
||||
@@ -144,45 +212,153 @@ onMounted(() => {
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<VRow>
|
||||
<VCol cols="6" md="5">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
size="small"
|
||||
density="compact"
|
||||
:label="t('dialog.siteResource.searchKeyword')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
:label="t('dialog.siteResource.resourceCategory')"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2" class="text-center">
|
||||
<VBtn variant="tonal" block prepend-icon="mdi-magnify" @click="getResourceList">
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
|
||||
<div class="pa-3 pb-2">
|
||||
<template v-if="!isMobileLayout">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="keywordFieldLabel"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
chips
|
||||
:label="categoryFieldLabel"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3" class="d-flex align-center">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
block
|
||||
size="default"
|
||||
rounded="lg"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="site-resource-search-btn"
|
||||
@click="getResourceList"
|
||||
>
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div
|
||||
v-if="resourceTotalItems > 0"
|
||||
class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ resultSummaryText }}
|
||||
</div>
|
||||
<VChip size="small" color="primary" variant="tonal" class="site-resource-result-chip">
|
||||
{{ resourceTotalItems }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="site-resource-mobile-search">
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="site-resource-mobile-search__toggle"
|
||||
@click="toggleMobileSearch"
|
||||
>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<div v-if="resourceTotalItems > 0" class="text-body-2 text-medium-emphasis">
|
||||
{{ resultSummaryText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VExpandTransition>
|
||||
<div v-if="mobileSearchExpanded" class="mt-2">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="keywordFieldLabel"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
autofocus
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
chips
|
||||
:label="categoryFieldLabel"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" class="d-flex gap-2">
|
||||
<VBtn color="primary" variant="flat" block rounded="lg" class="site-resource-search-btn" @click="getResourceList">
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" rounded="lg" @click="closeMobileSearch">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VSheet>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
</template>
|
||||
</div>
|
||||
<VCardText class="px-0 py-0 my-0">
|
||||
|
||||
<VCardText class="site-resource-content px-0 py-0 my-0">
|
||||
<VDataTable
|
||||
v-if="display.mdAndUp.value"
|
||||
v-model:page="resourcePage"
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
@@ -191,60 +367,69 @@ onMounted(() => {
|
||||
hover
|
||||
:items-per-page-text="t('dialog.siteResource.itemsPerPage')"
|
||||
:loading-text="t('dialog.siteResource.loading')"
|
||||
class="h-full"
|
||||
:items-per-page-options="[10, 25, 50, 100]"
|
||||
height="100%"
|
||||
class="h-full site-resource-table"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1 font-weight-medium">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
<div v-if="item.description" class="text-sm my-1 text-medium-emphasis">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</a>
|
||||
<div class="mt-2">
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-medium-emphasis">
|
||||
{{ item.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.seeders }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.peers }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
@@ -268,11 +453,119 @@ onMounted(() => {
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #no-data>{{ t('dialog.siteResource.noData') }}</template>
|
||||
</VDataTable>
|
||||
|
||||
<div v-else class="site-resource-mobile">
|
||||
<div v-if="resourceLoading" class="px-4 py-6">
|
||||
<VProgressLinear color="primary" indeterminate rounded />
|
||||
<div class="text-center text-body-2 text-medium-emphasis mt-3">
|
||||
{{ t('dialog.siteResource.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mobileResourceList.length > 0" class="px-3 pb-4">
|
||||
<VCard
|
||||
v-for="(item, index) in mobileResourceList"
|
||||
:key="item.page_url || item.enclosure || `${item.title}-${index}`"
|
||||
class="mb-3"
|
||||
>
|
||||
<VCardText class="pa-4">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.description"
|
||||
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3">
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, chipIndex) in item.labels"
|
||||
:key="chipIndex"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__meta mt-4">
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__actions mt-4">
|
||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
||||
{{ t('actionStep.addDownload') }}
|
||||
</VBtn>
|
||||
<div class="site-resource-card__secondary-actions mt-2">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-tray-arrow-down"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
||||
{{ t('dialog.siteResource.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 添加下载对话框 -->
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
@@ -285,7 +578,160 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.site-resource-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-filter-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-filter-panel {
|
||||
border-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(var(--v-theme-primary), 0.06), transparent 40%),
|
||||
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.93));
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 4%);
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field) {
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.8));
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field__prepend-inner) {
|
||||
color: rgba(var(--v-theme-primary), 0.85);
|
||||
}
|
||||
|
||||
.site-resource-search-btn {
|
||||
box-shadow: 0 8px 18px rgba(var(--v-theme-primary), 0.18);
|
||||
letter-spacing: 0.02em;
|
||||
min-block-size: 40px;
|
||||
}
|
||||
|
||||
.site-resource-result-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search__toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.site-resource-title-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-content {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-table {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table__wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-table__wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table-footer) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.site-resource-card__meta {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item {
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(var(--v-theme-surface), 0.78);
|
||||
min-block-size: 0;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-caption) {
|
||||
font-size: 0.72rem !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
||||
font-size: 0.82rem !important;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
.site-resource-dialog {
|
||||
block-size: min(88vh, 960px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 959px) {
|
||||
.site-resource-dialog {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
padding: 0.7rem 0.75rem;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search {
|
||||
min-block-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,44 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const menu = ref(false)
|
||||
const currentCron = ref(props.modelValue)
|
||||
const menuRoot = ref<HTMLElement>()
|
||||
const instance = getCurrentInstance()
|
||||
const menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`
|
||||
const menuContentSelector = `.${menuContentClass}`
|
||||
|
||||
function isCronMenuTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof Element)) return false
|
||||
|
||||
if (menuRoot.value?.contains(target)) return true
|
||||
|
||||
const menuContent = document.querySelector(menuContentSelector)
|
||||
|
||||
if (menuContent?.contains(target)) return true
|
||||
|
||||
const overlayId = target.closest('.v-overlay')?.getAttribute('id')
|
||||
|
||||
if (!overlayId || !menuContent) return false
|
||||
|
||||
return Array.from(menuContent.querySelectorAll('[aria-owns]')).some(
|
||||
activator => activator.getAttribute('aria-owns') === overlayId,
|
||||
)
|
||||
}
|
||||
|
||||
function closeOnOutsidePointerDown(event: PointerEvent) {
|
||||
if (!menu.value || isCronMenuTarget(event.target)) return
|
||||
|
||||
menu.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointerDown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointerDown, true)
|
||||
})
|
||||
|
||||
watch(currentCron, newVal => {
|
||||
emit('update:modelValue', newVal)
|
||||
@@ -23,8 +60,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
|
||||
<div ref="menuRoot">
|
||||
<VMenu
|
||||
v-model="menu"
|
||||
:close-on-content-click="false"
|
||||
:content-class="['cursor-default', menuContentClass]"
|
||||
persistent
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<slot name="activator" :menuprops="props" />
|
||||
</template>
|
||||
|
||||
@@ -103,8 +103,21 @@ const selectedPath = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
function isFileItem(value: unknown): value is FileItem {
|
||||
return typeof value === 'object' && value !== null && 'path' in value && 'type' in value
|
||||
}
|
||||
|
||||
function activateDir({ id }: { id: unknown }) {
|
||||
const item = isFileItem(id) ? id : typeof id === 'string' ? findPath(treeItems.value[0], id) : null
|
||||
|
||||
if (!item || item.type !== 'dir') return
|
||||
|
||||
activedDirs.value = [item]
|
||||
}
|
||||
|
||||
watch(activedDirs, newVal => {
|
||||
if (!newVal.length) return
|
||||
|
||||
emit('update:modelValue', selectedPath.value)
|
||||
})
|
||||
|
||||
@@ -165,8 +178,10 @@ watch(
|
||||
activatable
|
||||
return-object
|
||||
max-height="20rem"
|
||||
open-on-click
|
||||
expand-icon="mdi-folder"
|
||||
collapse-icon="mdi-folder-open"
|
||||
@click:open="activateDir"
|
||||
/>
|
||||
</VMenu>
|
||||
</div>
|
||||
|
||||
@@ -2787,7 +2787,12 @@ export default {
|
||||
loading: 'Loading...',
|
||||
pageSize: 'Items Per Page',
|
||||
pageInfo: '{begin} - {end} / {total}',
|
||||
aiRedoDisabled: 'Please enable the AI assistant in system settings first',
|
||||
aiRedoQueued: 'Assistant organize task submitted: {title}',
|
||||
aiRedoFailed: 'Failed to submit assistant organize task',
|
||||
actions: {
|
||||
aiRedo: 'Assistant Organize',
|
||||
aiRedoPending: 'Assistant Organizing...',
|
||||
redo: 'Reorganize',
|
||||
delete: 'Delete',
|
||||
},
|
||||
|
||||
@@ -1321,6 +1321,9 @@ export default {
|
||||
llmProviderHint: '选择使用的LLM服务提供商',
|
||||
llmModel: 'LLM模型名称',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmSupportImageInput: '模型支持图片输入',
|
||||
llmSupportImageInputHint:
|
||||
'启用后,消息中的图片会按多模态图片发送给 LLM;关闭后图片会作为附件保存到本地,并将文件路径提供给智能助手处理',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
|
||||
@@ -2749,7 +2752,12 @@ export default {
|
||||
loading: '加载中...',
|
||||
pageSize: '每页条数',
|
||||
pageInfo: '{begin} - {end} / {total}',
|
||||
aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',
|
||||
aiRedoQueued: '已提交智能助手整理任务:{title}',
|
||||
aiRedoFailed: '提交智能助手整理任务失败',
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
redo: '重新整理',
|
||||
delete: '删除',
|
||||
},
|
||||
|
||||
@@ -1322,6 +1322,9 @@ export default {
|
||||
llmProviderHint: '選擇使用的LLM服務提供商',
|
||||
llmModel: 'LLM模型名稱',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmSupportImageInput: '模型支援圖片輸入',
|
||||
llmSupportImageInputHint:
|
||||
'啟用後,消息中的圖片會按多模態圖片發送給 LLM;關閉後圖片會作為附件保存到本地,並將檔案路徑提供給智能助手處理',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
|
||||
@@ -2750,7 +2753,12 @@ export default {
|
||||
loading: '加載中...',
|
||||
pageSize: '每頁條數',
|
||||
pageInfo: '{begin} - {end} / {total}',
|
||||
aiRedoDisabled: '請先在系統設置中啟用 AI 智能助手',
|
||||
aiRedoQueued: '已提交智能助手整理任務:{title}',
|
||||
aiRedoFailed: '提交智能助手整理任務失敗',
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
redo: '重新整理',
|
||||
delete: '刪除',
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ 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'
|
||||
@@ -15,7 +14,6 @@ import { useToast } from 'vue-toastification'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useProgressSSE } = useBackgroundOptimization()
|
||||
|
||||
// 提示框
|
||||
const toast = useToast()
|
||||
@@ -109,12 +107,43 @@ const progressEnabled = ref(false)
|
||||
// 进度是否激活
|
||||
const progressActive = ref(false)
|
||||
|
||||
// 是否显示搜索进度
|
||||
const isSearchProgressVisible = computed(
|
||||
() => progressActive.value || (!isRefreshed.value && (progressEnabled.value || progressValue.value > 0)),
|
||||
)
|
||||
|
||||
// 是否显示搜索中的页面态
|
||||
const isSearchLoading = computed(
|
||||
() => !isRefreshed.value && isSearchProgressVisible.value && rawDataList.value.length === 0,
|
||||
)
|
||||
|
||||
// 归一化搜索进度,避免 SSE 异常值影响显示
|
||||
const searchProgressPercent = computed(() => Math.min(100, Math.max(0, Math.ceil(Number(progressValue.value) || 0))))
|
||||
|
||||
// 搜索进度文案
|
||||
const searchProgressLabel = computed(() =>
|
||||
progressEnabled.value || progressValue.value > 0 ? `${searchProgressPercent.value}%` : '...',
|
||||
)
|
||||
|
||||
// 进度未返回前使用不确定态
|
||||
const searchProgressIndeterminate = computed(() => !progressEnabled.value && searchProgressPercent.value <= 0)
|
||||
|
||||
// 错误标题
|
||||
const errorTitle = ref(t('resource.noData'))
|
||||
|
||||
// 错误描述
|
||||
const errorDescription = ref(t('resource.noResourceFound'))
|
||||
|
||||
let searchEventSource: EventSource | null = null
|
||||
|
||||
const streamPreviewLimit = 24
|
||||
|
||||
const streamTotalCount = ref(0)
|
||||
|
||||
const displayResourceCount = computed(() =>
|
||||
progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value,
|
||||
)
|
||||
|
||||
// 监听筛选条件变化,重新筛选数据
|
||||
watch(
|
||||
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
|
||||
@@ -169,39 +198,19 @@ const watchProgressValue = watch(
|
||||
}, 60_000),
|
||||
)
|
||||
|
||||
// 进度SSE消息处理函数
|
||||
function handleProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressValue.value = progress.value
|
||||
progressEnabled.value = progress.enable
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的进度SSE连接
|
||||
const progressSSE = useProgressSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/progress/search`,
|
||||
handleProgressMessage,
|
||||
'resource-search-progress',
|
||||
progressActive,
|
||||
)
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
watchProgressValue.resume()
|
||||
progressText.value = t('resource.searching')
|
||||
progressValue.value = 0
|
||||
progressEnabled.value = false
|
||||
progressEnabled.value = true
|
||||
progressActive.value = true
|
||||
progressSSE.start()
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
watchProgressValue.pause()
|
||||
progressActive.value = false
|
||||
progressSSE.stop()
|
||||
|
||||
// 确保进度显示100%,然后再渐进清零
|
||||
progressValue.value = 100
|
||||
@@ -211,6 +220,203 @@ function stopLoadingProgress() {
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 关闭SSE连接
|
||||
function closeSearchEventSource() {
|
||||
if (searchEventSource) {
|
||||
searchEventSource.close()
|
||||
searchEventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取API URL
|
||||
function getApiUrl(path: string) {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const normalizedBaseUrl = apiBaseUrl.startsWith('http')
|
||||
? apiBaseUrl
|
||||
: `${window.location.origin}${apiBaseUrl.startsWith('/') ? apiBaseUrl : `/${apiBaseUrl}`}`
|
||||
|
||||
return new URL(path, normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl : `${normalizedBaseUrl}/`)
|
||||
}
|
||||
|
||||
// 设置搜索参数
|
||||
function setSearchParam(params: URLSearchParams, key: string, value: unknown) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
// 构建搜索流URL
|
||||
function buildSearchStreamUrl() {
|
||||
const isMediaSearch = /^[a-zA-Z]+:/.test(keyword)
|
||||
const url = getApiUrl(isMediaSearch ? `search/media/${encodeURIComponent(keyword)}/stream` : 'search/title/stream')
|
||||
|
||||
if (isMediaSearch) {
|
||||
setSearchParam(url.searchParams, 'mtype', type)
|
||||
setSearchParam(url.searchParams, 'area', area)
|
||||
setSearchParam(url.searchParams, 'title', title)
|
||||
setSearchParam(url.searchParams, 'year', year)
|
||||
setSearchParam(url.searchParams, 'season', season)
|
||||
setSearchParam(url.searchParams, 'sites', sites)
|
||||
} else {
|
||||
setSearchParam(url.searchParams, 'keyword', keyword)
|
||||
setSearchParam(url.searchParams, 'sites', sites)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// 重置搜索结果
|
||||
function resetSearchResults() {
|
||||
rawDataList.value = []
|
||||
originalDataList.value = []
|
||||
streamTotalCount.value = 0
|
||||
aiRecommended.value = false
|
||||
showingAiResults.value = false
|
||||
aiRecommendedList.value = []
|
||||
savedFilterState.value = null
|
||||
aiStatusChecked.value = false
|
||||
torrentFilter.clearAllFilters()
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
// 更新搜索进度
|
||||
function updateSearchProgress(eventData: { [key: string]: any }) {
|
||||
if (eventData.text) {
|
||||
progressText.value = eventData.text
|
||||
}
|
||||
if (typeof eventData.value === 'number') {
|
||||
progressValue.value = eventData.value
|
||||
}
|
||||
if (typeof eventData.total_items === 'number') {
|
||||
streamTotalCount.value = eventData.total_items
|
||||
}
|
||||
progressEnabled.value = true
|
||||
}
|
||||
|
||||
// 设置流式搜索结果
|
||||
function setStreamResults(items: Context[]) {
|
||||
rawDataList.value = items
|
||||
originalDataList.value = items
|
||||
if (!progressActive.value) {
|
||||
streamTotalCount.value = items.length
|
||||
}
|
||||
isRefreshed.value = true
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
// 追加流式搜索结果
|
||||
function appendStreamResults(items: Context[]) {
|
||||
if (!items.length) return
|
||||
|
||||
const nextItems = [...items, ...rawDataList.value]
|
||||
setStreamResults(progressActive.value ? nextItems.slice(0, streamPreviewLimit) : nextItems)
|
||||
}
|
||||
|
||||
// 获取磁力链接的key
|
||||
function getTorrentItemKey(item: Context, index: number) {
|
||||
return (
|
||||
item.torrent_info?.page_url ||
|
||||
item.torrent_info?.enclosure ||
|
||||
`${item.torrent_info?.site_name || ''}-${item.torrent_info?.title || ''}-${item.torrent_info?.description || ''}` ||
|
||||
`torrent-${index}`
|
||||
)
|
||||
}
|
||||
|
||||
// 处理搜索流消息
|
||||
function handleSearchStreamMessage(eventData: { [key: string]: any }) {
|
||||
updateSearchProgress(eventData)
|
||||
|
||||
if (eventData.type === 'error') {
|
||||
errorDescription.value = eventData.message || t('resource.noResourceFound')
|
||||
return
|
||||
}
|
||||
|
||||
const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : []
|
||||
if (eventData.type === 'append') {
|
||||
appendStreamResults(items)
|
||||
} else if (eventData.type === 'replace' || eventData.type === 'done') {
|
||||
setStreamResults(items)
|
||||
}
|
||||
}
|
||||
|
||||
// 按请求搜索
|
||||
async function searchByRequest() {
|
||||
let result: { [key: string]: any }
|
||||
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索
|
||||
if (/^[a-zA-Z]+:/.test(keyword)) {
|
||||
result = await api.get(`search/media/${keyword}`, {
|
||||
params: {
|
||||
mtype: type,
|
||||
area,
|
||||
title,
|
||||
year,
|
||||
season,
|
||||
sites,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 按标题模糊查询
|
||||
result = await api.get(`search/title`, {
|
||||
params: {
|
||||
keyword,
|
||||
sites,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
streamTotalCount.value = result.data?.length || 0
|
||||
setStreamResults(result.data || [])
|
||||
} else {
|
||||
errorDescription.value = result?.message || t('resource.noResourceFound')
|
||||
streamTotalCount.value = 0
|
||||
setStreamResults([])
|
||||
}
|
||||
}
|
||||
|
||||
// 按流搜索
|
||||
function searchByStream() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
closeSearchEventSource()
|
||||
|
||||
let settled = false
|
||||
const source = new EventSource(buildSearchStreamUrl())
|
||||
searchEventSource = source
|
||||
|
||||
source.onmessage = event => {
|
||||
try {
|
||||
const eventData = JSON.parse(event.data)
|
||||
handleSearchStreamMessage(eventData)
|
||||
|
||||
if (eventData.type === 'error') {
|
||||
settled = true
|
||||
closeSearchEventSource()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
if (eventData.type === 'done') {
|
||||
settled = true
|
||||
closeSearchEventSource()
|
||||
resolve()
|
||||
}
|
||||
} catch (error) {
|
||||
settled = true
|
||||
closeSearchEventSource()
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (settled) return
|
||||
|
||||
settled = true
|
||||
closeSearchEventSource()
|
||||
reject(new Error(t('resource.noResourceFound')))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 设置视图类型
|
||||
function changeViewType(newType: string) {
|
||||
if (viewType.value !== newType) {
|
||||
@@ -233,38 +439,13 @@ async function fetchData() {
|
||||
rawDataList.value = (results as unknown as Context[]) || []
|
||||
originalDataList.value = (results as unknown as Context[]) || []
|
||||
} else {
|
||||
resetSearchResults()
|
||||
startLoadingProgress()
|
||||
let result: { [key: string]: any }
|
||||
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索
|
||||
if (/^[a-zA-Z]+:/.test(keyword)) {
|
||||
result = await api.get(`search/media/${keyword}`, {
|
||||
params: {
|
||||
mtype: type,
|
||||
area,
|
||||
title,
|
||||
year,
|
||||
season,
|
||||
sites,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 按标题模糊查询
|
||||
result = await api.get(`search/title`, {
|
||||
params: {
|
||||
keyword,
|
||||
sites,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (result && result.success) {
|
||||
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
|
||||
try {
|
||||
await searchByStream()
|
||||
} catch (error) {
|
||||
console.warn('渐进式搜索连接失败,回退到普通搜索:', error)
|
||||
await searchByRequest()
|
||||
}
|
||||
stopLoadingProgress()
|
||||
// 从浏览器历史中删除当前搜索
|
||||
@@ -276,6 +457,7 @@ async function fetchData() {
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
closeSearchEventSource()
|
||||
stopLoadingProgress()
|
||||
isRefreshed.value = true
|
||||
return Promise.reject(error)
|
||||
@@ -542,7 +724,12 @@ const hasData = computed(() => {
|
||||
// 使用 watchEffect 确保计算属性变化时立即响应
|
||||
watchEffect(() => {
|
||||
// 需要满足:AI 功能启用、数据已加载、尚未检查
|
||||
if (aiRecommendEnabled.value && originalDataList.value.length > 0 && !aiStatusChecked.value) {
|
||||
if (
|
||||
aiRecommendEnabled.value &&
|
||||
originalDataList.value.length > 0 &&
|
||||
!progressActive.value &&
|
||||
!aiStatusChecked.value
|
||||
) {
|
||||
checkAiRecommendStatus()
|
||||
}
|
||||
})
|
||||
@@ -554,6 +741,7 @@ onMounted(async () => {
|
||||
|
||||
// 卸载时停止轮询
|
||||
onUnmounted(() => {
|
||||
closeSearchEventSource()
|
||||
stopLoadingProgress()
|
||||
stopAiRecommendPolling()
|
||||
})
|
||||
@@ -561,24 +749,67 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 加载进度条 -->
|
||||
<!-- 搜索加载状态 -->
|
||||
<VFadeTransition>
|
||||
<div v-if="progressValue > 0 || progressEnabled" class="search-progress-container">
|
||||
<VCard elevation="3" class="search-progress-card">
|
||||
<div v-if="isSearchProgressVisible" class="search-loading-state mb-3" :class="{ 'is-empty-loading': isSearchLoading }">
|
||||
<VCard elevation="0" class="search-progress-card">
|
||||
<div class="progress-header">
|
||||
<VIcon icon="mdi-movie-search" color="primary" size="small" class="me-2" />
|
||||
<span class="progress-title">{{ progressText }}</span>
|
||||
<div class="progress-icon-wrap">
|
||||
<VProgressCircular
|
||||
color="primary"
|
||||
:indeterminate="searchProgressIndeterminate"
|
||||
:model-value="searchProgressPercent"
|
||||
:size="56"
|
||||
:width="5"
|
||||
>
|
||||
<VIcon icon="mdi-movie-search" color="primary" size="24" />
|
||||
</VProgressCircular>
|
||||
</div>
|
||||
<div class="progress-copy">
|
||||
<span class="progress-title">{{ progressText }}</span>
|
||||
<div v-if="hasSearchTags" class="progress-tags d-flex flex-wrap">
|
||||
<VChip v-if="keyword" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
|
||||
{{ t('resource.keyword') }}: {{ keyword }}
|
||||
</VChip>
|
||||
<VChip v-if="title" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
|
||||
{{ t('resource.title') }}: {{ title }}
|
||||
</VChip>
|
||||
<VChip v-if="year" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
|
||||
{{ t('resource.year') }}: {{ year }}
|
||||
</VChip>
|
||||
<VChip v-if="season" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
|
||||
{{ t('resource.season') }}: {{ season }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-percentage">{{ searchProgressLabel }}</div>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<VProgressLinear color="primary" rounded :model-value="progressValue" />
|
||||
<div class="progress-percentage">{{ Math.ceil(progressValue) }}%</div>
|
||||
<VProgressLinear
|
||||
color="primary"
|
||||
rounded
|
||||
:indeterminate="searchProgressIndeterminate"
|
||||
:model-value="searchProgressPercent"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<div v-if="isSearchLoading && viewType === 'card'" class="search-skeleton-grid">
|
||||
<VCard v-for="item in 6" :key="`search-card-skeleton-${item}`" class="search-skeleton-card" elevation="0">
|
||||
<VSkeletonLoader type="image, article" />
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<VCard v-else-if="isSearchLoading" class="search-skeleton-list" elevation="0">
|
||||
<div v-for="item in 6" :key="`search-row-skeleton-${item}`" class="search-skeleton-row">
|
||||
<VSkeletonLoader type="list-item-avatar-two-line" />
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
|
||||
<!-- 精简标题栏 -->
|
||||
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
|
||||
<VCard v-if="isRefreshed && !progressActive" class="search-header d-flex align-center mb-3">
|
||||
<div class="search-info-container">
|
||||
<div class="search-title text-moviepilot">
|
||||
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
|
||||
@@ -668,11 +899,12 @@ onUnmounted(() => {
|
||||
<div v-if="isRefreshed && hasData" class="search-results-container">
|
||||
<!-- 筛选栏 -->
|
||||
<TorrentFilterBar
|
||||
v-if="!progressActive"
|
||||
:filter-form="torrentFilter.filterForm"
|
||||
:filter-options="torrentFilter.filterOptions"
|
||||
:sort-field="torrentFilter.sortField.value"
|
||||
:sort-type="torrentFilter.sortType.value"
|
||||
:total-filtered-count="torrentFilter.totalFilteredCount.value"
|
||||
:total-filtered-count="displayResourceCount"
|
||||
:filter-titles="torrentFilter.filterTitles"
|
||||
:sort-titles="torrentFilter.sortTitles"
|
||||
:enable-animation="enableFilterAnimation"
|
||||
@@ -701,10 +933,11 @@ onUnmounted(() => {
|
||||
<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}`"
|
||||
v-for="(item, index) in cardScroll.displayDataList.value"
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
:torrent="item"
|
||||
:more="item.more"
|
||||
class="stream-result-item"
|
||||
/>
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
@@ -736,7 +969,8 @@ onUnmounted(() => {
|
||||
<template #empty />
|
||||
<div
|
||||
v-for="(item, index) in rowScroll.displayDataList.value"
|
||||
:key="`${item.torrent_info?.enclosure || ''}-${index}`"
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
class="stream-result-item"
|
||||
>
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
|
||||
@@ -756,7 +990,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 初始加载状态 -->
|
||||
<LoadingBanner v-else-if="!isRefreshed && !(progressEnabled || progressValue > 0)" />
|
||||
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
|
||||
<!-- 滚动到顶部按钮 -->
|
||||
<Teleport to="body" v-if="route.path === '/resource'">
|
||||
<VScrollToTopBtn />
|
||||
@@ -765,51 +999,111 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-progress-container {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
.search-loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
inset-block-start: env(safe-area-inset-top);
|
||||
inset-inline: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.search-loading-state.is-empty-loading {
|
||||
min-block-size: 50vh;
|
||||
}
|
||||
|
||||
.search-progress-card {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.1);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.18);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 10%);
|
||||
inline-size: 90%;
|
||||
max-inline-size: 400px;
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), rgb(var(--v-theme-surface));
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-block-end: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-icon-wrap {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-copy {
|
||||
flex: 1 1 auto;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-tags {
|
||||
gap: 6px;
|
||||
margin-block-start: 8px;
|
||||
}
|
||||
|
||||
.progress-tag {
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-block-start: 14px;
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
flex: 0 0 auto;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
min-inline-size: 36px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
min-inline-size: 44px;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.search-skeleton-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.search-skeleton-card,
|
||||
.search-skeleton-list {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.search-skeleton-row + .search-skeleton-row {
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.stream-result-item {
|
||||
animation: stream-result-in 0.28s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes stream-result-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 精简标题栏样式 */
|
||||
.search-header {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
@@ -840,25 +1134,27 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.view-toggle-buttons {
|
||||
position: relative;
|
||||
display: flex;
|
||||
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;
|
||||
border-radius: 6px;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
block-size: 36px;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 12%),
|
||||
0 1px 2px rgba(0, 0, 0, 24%);
|
||||
inline-size: 40px;
|
||||
inset-block-start: 4px;
|
||||
inset-inline-start: 4px;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.active-indicator.row {
|
||||
@@ -866,6 +1162,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
position: relative;
|
||||
z-index: 2; /* Sit on top of indicator */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -875,13 +1173,11 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
inline-size: 40px;
|
||||
transition: all 0.2s ease;
|
||||
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;
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
/* AI按钮组样式 */
|
||||
@@ -891,31 +1187,31 @@ onUnmounted(() => {
|
||||
|
||||
.ai-toggle-buttons {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
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 */
|
||||
block-size: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
|
||||
}
|
||||
|
||||
.ai-recommend-btn {
|
||||
transition: all 0.3s ease;
|
||||
margin: 0;
|
||||
height: 100% !important;
|
||||
block-size: 100% !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 仅为激活的按钮添加背景 */
|
||||
.ai-recommend-btn.ai-active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
z-index: 1;
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
}
|
||||
|
||||
/* 图标基础样式 */
|
||||
.ai-icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: translateZ(0);
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 激活状态图标:变色 + 辉光 */
|
||||
@@ -927,10 +1223,10 @@ onUnmounted(() => {
|
||||
/* 文字基础样式 */
|
||||
.ai-text {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
font-weight: 600; /* 保持一致的字重防止位移 */
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.3s ease;
|
||||
font-weight: 600; /* 保持一致的字重防止位移 */
|
||||
transform: translateZ(0);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 激活状态文字 */
|
||||
@@ -945,12 +1241,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-shrink: 0;
|
||||
block-size: 20px;
|
||||
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.12); /* 使用边框显示线条 */
|
||||
inline-size: 0; /* 宽度设为0,不占用空间 */
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.search-results-container {
|
||||
@@ -1012,6 +1308,45 @@ onUnmounted(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-loading-state {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-progress-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.progress-icon-wrap {
|
||||
padding-block-start: 2px;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
font-size: 0.85rem;
|
||||
min-inline-size: 36px;
|
||||
}
|
||||
|
||||
.progress-tags {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.progress-tags::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-skeleton-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.view-toggle-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1021,10 +1356,10 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
block-size: 32px;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 2px;
|
||||
inset-inline-start: 2px;
|
||||
}
|
||||
|
||||
.active-indicator.row {
|
||||
@@ -1037,7 +1372,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.ai-toggle-buttons {
|
||||
height: 36px;
|
||||
block-size: 36px;
|
||||
}
|
||||
|
||||
.ai-text {
|
||||
@@ -1046,17 +1381,16 @@ onUnmounted(() => {
|
||||
|
||||
.ai-recommend-btn,
|
||||
.ai-toggle-buttons .v-btn {
|
||||
height: 36px !important;
|
||||
min-width: unset !important;
|
||||
block-size: 36px !important;
|
||||
min-inline-size: unset !important;
|
||||
}
|
||||
|
||||
.ai-recommend-btn {
|
||||
padding-inline-start: 12px !important;
|
||||
padding-inline-end: 8px !important;
|
||||
padding-inline: 12px 8px !important;
|
||||
}
|
||||
|
||||
.ai-toggle-buttons .v-btn:last-child {
|
||||
min-width: 32px !important;
|
||||
min-inline-size: 32px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,18 @@ declare let self: ServiceWorkerGlobalScope & {
|
||||
|
||||
// 缓存版本控制
|
||||
const RESOURCE_VERSION = 'V2'
|
||||
const CACHE_VERSION = `${__APP_VERSION__}-${__BUILD_TIME__}` // 开发环境下无法使用此环境变量,生产环境正常
|
||||
// 开发态 dev-sw 可能拿不到 Vite define 注入;仅在开发环境做 dev 兜底
|
||||
const hasAppVersion = typeof __APP_VERSION__ !== 'undefined'
|
||||
const hasBuildTime = typeof __BUILD_TIME__ !== 'undefined'
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
if (!isDev && (!hasAppVersion || !hasBuildTime)) {
|
||||
throw new Error('[SW] Missing __APP_VERSION__ or __BUILD_TIME__ in production build')
|
||||
}
|
||||
|
||||
const appVersion = hasAppVersion ? __APP_VERSION__ : 'dev'
|
||||
const buildTime = hasBuildTime ? __BUILD_TIME__ : 'dev'
|
||||
const CACHE_VERSION = `${appVersion}-${buildTime}`
|
||||
|
||||
// 启用导航预载
|
||||
navigationPreload.enable()
|
||||
|
||||
@@ -23,6 +23,14 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
|
||||
|
||||
// 检查版本更新
|
||||
if (result.FRONTEND_VERSION) {
|
||||
const isBackendDev = Boolean(result.BACKEND_DEV)
|
||||
const skipVersionCheck = import.meta.env.DEV || isBackendDev
|
||||
|
||||
if (skipVersionCheck) {
|
||||
console.log('[VersionChecker] 开发环境下跳过版本一致性检查')
|
||||
return
|
||||
}
|
||||
|
||||
const { checkVersion } = useVersionChecker()
|
||||
await checkVersion(result.FRONTEND_VERSION)
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ const searchType = ref('title')
|
||||
const chooseSiteDialog = ref(false)
|
||||
|
||||
// 计算主题是否为透明
|
||||
const isNonTransparentTheme = computed(() => {
|
||||
return theme.name.value !== 'transparent'
|
||||
const isTransparentTheme = computed(() => {
|
||||
return theme.name.value === 'transparent'
|
||||
})
|
||||
|
||||
// 查询所有站点
|
||||
@@ -567,12 +567,16 @@ onBeforeMount(() => {
|
||||
|
||||
<template>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" class="max-w-8xl mx-auto px-4">
|
||||
<template v-if="(getBackdropUrl || getPosterUrl) && isNonTransparentTheme">
|
||||
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
|
||||
<div
|
||||
v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id"
|
||||
class="max-w-8xl mx-auto px-4"
|
||||
:class="{ 'media-detail-transparent': isTransparentTheme }"
|
||||
>
|
||||
<template v-if="getBackdropUrl || getPosterUrl">
|
||||
<div class="vue-media-back vue-media-back-image absolute left-0 top-0 w-full h-96">
|
||||
<VImg class="h-96" position="top" :src="getBackdropUrl || getPosterUrl" cover />
|
||||
</div>
|
||||
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
|
||||
<div class="vue-media-back vue-media-back-overlay absolute left-0 top-0 w-full h-96" />
|
||||
</template>
|
||||
<div class="media-page">
|
||||
<div class="media-header">
|
||||
@@ -1038,18 +1042,54 @@ onBeforeMount(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vue-media-back {
|
||||
--media-backdrop-edge-opacity: 1;
|
||||
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(var(--v-theme-background), 0) 50%,
|
||||
rgba(var(--v-theme-background), 1) 100%
|
||||
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
|
||||
),
|
||||
linear-gradient(90deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),
|
||||
linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);
|
||||
linear-gradient(
|
||||
0deg,
|
||||
rgba(var(--v-theme-background), 0) 80%,
|
||||
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(var(--v-theme-background), 0) 50%,
|
||||
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
270deg,
|
||||
rgba(var(--v-theme-background), 0) 50%,
|
||||
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
|
||||
);
|
||||
margin-block-start: calc(-70px - env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.vue-media-back-image {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.media-detail-transparent .vue-media-back-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-detail-transparent .vue-media-back-image {
|
||||
opacity: 0.78;
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
|
||||
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
|
||||
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
|
||||
-webkit-mask-composite: source-in;
|
||||
}
|
||||
|
||||
.media-page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background-position: 50%;
|
||||
background-size: cover;
|
||||
margin-block-start: calc(-4rem - env(safe-area-inset-top));
|
||||
|
||||
@@ -13,14 +13,20 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const { useProgressSSE } = useBackgroundOptimization()
|
||||
|
||||
// 计算列表可用高度
|
||||
// componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111
|
||||
@@ -44,6 +50,16 @@ const transferQueueDialog = ref(false)
|
||||
// 当前操作记录
|
||||
const currentHistory = ref<TransferHistory>()
|
||||
|
||||
// AI整理中的记录
|
||||
const aiRedoIds = ref<number[]>([])
|
||||
|
||||
// AI整理进度
|
||||
const aiRedoProgressDialog = ref(false)
|
||||
const aiRedoProgressActive = ref(false)
|
||||
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
|
||||
const aiRedoProgressSSE = ref<any>(null)
|
||||
const aiRedoProgressHistoryId = ref<number>()
|
||||
|
||||
// 重新整理IDS
|
||||
const redoIds = ref<number[]>([])
|
||||
const redoTargetStorage = ref<string>()
|
||||
@@ -425,32 +441,147 @@ function transferDone() {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: t('transferHistory.actions.redo'),
|
||||
value: 1,
|
||||
props: {
|
||||
prependIcon: 'mdi-redo-variant',
|
||||
click: (item: TransferHistory) => {
|
||||
redoIds.value = [item.id]
|
||||
redoTargetStorage.value = item.dest_storage
|
||||
redoDialog.value = true
|
||||
// AI助手是否启用
|
||||
const aiAgentEnabled = computed(() => Boolean(globalSettingsStore.globalSettings.AI_AGENT_ENABLE))
|
||||
const hasRunningAiRedo = computed(() => aiRedoIds.value.length > 0)
|
||||
|
||||
// AI整理中的记录
|
||||
function isAiRedoing(historyId: number) {
|
||||
return aiRedoIds.value.includes(historyId)
|
||||
}
|
||||
|
||||
// 停止AI整理进度
|
||||
function stopAiRedoProgress() {
|
||||
aiRedoProgressActive.value = false
|
||||
|
||||
if (aiRedoProgressSSE.value) {
|
||||
aiRedoProgressSSE.value.stop()
|
||||
aiRedoProgressSSE.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// AI整理完成
|
||||
async function finishAiRedo(success: boolean, errorMessage?: string) {
|
||||
const historyId = aiRedoProgressHistoryId.value
|
||||
|
||||
stopAiRedoProgress()
|
||||
aiRedoProgressDialog.value = false
|
||||
aiRedoProgressHistoryId.value = undefined
|
||||
|
||||
if (historyId !== undefined) {
|
||||
aiRedoIds.value = aiRedoIds.value.filter(id => id !== historyId)
|
||||
}
|
||||
|
||||
await fetchData()
|
||||
|
||||
if (!success && errorMessage) {
|
||||
$toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理AI整理进度
|
||||
async function handleAiRedoProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (!progress) return
|
||||
|
||||
aiRedoProgressText.value = progress.text || t('transferHistory.actions.aiRedoPending')
|
||||
|
||||
if (progress.enable === false) {
|
||||
await finishAiRedo(progress.data?.success !== false, progress.data?.error)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始监听整理进度
|
||||
function startAiRedoProgress(historyId: number, progressKey: string) {
|
||||
stopAiRedoProgress()
|
||||
|
||||
aiRedoProgressHistoryId.value = historyId
|
||||
aiRedoProgressDialog.value = true
|
||||
aiRedoProgressActive.value = true
|
||||
aiRedoProgressText.value = t('transferHistory.actions.aiRedoPending')
|
||||
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${progressKey}`
|
||||
|
||||
aiRedoProgressSSE.value = useProgressSSE(
|
||||
url,
|
||||
handleAiRedoProgressMessage,
|
||||
`transfer-history-ai-redo-${progressKey}`,
|
||||
aiRedoProgressActive,
|
||||
)
|
||||
|
||||
aiRedoProgressSSE.value.start()
|
||||
}
|
||||
|
||||
// 触发AI整理
|
||||
async function triggerAiRedo(item: TransferHistory) {
|
||||
if (!aiAgentEnabled.value) {
|
||||
$toast.error(t('transferHistory.aiRedoDisabled'))
|
||||
return
|
||||
}
|
||||
if (hasRunningAiRedo.value) return
|
||||
|
||||
aiRedoIds.value = [...aiRedoIds.value, item.id]
|
||||
let progressStarted = false
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`history/transfer/${item.id}/ai-redo`)
|
||||
|
||||
const progressKey = result.data?.progress_key
|
||||
|
||||
if (!result.success || !progressKey) {
|
||||
$toast.error(result.message || t('transferHistory.aiRedoFailed'))
|
||||
return
|
||||
}
|
||||
startAiRedoProgress(item.id, progressKey)
|
||||
progressStarted = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('transferHistory.aiRedoFailed'))
|
||||
} finally {
|
||||
if (!progressStarted) {
|
||||
aiRedoIds.value = aiRedoIds.value.filter(id => id !== item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算下拉菜单
|
||||
function getDropdownItems(item: TransferHistory) {
|
||||
return [
|
||||
{
|
||||
title: isAiRedoing(item.id) ? t('transferHistory.actions.aiRedoPending') : t('transferHistory.actions.aiRedo'),
|
||||
value: 0,
|
||||
props: {
|
||||
prependIcon: 'mdi-robot-outline',
|
||||
disabled: !aiAgentEnabled.value || (hasRunningAiRedo.value && !isAiRedoing(item.id)),
|
||||
click: () => {
|
||||
triggerAiRedo(item)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('transferHistory.actions.delete'),
|
||||
value: 2,
|
||||
props: {
|
||||
prependIcon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
click: (item: TransferHistory) => {
|
||||
removeHistory(item)
|
||||
{
|
||||
title: t('transferHistory.actions.redo'),
|
||||
value: 1,
|
||||
props: {
|
||||
prependIcon: 'mdi-redo-variant',
|
||||
click: () => {
|
||||
redoIds.value = [item.id]
|
||||
redoTargetStorage.value = item.dest_storage
|
||||
redoDialog.value = true
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
{
|
||||
title: t('transferHistory.actions.delete'),
|
||||
value: 2,
|
||||
props: {
|
||||
prependIcon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
click: () => {
|
||||
removeHistory(item)
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// 添加url参数
|
||||
function addUrlQuery(url: string, name: string, value: any) {
|
||||
@@ -515,6 +646,10 @@ onMounted(() => {
|
||||
loadStorages()
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAiRedoProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -642,10 +777,11 @@ onMounted(() => {
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
v-for="(menu, i) in getDropdownItems(item)"
|
||||
:key="i"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
:disabled="menu.props.disabled"
|
||||
@click="menu.props.click()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
@@ -728,10 +864,11 @@ onMounted(() => {
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
v-for="(menu, i) in getDropdownItems(item)"
|
||||
:key="i"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
:disabled="menu.props.disabled"
|
||||
@click="menu.props.click()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
@@ -813,6 +950,7 @@ onMounted(() => {
|
||||
</VBottomSheet>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||
<ProgressDialog v-if="aiRedoProgressDialog" v-model="aiRedoProgressDialog" :text="aiRedoProgressText" />
|
||||
<!-- 文件整理弹窗 -->
|
||||
<ReorganizeDialog
|
||||
v-if="redoDialog"
|
||||
|
||||
@@ -37,9 +37,10 @@ const SystemSettings = ref<any>({
|
||||
AI_AGENT_JOB_INTERVAL: 24,
|
||||
LLM_PROVIDER: 'deepseek',
|
||||
LLM_MODEL: 'deepseek-chat',
|
||||
LLM_SUPPORT_IMAGE_INPUT: false,
|
||||
LLM_API_KEY: null,
|
||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||
AI_AGENT_RETRY_TRANSFER: false,
|
||||
AI_AGENT_RETRY_TRANSFER: false,
|
||||
AI_RECOMMEND_ENABLED: false,
|
||||
AI_RECOMMEND_USER_PREFERENCE: null,
|
||||
AI_RECOMMEND_MAX_ITEMS: 50,
|
||||
@@ -794,6 +795,14 @@ onDeactivated(() => {
|
||||
prepend-inner-icon="mdi-timer-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.LLM_SUPPORT_IMAGE_INPUT"
|
||||
:label="t('setting.system.llmSupportImageInput')"
|
||||
:hint="t('setting.system.llmSupportImageInputHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_RETRY_TRANSFER"
|
||||
|
||||
Reference in New Issue
Block a user