feat(subscribe): “我的订阅”中“文件统计弹窗”支持媒体服务器入库展示 (#531)

This commit is contained in:
kuke2733
2026-07-16 17:39:33 +08:00
committed by GitHub
parent 8a069db55e
commit c5da08e388
2 changed files with 166 additions and 26 deletions
+6
View File
@@ -1467,6 +1467,12 @@ export interface SubscribeLibraryFileInfo {
storage?: string storage?: string
// 文件路径 // 文件路径
file_path?: string file_path?: string
// 媒体服务器名称
server?: string
// 媒体服务器类型:emby、jellyfin、plex 等
server_type?: string
// 媒体服务器条目 ID
itemid?: string
} }
// 订阅集详情 // 订阅集详情
+160 -26
View File
@@ -110,6 +110,41 @@ function resolveEpisodeStatus(download: SubscribeDownloadFileInfo[], library: Su
return 'missing' return 'missing'
} }
/**
* 入库条目的驱动器标签:服务器名或 local。
*/
function resolveLibraryStorageLabel(file: SubscribeLibraryFileInfo) {
if (file.server) return file.server
return file.storage || 'local'
}
/**
* 判断路径是否为可打开的详情链接。
*/
function isDetailUrl(path?: string) {
return !!path && /^https?:\/\//i.test(path)
}
/**
* 入库条目路径展示文案。
*/
function resolveLibraryPathText(file: SubscribeLibraryFileInfo) {
if (file.file_path) return file.file_path
return t('dialog.subscribeFiles.noPath')
}
/**
* 文件列表项稳定 key(媒体服务器条目可能无路径)。
*/
function resolveFileKey(tab: SubscribeFileTab, file: SubscribeDownloadFileInfo | SubscribeLibraryFileInfo, index: number, episodeNumber?: number) {
const prefix = episodeNumber == null ? tab : `${episodeNumber}-${tab}`
if (tab === 'library') {
const libraryFile = file as SubscribeLibraryFileInfo
return `${prefix}-${libraryFile.file_path || libraryFile.itemid || libraryFile.server || libraryFile.server_type || index}`
}
return `${prefix}-${(file as SubscribeDownloadFileInfo).file_path || index}`
}
/** /**
* 返回集状态对应的本地化展示文案。 * 返回集状态对应的本地化展示文案。
*/ */
@@ -183,9 +218,23 @@ function calcPercent(value: number, total: number) {
/** /**
* 从种子标题或文件路径中提取分辨率标签。 * 从种子标题或文件路径中提取分辨率标签。
* 媒体服务器详情 URL 不参与解析。
*/ */
function resolveResolutionLabel(file?: SubscribeDownloadFileInfo | SubscribeLibraryFileInfo) { function resolveResolutionLabel(file?: SubscribeDownloadFileInfo | SubscribeLibraryFileInfo) {
const text = 'torrent_title' in (file || {}) ? `${(file as SubscribeDownloadFileInfo).torrent_title || ''} ${file?.file_path || ''}` : file?.file_path || '' if (!file) return undefined
const path = file.file_path || ''
if (isDetailUrl(path)) {
if ('torrent_title' in file) {
const matched = ((file as SubscribeDownloadFileInfo).torrent_title || '').match(/(?:2160|1080|720|480)p|4k|8k/i)
return matched?.[0]?.toUpperCase()
}
return undefined
}
const text = 'torrent_title' in file
? `${(file as SubscribeDownloadFileInfo).torrent_title || ''} ${path}`
: path
const matched = text.match(/(?:2160|1080|720|480)p|4k|8k/i) const matched = text.match(/(?:2160|1080|720|480)p|4k|8k/i)
return matched?.[0]?.toUpperCase() return matched?.[0]?.toUpperCase()
} }
@@ -336,7 +385,6 @@ onBeforeMount(() => {
<template> <template>
<VDialog <VDialog
scrollable
max-width="74rem" max-width="74rem"
:fullscreen="!display.mdAndUp.value" :fullscreen="!display.mdAndUp.value"
content-class="subscribe-files-overlay" content-class="subscribe-files-overlay"
@@ -495,7 +543,7 @@ onBeforeMount(() => {
<template v-if="selectedFiles.length"> <template v-if="selectedFiles.length">
<article <article
v-for="(file, index) in selectedFiles" v-for="(file, index) in selectedFiles"
:key="`${activeTab}-${file.file_path || index}`" :key="resolveFileKey(activeTab, file, index)"
class="subscribe-files-file-card" class="subscribe-files-file-card"
> >
<div class="subscribe-files-file-card__media"> <div class="subscribe-files-file-card__media">
@@ -519,12 +567,12 @@ onBeforeMount(() => {
{{ (file as SubscribeDownloadFileInfo).site_name }} {{ (file as SubscribeDownloadFileInfo).site_name }}
</VChip> </VChip>
<VChip <VChip
v-if="activeTab === 'library' && (file as SubscribeLibraryFileInfo).storage" v-if="activeTab === 'library' && resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo)"
color="success" color="success"
variant="tonal" variant="tonal"
size="x-small" size="x-small"
> >
{{ (file as SubscribeLibraryFileInfo).storage }} {{ resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo) }}
</VChip> </VChip>
<VChip <VChip
:color="activeTab === 'download' ? 'info' : 'success'" :color="activeTab === 'download' ? 'info' : 'success'"
@@ -547,10 +595,25 @@ onBeforeMount(() => {
</div> </div>
<div class="subscribe-files-path-block"> <div class="subscribe-files-path-block">
<div class="subscribe-files-path-block__label"> <div class="subscribe-files-path-block__label">
<VIcon icon="mdi-folder-outline" size="16" /> <VIcon :icon="activeTab === 'library' && isDetailUrl(file.file_path) ? 'mdi-open-in-new' : 'mdi-folder-outline'" size="16" />
{{ t('dialog.subscribeFiles.filePath') }} {{ t('dialog.subscribeFiles.filePath') }}
</div> </div>
<code>{{ file.file_path || t('dialog.subscribeFiles.noPath') }}</code> <a
v-if="activeTab === 'library' && isDetailUrl(file.file_path)"
class="subscribe-files-path-link"
:href="file.file_path"
target="_blank"
rel="noopener noreferrer"
>
{{ file.file_path }}
</a>
<code v-else>
{{
activeTab === 'library'
? resolveLibraryPathText(file as SubscribeLibraryFileInfo)
: (file.file_path || t('dialog.subscribeFiles.noPath'))
}}
</code>
<VBtn <VBtn
icon="mdi-content-copy" icon="mdi-content-copy"
variant="tonal" variant="tonal"
@@ -593,7 +656,7 @@ onBeforeMount(() => {
<div v-if="episode.activeFiles.length" class="subscribe-files-mobile-card__files"> <div v-if="episode.activeFiles.length" class="subscribe-files-mobile-card__files">
<div <div
v-for="(file, index) in episode.activeFiles" v-for="(file, index) in episode.activeFiles"
:key="`${episode.episodeNumber}-${activeTab}-${file.file_path || index}`" :key="resolveFileKey(activeTab, file, index, episode.episodeNumber)"
class="subscribe-files-mobile-file" class="subscribe-files-mobile-file"
> >
<div class="subscribe-files-mobile-file__chips"> <div class="subscribe-files-mobile-file__chips">
@@ -609,19 +672,34 @@ onBeforeMount(() => {
{{ (file as SubscribeDownloadFileInfo).site_name }} {{ (file as SubscribeDownloadFileInfo).site_name }}
</VChip> </VChip>
<VChip <VChip
v-if="activeTab === 'library' && (file as SubscribeLibraryFileInfo).storage" v-if="activeTab === 'library' && resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo)"
color="success" color="success"
variant="tonal" variant="tonal"
size="x-small" size="x-small"
> >
{{ (file as SubscribeLibraryFileInfo).storage }} {{ resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo) }}
</VChip> </VChip>
</div> </div>
<div v-if="activeTab === 'download'" class="subscribe-files-mobile-file__title"> <div v-if="activeTab === 'download'" class="subscribe-files-mobile-file__title">
{{ (file as SubscribeDownloadFileInfo).torrent_title || t('dialog.subscribeFiles.unknownTorrent') }} {{ (file as SubscribeDownloadFileInfo).torrent_title || t('dialog.subscribeFiles.unknownTorrent') }}
</div> </div>
<div class="subscribe-files-path-block subscribe-files-path-block--mobile"> <div class="subscribe-files-path-block subscribe-files-path-block--mobile">
<code>{{ file.file_path || t('dialog.subscribeFiles.noPath') }}</code> <a
v-if="activeTab === 'library' && isDetailUrl(file.file_path)"
class="subscribe-files-path-link"
:href="file.file_path"
target="_blank"
rel="noopener noreferrer"
>
{{ file.file_path }}
</a>
<code v-else>
{{
activeTab === 'library'
? resolveLibraryPathText(file as SubscribeLibraryFileInfo)
: (file.file_path || t('dialog.subscribeFiles.noPath'))
}}
</code>
<VBtn <VBtn
icon="mdi-content-copy" icon="mdi-content-copy"
variant="tonal" variant="tonal"
@@ -652,9 +730,46 @@ onBeforeMount(() => {
</VDialog> </VDialog>
</template> </template>
<style lang="scss">
.subscribe-files-overlay {
display: flex !important;
flex-direction: column !important;
inline-size: 100% !important;
max-inline-size: 74rem !important;
overflow: hidden !important;
}
.subscribe-files-overlay:not(.v-dialog--fullscreen) {
block-size: 80vh !important;
max-block-size: 80vh !important;
}
.subscribe-files-overlay.v-dialog--fullscreen {
block-size: 100% !important;
max-block-size: 100% !important;
max-inline-size: 100% !important;
}
.subscribe-files-overlay > .v-card {
display: flex;
flex: 1 1 0;
flex-direction: column;
inline-size: 100%;
block-size: 100%;
min-block-size: 0;
overflow: hidden;
}
</style>
<style lang="scss" scoped> <style lang="scss" scoped>
.subscribe-files-dialog { .subscribe-files-dialog {
display: flex;
overflow: hidden; overflow: hidden;
flex: 1 1 auto;
flex-direction: column;
inline-size: 100%;
block-size: 100%;
min-block-size: 0;
border: 1px solid rgba(var(--v-theme-on-surface), var(--sfd-border-opacity)); border: 1px solid rgba(var(--v-theme-on-surface), var(--sfd-border-opacity));
backdrop-filter: blur(var(--sfd-blur)) saturate(1.18); backdrop-filter: blur(var(--sfd-blur)) saturate(1.18);
background: background:
@@ -686,20 +801,26 @@ onBeforeMount(() => {
} }
.subscribe-files-dialog__body { .subscribe-files-dialog__body {
display: flex;
overflow: hidden; overflow: hidden;
flex: 1 1 0;
flex-direction: column;
min-block-size: 0;
padding: 0 !important; padding: 0 !important;
} }
.subscribe-files-shell { .subscribe-files-shell {
display: flex; display: flex;
overflow: hidden;
flex: 1 1 0;
flex-direction: column; flex-direction: column;
min-block-size: min(86vh, 56rem); min-block-size: 0;
} }
.subscribe-files-hero { .subscribe-files-hero {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
min-block-size: 21rem; flex: 0 0 auto;
background: background:
linear-gradient( linear-gradient(
90deg, 90deg,
@@ -848,16 +969,20 @@ onBeforeMount(() => {
.subscribe-files-content { .subscribe-files-content {
display: grid; display: grid;
flex: 1 1 auto; overflow: hidden;
flex: 1 1 0;
gap: 1rem; gap: 1rem;
grid-template-columns: 19rem minmax(0, 1fr); grid-template-columns: 19rem minmax(0, 1fr);
min-block-size: 0; grid-template-rows: minmax(0, 1fr);
min-block-size: 12rem;
padding: 0 1.25rem 1.25rem; padding: 0 1.25rem 1.25rem;
} }
.subscribe-files-episode-rail, .subscribe-files-episode-rail,
.subscribe-files-main { .subscribe-files-main {
overflow: hidden; overflow: hidden;
block-size: 100%;
min-block-size: 0;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1); border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: var(--app-surface-radius); border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity)); background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
@@ -1108,7 +1233,8 @@ onBeforeMount(() => {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.subscribe-files-path-block code { .subscribe-files-path-block code,
.subscribe-files-path-link {
overflow: hidden; overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.88); color: rgba(var(--v-theme-on-surface), 0.88);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
@@ -1117,6 +1243,16 @@ onBeforeMount(() => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.subscribe-files-path-link {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
text-underline-offset: 0.15em;
}
.subscribe-files-path-link:hover {
opacity: 0.88;
}
.subscribe-files-empty { .subscribe-files-empty {
display: grid; display: grid;
align-content: center; align-content: center;
@@ -1139,6 +1275,7 @@ onBeforeMount(() => {
flex: 1 1 auto; flex: 1 1 auto;
flex-direction: column; flex-direction: column;
gap: 0.85rem; gap: 0.85rem;
min-block-size: 0;
padding: 1rem; padding: 1rem;
} }
@@ -1241,14 +1378,6 @@ onBeforeMount(() => {
border-radius: 0 !important; border-radius: 0 !important;
} }
.subscribe-files-shell {
min-block-size: 100dvh;
}
.subscribe-files-hero {
min-block-size: auto;
}
.subscribe-files-hero__content { .subscribe-files-hero__content {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@@ -1303,14 +1432,19 @@ onBeforeMount(() => {
.subscribe-files-content { .subscribe-files-content {
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 0;
flex-direction: column; flex-direction: column;
min-block-size: 0; min-block-size: 0;
padding: 0; padding: 0;
} }
.subscribe-files-main { .subscribe-files-main {
flex: 1 1 auto; display: flex;
overflow: hidden;
flex: 1 1 0;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;