mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 10:00:08 +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",
|
"name": "moviepilot",
|
||||||
"version": "2.9.27",
|
"version": "2.9.31",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
@@ -237,14 +237,6 @@ async function handleCheckSubscribe() {
|
|||||||
// 查询当前媒体是否已入库
|
// 查询当前媒体是否已入库
|
||||||
async function handleCheckExists() {
|
async function handleCheckExists() {
|
||||||
try {
|
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', {
|
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||||
params: {
|
params: {
|
||||||
tmdbid: props.media?.tmdb_id,
|
tmdbid: props.media?.tmdb_id,
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Site } from '@/api/types'
|
|
||||||
import api from '@/api'
|
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 { formatFileSize } from '@core/utils/formatters'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
// 响应式断点
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -23,6 +26,30 @@ const selectCategory = ref<number[]>([])
|
|||||||
// 全部分类
|
// 全部分类
|
||||||
const siteCategoryList = ref<SiteCategory[]>()
|
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(() => {
|
const categoryOptions = computed(() => {
|
||||||
return siteCategoryList.value?.map(item => {
|
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 resourceTotalItems = computed(() => resourceDataList.value.length)
|
||||||
|
|
||||||
// 每页条数
|
|
||||||
const resourceItemsPerPage = ref(25)
|
|
||||||
|
|
||||||
// 加载状态
|
|
||||||
const resourceLoading = ref(false)
|
|
||||||
|
|
||||||
// 种子元数据
|
|
||||||
const torrent = ref<TorrentInfo>()
|
|
||||||
|
|
||||||
// 资源浏览表头
|
// 资源浏览表头
|
||||||
const resourceHeaders = [
|
const resourceHeaders = computed(() => [
|
||||||
{ title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },
|
{ title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },
|
||||||
{ title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },
|
{ title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },
|
||||||
{ title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },
|
{ title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },
|
||||||
{ title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },
|
{ title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },
|
||||||
{ title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },
|
{ title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },
|
||||||
{ title: '', key: 'actions', sortable: false },
|
{ 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) {
|
function openTorrentDetail(page_url: string) {
|
||||||
|
if (!page_url) return
|
||||||
window.open(page_url, '_blank')
|
window.open(page_url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载种子文件
|
// 下载种子文件
|
||||||
async function downloadTorrentFile(enclosure: string) {
|
async function downloadTorrentFile(enclosure: string) {
|
||||||
|
if (!enclosure) return
|
||||||
window.open(enclosure, '_blank')
|
window.open(enclosure, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 促销Chip类
|
// 促销Chip类
|
||||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||||
else return 'text-white bg-gray-500'
|
|
||||||
|
return 'text-white bg-gray-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加下载
|
// 添加下载
|
||||||
async function addDownload(_torrent: any) {
|
async function addDownload(_torrent: TorrentInfo) {
|
||||||
torrent.value = _torrent
|
torrent.value = _torrent
|
||||||
addDownloadDialog.value = true
|
addDownloadDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加下载对话框
|
|
||||||
const addDownloadDialog = ref(false)
|
|
||||||
|
|
||||||
// 添加下载成功
|
// 添加下载成功
|
||||||
function addDownloadSuccess(url: string) {
|
function addDownloadSuccess(_url: string) {
|
||||||
addDownloadDialog.value = false
|
addDownloadDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加下载失败
|
// 添加下载失败
|
||||||
function addDownloadError(error: string) {
|
function addDownloadError(_error: string) {
|
||||||
addDownloadDialog.value = false
|
addDownloadDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用API,查询站点资源
|
// 调用API,查询站点资源
|
||||||
async function getResourceList() {
|
async function getResourceList() {
|
||||||
resourceLoading.value = true
|
resourceLoading.value = true
|
||||||
|
resourcePage.value = 1
|
||||||
|
|
||||||
try {
|
try {
|
||||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
@@ -111,7 +146,12 @@ async function getResourceList() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
resourceLoading.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
getSiteCategoryList()
|
getSiteCategoryList()
|
||||||
getResourceList()
|
getResourceList()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
<VDialog scrollable :fullscreen="display.smAndDown.value" max-width="92rem" transition="dialog-bottom-transition">
|
||||||
<VCard>
|
<VCard class="site-resource-dialog">
|
||||||
<!-- Toolbar -->
|
|
||||||
<div>
|
<div>
|
||||||
<VToolbar color="primary" density="comfortable">
|
<VToolbar color="primary" density="comfortable">
|
||||||
<VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>
|
<VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>
|
||||||
@@ -144,45 +212,153 @@ onMounted(() => {
|
|||||||
</VToolbarItems>
|
</VToolbarItems>
|
||||||
</VToolbar>
|
</VToolbar>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3">
|
|
||||||
<VRow>
|
<div class="pa-3 pb-2">
|
||||||
<VCol cols="6" md="5">
|
<template v-if="!isMobileLayout">
|
||||||
<VTextField
|
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||||
v-model="keyword"
|
<div class="site-resource-filter-panel__inner">
|
||||||
size="small"
|
<VRow class="site-resource-filter-row">
|
||||||
density="compact"
|
<VCol cols="12" md="4">
|
||||||
:label="t('dialog.siteResource.searchKeyword')"
|
<VTextField
|
||||||
clearable
|
v-model="keyword"
|
||||||
prepend-inner-icon="mdi-magnify"
|
class="site-resource-filter-input"
|
||||||
/>
|
size="small"
|
||||||
</VCol>
|
density="compact"
|
||||||
<VCol cols="6" md="5">
|
variant="solo-filled"
|
||||||
<VSelect
|
flat
|
||||||
v-model="selectCategory"
|
:label="keywordFieldLabel"
|
||||||
:items="categoryOptions"
|
clearable
|
||||||
size="small"
|
prepend-inner-icon="mdi-magnify"
|
||||||
density="compact"
|
hide-details
|
||||||
chips
|
@keyup.enter="getResourceList"
|
||||||
:label="t('dialog.siteResource.resourceCategory')"
|
/>
|
||||||
multiple
|
</VCol>
|
||||||
clearable
|
<VCol cols="12" md="5">
|
||||||
prepend-inner-icon="mdi-folder"
|
<VSelect
|
||||||
/>
|
v-model="selectCategory"
|
||||||
</VCol>
|
:items="categoryOptions"
|
||||||
<VCol cols="12" md="2" class="text-center">
|
class="site-resource-filter-input"
|
||||||
<VBtn variant="tonal" block prepend-icon="mdi-magnify" @click="getResourceList">
|
size="small"
|
||||||
{{ t('dialog.siteResource.search') }}
|
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>
|
</VBtn>
|
||||||
</VCol>
|
<div v-if="resourceTotalItems > 0" class="text-body-2 text-medium-emphasis">
|
||||||
</VRow>
|
{{ 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>
|
</div>
|
||||||
<VCardText class="px-0 py-0 my-0">
|
|
||||||
|
<VCardText class="site-resource-content px-0 py-0 my-0">
|
||||||
<VDataTable
|
<VDataTable
|
||||||
|
v-if="display.mdAndUp.value"
|
||||||
|
v-model:page="resourcePage"
|
||||||
v-model:items-per-page="resourceItemsPerPage"
|
v-model:items-per-page="resourceItemsPerPage"
|
||||||
:headers="resourceHeaders"
|
:headers="resourceHeaders"
|
||||||
:items="resourceDataList"
|
:items="resourceDataList"
|
||||||
:items-length="resourceTotalItems"
|
:items-length="resourceTotalItems"
|
||||||
:search="resourceSearch"
|
|
||||||
:loading="resourceLoading"
|
:loading="resourceLoading"
|
||||||
density="compact"
|
density="compact"
|
||||||
item-value="title"
|
item-value="title"
|
||||||
@@ -191,60 +367,69 @@ onMounted(() => {
|
|||||||
hover
|
hover
|
||||||
:items-per-page-text="t('dialog.siteResource.itemsPerPage')"
|
:items-per-page-text="t('dialog.siteResource.itemsPerPage')"
|
||||||
:loading-text="t('dialog.siteResource.loading')"
|
: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 }">
|
<template #item.title="{ item }">
|
||||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
<button type="button" class="site-resource-title-btn text-start" @click.stop="addDownload(item)">
|
||||||
<div class="text-high-emphasis pt-1">
|
<div class="text-high-emphasis pt-1 font-weight-medium">
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm my-1">
|
<div v-if="item.description" class="text-sm my-1 text-medium-emphasis">
|
||||||
{{ item.description }}
|
{{ item.description }}
|
||||||
</div>
|
</div>
|
||||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
<div class="mt-2">
|
||||||
H&R
|
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||||
</VChip>
|
H&R
|
||||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
</VChip>
|
||||||
{{ item.freedate_diff }}
|
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||||
</VChip>
|
{{ item.freedate_diff }}
|
||||||
<VChip
|
</VChip>
|
||||||
v-for="(label, index) in item.labels"
|
<VChip
|
||||||
:key="index"
|
v-for="(label, index) in item.labels"
|
||||||
variant="elevated"
|
:key="index"
|
||||||
size="small"
|
variant="elevated"
|
||||||
color="primary"
|
size="small"
|
||||||
class="me-1 mb-1"
|
color="primary"
|
||||||
>
|
class="me-1 mb-1"
|
||||||
{{ label }}
|
>
|
||||||
</VChip>
|
{{ label }}
|
||||||
<VChip
|
</VChip>
|
||||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
<VChip
|
||||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||||
variant="elevated"
|
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||||
size="small"
|
variant="elevated"
|
||||||
class="me-1 mb-1"
|
size="small"
|
||||||
>
|
class="me-1 mb-1"
|
||||||
{{ item.volume_factor }}
|
>
|
||||||
</VChip>
|
{{ item.volume_factor }}
|
||||||
</a>
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.pubdate="{ item }">
|
<template #item.pubdate="{ item }">
|
||||||
<div>{{ item.date_elapsed }}</div>
|
<div>{{ item.date_elapsed }}</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm text-medium-emphasis">
|
||||||
{{ item.pubdate }}
|
{{ item.pubdate }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.size="{ item }">
|
<template #item.size="{ item }">
|
||||||
<div class="text-nowrap whitespace-nowrap">
|
<div class="text-nowrap whitespace-nowrap">
|
||||||
{{ formatFileSize(item.size) }}
|
{{ formatFileSize(item.size) }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.seeders="{ item }">
|
<template #item.seeders="{ item }">
|
||||||
<div>{{ item.seeders }}</div>
|
<div>{{ item.seeders }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.peers="{ item }">
|
<template #item.peers="{ item }">
|
||||||
<div>{{ item.peers }}</div>
|
<div>{{ item.peers }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.actions="{ item }">
|
<template #item.actions="{ item }">
|
||||||
<div class="me-n3">
|
<div class="me-n3">
|
||||||
<IconBtn>
|
<IconBtn>
|
||||||
@@ -268,11 +453,119 @@ onMounted(() => {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #no-data>{{ t('dialog.siteResource.noData') }}</template>
|
<template #no-data>{{ t('dialog.siteResource.noData') }}</template>
|
||||||
</VDataTable>
|
</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>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
<!-- 添加下载对话框 -->
|
|
||||||
<AddDownloadDialog
|
<AddDownloadDialog
|
||||||
v-if="addDownloadDialog"
|
v-if="addDownloadDialog"
|
||||||
v-model="addDownloadDialog"
|
v-model="addDownloadDialog"
|
||||||
@@ -285,7 +578,160 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.v-table th {
|
||||||
white-space: nowrap;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,44 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const menu = ref(false)
|
||||||
const currentCron = ref(props.modelValue)
|
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 => {
|
watch(currentCron, newVal => {
|
||||||
emit('update:modelValue', newVal)
|
emit('update:modelValue', newVal)
|
||||||
@@ -23,8 +60,13 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div ref="menuRoot">
|
||||||
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
|
<VMenu
|
||||||
|
v-model="menu"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
:content-class="['cursor-default', menuContentClass]"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<slot name="activator" :menuprops="props" />
|
<slot name="activator" :menuprops="props" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -103,8 +103,21 @@ const selectedPath = computed(() => {
|
|||||||
return ''
|
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 => {
|
watch(activedDirs, newVal => {
|
||||||
if (!newVal.length) return
|
if (!newVal.length) return
|
||||||
|
|
||||||
emit('update:modelValue', selectedPath.value)
|
emit('update:modelValue', selectedPath.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -165,8 +178,10 @@ watch(
|
|||||||
activatable
|
activatable
|
||||||
return-object
|
return-object
|
||||||
max-height="20rem"
|
max-height="20rem"
|
||||||
|
open-on-click
|
||||||
expand-icon="mdi-folder"
|
expand-icon="mdi-folder"
|
||||||
collapse-icon="mdi-folder-open"
|
collapse-icon="mdi-folder-open"
|
||||||
|
@click:open="activateDir"
|
||||||
/>
|
/>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2787,7 +2787,12 @@ export default {
|
|||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
pageSize: 'Items Per Page',
|
pageSize: 'Items Per Page',
|
||||||
pageInfo: '{begin} - {end} / {total}',
|
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: {
|
actions: {
|
||||||
|
aiRedo: 'Assistant Organize',
|
||||||
|
aiRedoPending: 'Assistant Organizing...',
|
||||||
redo: 'Reorganize',
|
redo: 'Reorganize',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1321,6 +1321,9 @@ export default {
|
|||||||
llmProviderHint: '选择使用的LLM服务提供商',
|
llmProviderHint: '选择使用的LLM服务提供商',
|
||||||
llmModel: 'LLM模型名称',
|
llmModel: 'LLM模型名称',
|
||||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||||
|
llmSupportImageInput: '模型支持图片输入',
|
||||||
|
llmSupportImageInputHint:
|
||||||
|
'启用后,消息中的图片会按多模态图片发送给 LLM;关闭后图片会作为附件保存到本地,并将文件路径提供给智能助手处理',
|
||||||
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
|
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
|
||||||
llmMaxContextTokensHint:
|
llmMaxContextTokensHint:
|
||||||
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
|
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
|
||||||
@@ -2749,7 +2752,12 @@ export default {
|
|||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
pageSize: '每页条数',
|
pageSize: '每页条数',
|
||||||
pageInfo: '{begin} - {end} / {total}',
|
pageInfo: '{begin} - {end} / {total}',
|
||||||
|
aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',
|
||||||
|
aiRedoQueued: '已提交智能助手整理任务:{title}',
|
||||||
|
aiRedoFailed: '提交智能助手整理任务失败',
|
||||||
actions: {
|
actions: {
|
||||||
|
aiRedo: '智能助手整理',
|
||||||
|
aiRedoPending: '智能助手整理中...',
|
||||||
redo: '重新整理',
|
redo: '重新整理',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1322,6 +1322,9 @@ export default {
|
|||||||
llmProviderHint: '選擇使用的LLM服務提供商',
|
llmProviderHint: '選擇使用的LLM服務提供商',
|
||||||
llmModel: 'LLM模型名稱',
|
llmModel: 'LLM模型名稱',
|
||||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||||
|
llmSupportImageInput: '模型支援圖片輸入',
|
||||||
|
llmSupportImageInputHint:
|
||||||
|
'啟用後,消息中的圖片會按多模態圖片發送給 LLM;關閉後圖片會作為附件保存到本地,並將檔案路徑提供給智能助手處理',
|
||||||
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
|
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
|
||||||
llmMaxContextTokensHint:
|
llmMaxContextTokensHint:
|
||||||
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
|
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
|
||||||
@@ -2750,7 +2753,12 @@ export default {
|
|||||||
loading: '加載中...',
|
loading: '加載中...',
|
||||||
pageSize: '每頁條數',
|
pageSize: '每頁條數',
|
||||||
pageInfo: '{begin} - {end} / {total}',
|
pageInfo: '{begin} - {end} / {total}',
|
||||||
|
aiRedoDisabled: '請先在系統設置中啟用 AI 智能助手',
|
||||||
|
aiRedoQueued: '已提交智能助手整理任務:{title}',
|
||||||
|
aiRedoFailed: '提交智能助手整理任務失敗',
|
||||||
actions: {
|
actions: {
|
||||||
|
aiRedo: '智能助手整理',
|
||||||
|
aiRedoPending: '智能助手整理中...',
|
||||||
redo: '重新整理',
|
redo: '重新整理',
|
||||||
delete: '刪除',
|
delete: '刪除',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import TorrentCard from '@/components/cards/TorrentCard.vue'
|
|||||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||||
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
|
||||||
import { useGlobalSettingsStore } from '@/stores/global'
|
import { useGlobalSettingsStore } from '@/stores/global'
|
||||||
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
||||||
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
||||||
@@ -15,7 +14,6 @@ import { useToast } from 'vue-toastification'
|
|||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { useProgressSSE } = useBackgroundOptimization()
|
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -109,12 +107,43 @@ const progressEnabled = ref(false)
|
|||||||
// 进度是否激活
|
// 进度是否激活
|
||||||
const progressActive = 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 errorTitle = ref(t('resource.noData'))
|
||||||
|
|
||||||
// 错误描述
|
// 错误描述
|
||||||
const errorDescription = ref(t('resource.noResourceFound'))
|
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(
|
watch(
|
||||||
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
|
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
|
||||||
@@ -169,39 +198,19 @@ const watchProgressValue = watch(
|
|||||||
}, 60_000),
|
}, 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监听加载进度
|
// 使用SSE监听加载进度
|
||||||
function startLoadingProgress() {
|
function startLoadingProgress() {
|
||||||
watchProgressValue.resume()
|
watchProgressValue.resume()
|
||||||
progressText.value = t('resource.searching')
|
progressText.value = t('resource.searching')
|
||||||
progressValue.value = 0
|
progressValue.value = 0
|
||||||
progressEnabled.value = false
|
progressEnabled.value = true
|
||||||
progressActive.value = true
|
progressActive.value = true
|
||||||
progressSSE.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止监听加载进度
|
// 停止监听加载进度
|
||||||
function stopLoadingProgress() {
|
function stopLoadingProgress() {
|
||||||
watchProgressValue.pause()
|
watchProgressValue.pause()
|
||||||
progressActive.value = false
|
progressActive.value = false
|
||||||
progressSSE.stop()
|
|
||||||
|
|
||||||
// 确保进度显示100%,然后再渐进清零
|
// 确保进度显示100%,然后再渐进清零
|
||||||
progressValue.value = 100
|
progressValue.value = 100
|
||||||
@@ -211,6 +220,203 @@ function stopLoadingProgress() {
|
|||||||
}, 1500)
|
}, 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) {
|
function changeViewType(newType: string) {
|
||||||
if (viewType.value !== newType) {
|
if (viewType.value !== newType) {
|
||||||
@@ -233,38 +439,13 @@ async function fetchData() {
|
|||||||
rawDataList.value = (results as unknown as Context[]) || []
|
rawDataList.value = (results as unknown as Context[]) || []
|
||||||
originalDataList.value = (results as unknown as Context[]) || []
|
originalDataList.value = (results as unknown as Context[]) || []
|
||||||
} else {
|
} else {
|
||||||
|
resetSearchResults()
|
||||||
startLoadingProgress()
|
startLoadingProgress()
|
||||||
let result: { [key: string]: any }
|
try {
|
||||||
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索
|
await searchByStream()
|
||||||
if (/^[a-zA-Z]+:/.test(keyword)) {
|
} catch (error) {
|
||||||
result = await api.get(`search/media/${keyword}`, {
|
console.warn('渐进式搜索连接失败,回退到普通搜索:', error)
|
||||||
params: {
|
await searchByRequest()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
// 从浏览器历史中删除当前搜索
|
// 从浏览器历史中删除当前搜索
|
||||||
@@ -276,6 +457,7 @@ async function fetchData() {
|
|||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
closeSearchEventSource()
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
@@ -542,7 +724,12 @@ const hasData = computed(() => {
|
|||||||
// 使用 watchEffect 确保计算属性变化时立即响应
|
// 使用 watchEffect 确保计算属性变化时立即响应
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
// 需要满足:AI 功能启用、数据已加载、尚未检查
|
// 需要满足:AI 功能启用、数据已加载、尚未检查
|
||||||
if (aiRecommendEnabled.value && originalDataList.value.length > 0 && !aiStatusChecked.value) {
|
if (
|
||||||
|
aiRecommendEnabled.value &&
|
||||||
|
originalDataList.value.length > 0 &&
|
||||||
|
!progressActive.value &&
|
||||||
|
!aiStatusChecked.value
|
||||||
|
) {
|
||||||
checkAiRecommendStatus()
|
checkAiRecommendStatus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -554,6 +741,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 卸载时停止轮询
|
// 卸载时停止轮询
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
closeSearchEventSource()
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
stopAiRecommendPolling()
|
stopAiRecommendPolling()
|
||||||
})
|
})
|
||||||
@@ -561,24 +749,67 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- 加载进度条 -->
|
<!-- 搜索加载状态 -->
|
||||||
<VFadeTransition>
|
<VFadeTransition>
|
||||||
<div v-if="progressValue > 0 || progressEnabled" class="search-progress-container">
|
<div v-if="isSearchProgressVisible" class="search-loading-state mb-3" :class="{ 'is-empty-loading': isSearchLoading }">
|
||||||
<VCard elevation="3" class="search-progress-card">
|
<VCard elevation="0" class="search-progress-card">
|
||||||
<div class="progress-header">
|
<div class="progress-header">
|
||||||
<VIcon icon="mdi-movie-search" color="primary" size="small" class="me-2" />
|
<div class="progress-icon-wrap">
|
||||||
<span class="progress-title">{{ progressText }}</span>
|
<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>
|
||||||
<div class="progress-bar-container">
|
<div class="progress-bar-container">
|
||||||
<VProgressLinear color="primary" rounded :model-value="progressValue" />
|
<VProgressLinear
|
||||||
<div class="progress-percentage">{{ Math.ceil(progressValue) }}%</div>
|
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>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</VFadeTransition>
|
</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-info-container">
|
||||||
<div class="search-title text-moviepilot">
|
<div class="search-title text-moviepilot">
|
||||||
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
|
<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">
|
<div v-if="isRefreshed && hasData" class="search-results-container">
|
||||||
<!-- 筛选栏 -->
|
<!-- 筛选栏 -->
|
||||||
<TorrentFilterBar
|
<TorrentFilterBar
|
||||||
|
v-if="!progressActive"
|
||||||
:filter-form="torrentFilter.filterForm"
|
:filter-form="torrentFilter.filterForm"
|
||||||
:filter-options="torrentFilter.filterOptions"
|
:filter-options="torrentFilter.filterOptions"
|
||||||
:sort-field="torrentFilter.sortField.value"
|
:sort-field="torrentFilter.sortField.value"
|
||||||
:sort-type="torrentFilter.sortType.value"
|
:sort-type="torrentFilter.sortType.value"
|
||||||
:total-filtered-count="torrentFilter.totalFilteredCount.value"
|
:total-filtered-count="displayResourceCount"
|
||||||
:filter-titles="torrentFilter.filterTitles"
|
:filter-titles="torrentFilter.filterTitles"
|
||||||
:sort-titles="torrentFilter.sortTitles"
|
:sort-titles="torrentFilter.sortTitles"
|
||||||
:enable-animation="enableFilterAnimation"
|
:enable-animation="enableFilterAnimation"
|
||||||
@@ -701,10 +933,11 @@ onUnmounted(() => {
|
|||||||
<template #empty />
|
<template #empty />
|
||||||
<div class="grid gap-4 grid-torrent-card items-start">
|
<div class="grid gap-4 grid-torrent-card items-start">
|
||||||
<TorrentCard
|
<TorrentCard
|
||||||
v-for="item in cardScroll.displayDataList.value"
|
v-for="(item, index) in cardScroll.displayDataList.value"
|
||||||
:key="`${item.torrent_info.page_url}`"
|
:key="getTorrentItemKey(item, index)"
|
||||||
:torrent="item"
|
:torrent="item"
|
||||||
:more="item.more"
|
:more="item.more"
|
||||||
|
class="stream-result-item"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</VInfiniteScroll>
|
</VInfiniteScroll>
|
||||||
@@ -736,7 +969,8 @@ onUnmounted(() => {
|
|||||||
<template #empty />
|
<template #empty />
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in rowScroll.displayDataList.value"
|
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" />
|
<TorrentItem :torrent="item" />
|
||||||
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
|
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
|
||||||
@@ -756,7 +990,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 初始加载状态 -->
|
<!-- 初始加载状态 -->
|
||||||
<LoadingBanner v-else-if="!isRefreshed && !(progressEnabled || progressValue > 0)" />
|
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
|
||||||
<!-- 滚动到顶部按钮 -->
|
<!-- 滚动到顶部按钮 -->
|
||||||
<Teleport to="body" v-if="route.path === '/resource'">
|
<Teleport to="body" v-if="route.path === '/resource'">
|
||||||
<VScrollToTopBtn />
|
<VScrollToTopBtn />
|
||||||
@@ -765,51 +999,111 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-progress-container {
|
.search-loading-state {
|
||||||
position: fixed;
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
inset-block-start: env(safe-area-inset-top);
|
gap: 16px;
|
||||||
inset-inline: 0;
|
}
|
||||||
|
|
||||||
|
.search-loading-state.is-empty-loading {
|
||||||
|
min-block-size: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-progress-card {
|
.search-progress-card {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid rgba(var(--v-theme-primary), 0.1);
|
border: 1px solid rgba(var(--v-theme-primary), 0.18);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 10%);
|
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), rgb(var(--v-theme-surface));
|
||||||
inline-size: 90%;
|
inline-size: 100%;
|
||||||
max-inline-size: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-header {
|
.progress-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.progress-title {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
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 {
|
.progress-bar-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
margin-block-start: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-percentage {
|
.progress-percentage {
|
||||||
|
flex: 0 0 auto;
|
||||||
color: rgb(var(--v-theme-primary));
|
color: rgb(var(--v-theme-primary));
|
||||||
font-size: 0.8rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
min-inline-size: 36px;
|
min-inline-size: 44px;
|
||||||
text-align: end;
|
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 {
|
.search-header {
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
@@ -840,25 +1134,27 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-toggle-buttons {
|
.view-toggle-buttons {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||||
position: relative;
|
|
||||||
isolation: isolate; /* Create new stacking context */
|
isolation: isolate; /* Create new stacking context */
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-indicator {
|
.active-indicator {
|
||||||
position: absolute;
|
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;
|
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 {
|
.active-indicator.row {
|
||||||
@@ -866,6 +1162,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-toggle-btn {
|
.view-toggle-btn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2; /* Sit on top of indicator */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -875,13 +1173,11 @@ onUnmounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
inline-size: 40px;
|
inline-size: 40px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
z-index: 2; /* Sit on top of indicator */
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-toggle-btn:hover:not(.active) {
|
.view-toggle-btn:hover:not(.active) {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI按钮组样式 */
|
/* AI按钮组样式 */
|
||||||
@@ -891,31 +1187,31 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.ai-toggle-buttons {
|
.ai-toggle-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||||
overflow: hidden;
|
block-size: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
|
||||||
height: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-recommend-btn {
|
.ai-recommend-btn {
|
||||||
transition: all 0.3s ease;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100% !important;
|
block-size: 100% !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 仅为激活的按钮添加背景 */
|
/* 仅为激活的按钮添加背景 */
|
||||||
.ai-recommend-btn.ai-active {
|
.ai-recommend-btn.ai-active {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图标基础样式 */
|
/* 图标基础样式 */
|
||||||
.ai-icon {
|
.ai-icon {
|
||||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
|
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 激活状态图标:变色 + 辉光 */
|
/* 激活状态图标:变色 + 辉光 */
|
||||||
@@ -927,10 +1223,10 @@ onUnmounted(() => {
|
|||||||
/* 文字基础样式 */
|
/* 文字基础样式 */
|
||||||
.ai-text {
|
.ai-text {
|
||||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
font-weight: 600; /* 保持一致的字重防止位移 */
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
transition: color 0.3s ease;
|
font-weight: 600; /* 保持一致的字重防止位移 */
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
|
transition: color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 激活状态文字 */
|
/* 激活状态文字 */
|
||||||
@@ -945,12 +1241,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-divider {
|
.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;
|
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 {
|
.search-results-container {
|
||||||
@@ -1012,6 +1308,45 @@ onUnmounted(() => {
|
|||||||
display: none;
|
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 {
|
.view-toggle-container {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -1021,10 +1356,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.active-indicator {
|
.active-indicator {
|
||||||
top: 2px;
|
block-size: 32px;
|
||||||
left: 2px;
|
inline-size: 36px;
|
||||||
width: 36px;
|
inset-block-start: 2px;
|
||||||
height: 32px;
|
inset-inline-start: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-indicator.row {
|
.active-indicator.row {
|
||||||
@@ -1037,7 +1372,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-toggle-buttons {
|
.ai-toggle-buttons {
|
||||||
height: 36px;
|
block-size: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-text {
|
.ai-text {
|
||||||
@@ -1046,17 +1381,16 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.ai-recommend-btn,
|
.ai-recommend-btn,
|
||||||
.ai-toggle-buttons .v-btn {
|
.ai-toggle-buttons .v-btn {
|
||||||
height: 36px !important;
|
block-size: 36px !important;
|
||||||
min-width: unset !important;
|
min-inline-size: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-recommend-btn {
|
.ai-recommend-btn {
|
||||||
padding-inline-start: 12px !important;
|
padding-inline: 12px 8px !important;
|
||||||
padding-inline-end: 8px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-toggle-buttons .v-btn:last-child {
|
.ai-toggle-buttons .v-btn:last-child {
|
||||||
min-width: 32px !important;
|
min-inline-size: 32px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,7 +12,18 @@ declare let self: ServiceWorkerGlobalScope & {
|
|||||||
|
|
||||||
// 缓存版本控制
|
// 缓存版本控制
|
||||||
const RESOURCE_VERSION = 'V2'
|
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()
|
navigationPreload.enable()
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
|
|||||||
|
|
||||||
// 检查版本更新
|
// 检查版本更新
|
||||||
if (result.FRONTEND_VERSION) {
|
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()
|
const { checkVersion } = useVersionChecker()
|
||||||
await checkVersion(result.FRONTEND_VERSION)
|
await checkVersion(result.FRONTEND_VERSION)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ const searchType = ref('title')
|
|||||||
const chooseSiteDialog = ref(false)
|
const chooseSiteDialog = ref(false)
|
||||||
|
|
||||||
// 计算主题是否为透明
|
// 计算主题是否为透明
|
||||||
const isNonTransparentTheme = computed(() => {
|
const isTransparentTheme = computed(() => {
|
||||||
return theme.name.value !== 'transparent'
|
return theme.name.value === 'transparent'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 查询所有站点
|
// 查询所有站点
|
||||||
@@ -567,12 +567,16 @@ onBeforeMount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<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">
|
<div
|
||||||
<template v-if="(getBackdropUrl || getPosterUrl) && isNonTransparentTheme">
|
v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id"
|
||||||
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
|
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 />
|
<VImg class="h-96" position="top" :src="getBackdropUrl || getPosterUrl" cover />
|
||||||
</div>
|
</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>
|
</template>
|
||||||
<div class="media-page">
|
<div class="media-page">
|
||||||
<div class="media-header">
|
<div class="media-header">
|
||||||
@@ -1038,18 +1042,54 @@ onBeforeMount(() => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.vue-media-back {
|
.vue-media-back {
|
||||||
|
--media-backdrop-edge-opacity: 1;
|
||||||
|
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(var(--v-theme-background), 0) 50%,
|
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(
|
||||||
linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);
|
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));
|
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 {
|
.media-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
background-position: 50%;
|
background-position: 50%;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
margin-block-start: calc(-4rem - env(safe-area-inset-top));
|
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 { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||||
|
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 全局设置
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// APP
|
// APP
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
|
const { useProgressSSE } = useBackgroundOptimization()
|
||||||
|
|
||||||
// 计算列表可用高度
|
// 计算列表可用高度
|
||||||
// componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111
|
// componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111
|
||||||
@@ -44,6 +50,16 @@ const transferQueueDialog = ref(false)
|
|||||||
// 当前操作记录
|
// 当前操作记录
|
||||||
const currentHistory = ref<TransferHistory>()
|
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
|
// 重新整理IDS
|
||||||
const redoIds = ref<number[]>([])
|
const redoIds = ref<number[]>([])
|
||||||
const redoTargetStorage = ref<string>()
|
const redoTargetStorage = ref<string>()
|
||||||
@@ -425,32 +441,147 @@ function transferDone() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹出菜单
|
// AI助手是否启用
|
||||||
const dropdownItems = ref([
|
const aiAgentEnabled = computed(() => Boolean(globalSettingsStore.globalSettings.AI_AGENT_ENABLE))
|
||||||
{
|
const hasRunningAiRedo = computed(() => aiRedoIds.value.length > 0)
|
||||||
title: t('transferHistory.actions.redo'),
|
|
||||||
value: 1,
|
// AI整理中的记录
|
||||||
props: {
|
function isAiRedoing(historyId: number) {
|
||||||
prependIcon: 'mdi-redo-variant',
|
return aiRedoIds.value.includes(historyId)
|
||||||
click: (item: TransferHistory) => {
|
}
|
||||||
redoIds.value = [item.id]
|
|
||||||
redoTargetStorage.value = item.dest_storage
|
// 停止AI整理进度
|
||||||
redoDialog.value = true
|
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.redo'),
|
||||||
title: t('transferHistory.actions.delete'),
|
value: 1,
|
||||||
value: 2,
|
props: {
|
||||||
props: {
|
prependIcon: 'mdi-redo-variant',
|
||||||
prependIcon: 'mdi-trash-can-outline',
|
click: () => {
|
||||||
color: 'error',
|
redoIds.value = [item.id]
|
||||||
click: (item: TransferHistory) => {
|
redoTargetStorage.value = item.dest_storage
|
||||||
removeHistory(item)
|
redoDialog.value = true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
])
|
title: t('transferHistory.actions.delete'),
|
||||||
|
value: 2,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-trash-can-outline',
|
||||||
|
color: 'error',
|
||||||
|
click: () => {
|
||||||
|
removeHistory(item)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// 添加url参数
|
// 添加url参数
|
||||||
function addUrlQuery(url: string, name: string, value: any) {
|
function addUrlQuery(url: string, name: string, value: any) {
|
||||||
@@ -515,6 +646,10 @@ onMounted(() => {
|
|||||||
loadStorages()
|
loadStorages()
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAiRedoProgress()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -642,10 +777,11 @@ onMounted(() => {
|
|||||||
<VMenu activator="parent" close-on-content-click>
|
<VMenu activator="parent" close-on-content-click>
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem
|
||||||
v-for="(menu, i) in dropdownItems"
|
v-for="(menu, i) in getDropdownItems(item)"
|
||||||
:key="i"
|
:key="i"
|
||||||
:base-color="menu.props.color"
|
:base-color="menu.props.color"
|
||||||
@click="menu.props.click(item)"
|
:disabled="menu.props.disabled"
|
||||||
|
@click="menu.props.click()"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon :icon="menu.props.prependIcon" />
|
<VIcon :icon="menu.props.prependIcon" />
|
||||||
@@ -728,10 +864,11 @@ onMounted(() => {
|
|||||||
<VMenu activator="parent" close-on-content-click>
|
<VMenu activator="parent" close-on-content-click>
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem
|
||||||
v-for="(menu, i) in dropdownItems"
|
v-for="(menu, i) in getDropdownItems(item)"
|
||||||
:key="i"
|
:key="i"
|
||||||
:base-color="menu.props.color"
|
:base-color="menu.props.color"
|
||||||
@click="menu.props.click(item)"
|
:disabled="menu.props.disabled"
|
||||||
|
@click="menu.props.click()"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon :icon="menu.props.prependIcon" />
|
<VIcon :icon="menu.props.prependIcon" />
|
||||||
@@ -813,6 +950,7 @@ onMounted(() => {
|
|||||||
</VBottomSheet>
|
</VBottomSheet>
|
||||||
<!-- 进度框 -->
|
<!-- 进度框 -->
|
||||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||||
|
<ProgressDialog v-if="aiRedoProgressDialog" v-model="aiRedoProgressDialog" :text="aiRedoProgressText" />
|
||||||
<!-- 文件整理弹窗 -->
|
<!-- 文件整理弹窗 -->
|
||||||
<ReorganizeDialog
|
<ReorganizeDialog
|
||||||
v-if="redoDialog"
|
v-if="redoDialog"
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ const SystemSettings = ref<any>({
|
|||||||
AI_AGENT_JOB_INTERVAL: 24,
|
AI_AGENT_JOB_INTERVAL: 24,
|
||||||
LLM_PROVIDER: 'deepseek',
|
LLM_PROVIDER: 'deepseek',
|
||||||
LLM_MODEL: 'deepseek-chat',
|
LLM_MODEL: 'deepseek-chat',
|
||||||
|
LLM_SUPPORT_IMAGE_INPUT: false,
|
||||||
LLM_API_KEY: null,
|
LLM_API_KEY: null,
|
||||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||||
AI_AGENT_RETRY_TRANSFER: false,
|
AI_AGENT_RETRY_TRANSFER: false,
|
||||||
AI_RECOMMEND_ENABLED: false,
|
AI_RECOMMEND_ENABLED: false,
|
||||||
AI_RECOMMEND_USER_PREFERENCE: null,
|
AI_RECOMMEND_USER_PREFERENCE: null,
|
||||||
AI_RECOMMEND_MAX_ITEMS: 50,
|
AI_RECOMMEND_MAX_ITEMS: 50,
|
||||||
@@ -794,6 +795,14 @@ onDeactivated(() => {
|
|||||||
prepend-inner-icon="mdi-timer-outline"
|
prepend-inner-icon="mdi-timer-outline"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</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">
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="SystemSettings.Basic.AI_AGENT_RETRY_TRANSFER"
|
v-model="SystemSettings.Basic.AI_AGENT_RETRY_TRANSFER"
|
||||||
|
|||||||
Reference in New Issue
Block a user