Compare commits

..

13 Commits

Author SHA1 Message Date
jxxghp
b40fc4bd30 更新 package.json 2026-04-16 10:42:02 +08:00
jxxghp
a225ba6075 feat: implement responsive filter panel with collapsible search for mobile layout 2026-04-15 17:59:35 +08:00
jxxghp
303fe39c01 更新 package.json 2026-04-15 17:17:09 +08:00
jxxghp
d343cbcf71 Add AI redo progress viewer 2026-04-15 17:10:18 +08:00
jxxghp
0eef8c5174 Optimize site resource dialog layout 2026-04-15 14:18:02 +08:00
jxxghp
46fe257585 feat: add LLM image input support toggle to system settings 2026-04-15 08:46:51 +08:00
jxxghp
f69a57863e feat: add AI-powered reorganization option to transfer history records 2026-04-14 15:39:15 +08:00
jxxghp
8876aadcfa 更新 package.json 2026-04-13 18:42:07 +08:00
jxxghp
485e9691a0 Merge pull request #460 from InfinityPacer/codex/fix/dev-version-check-skip 2026-04-13 06:55:33 +08:00
InfinityPacer
a0e7283ae6 fix(version): skip mismatch toast in dev and harden sw fallback 2026-04-12 23:38:56 +08:00
jxxghp
b44c0647f1 更新 package.json 2026-04-12 10:24:07 +08:00
jxxghp
7e60ab9064 Merge pull request #459 from PKC278/v2 2026-04-12 10:23:49 +08:00
PKC278
f05c1f42b5 fix: 修复推荐和探索页面电视剧已入库标识不显示的问题 2026-04-12 09:52:18 +08:00
10 changed files with 763 additions and 138 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.9.28",
"version": "2.10.0",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -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,

View File

@@ -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&amp;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&amp;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>

View File

@@ -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',
},

View File

@@ -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: '删除',
},

View File

@@ -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: '刪除',
},

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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"

View File

@@ -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"