Compare commits

..

19 Commits

Author SHA1 Message Date
jxxghp
98769a313f Refine dashboard profile persistence and radius tokens 2026-06-28 16:28:25 +08:00
jxxghp
1c28d98d70 Update expired translation labels to aired 2026-06-28 15:33:42 +08:00
jxxghp
7a430b095a Limit mobile calendar history and simplify episode tags 2026-06-28 15:32:00 +08:00
jxxghp
864af36b1c Use theme primary border for selected subscribe card 2026-06-28 13:59:25 +08:00
jxxghp
e3fa0b9dae Add batch selection support for subscriptions and history 2026-06-28 13:47:13 +08:00
jxxghp
ed28832484 Fix selected subscribe card border 2026-06-28 13:13:43 +08:00
jxxghp
e692a91113 Refine mobile cache and scheduler layouts 2026-06-28 12:46:47 +08:00
jxxghp
7574719e04 Refactor frontend dialogs and add missing annotations 2026-06-28 12:08:24 +08:00
jxxghp
ca800f7ae7 Refine transfer history mobile list and search styling 2026-06-28 11:09:47 +08:00
jxxghp
7d0550825b fix: adjust header margins for consistency in resource and site card list views 2026-06-28 10:56:33 +08:00
jxxghp
59abda63e3 Format season episodes in calendar tags 2026-06-28 09:58:11 +08:00
jxxghp
0776bc9fa0 fix: adjust dashboard item rows and improve storage display styling for better layout 2026-06-28 09:44:58 +08:00
jxxghp
8c78f9ca90 feat: update dashboard components to use 'dashboard-grid-fill' class for improved layout handling 2026-06-28 09:35:50 +08:00
jxxghp
1889d5d1e3 feat: enhance dashboard layout with fixed height components and improved styling for media cards 2026-06-28 09:05:31 +08:00
jxxghp
165247b263 fix: update storage used percentage display to use div elements for better layout 2026-06-28 08:30:24 +08:00
jxxghp
9a2384bf02 Refine subscribe calendar mobile filters and bump version 2026-06-27 22:17:47 +08:00
jxxghp
71c4f587d3 Enable hover open for horizontal nav submenus 2026-06-27 21:44:48 +08:00
jxxghp
8edbfa0b0e Adjust assistant entry anchor for mobile FAB 2026-06-27 21:39:05 +08:00
jxxghp
ee187a1c75 refactor: remove unused episode subtitle search functionality 2026-06-27 18:21:11 +08:00
36 changed files with 3632 additions and 347 deletions

View File

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

View File

@@ -66,6 +66,43 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
// 格式化为Sxx
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
/**
* 格式化为 SxxExx 季集标识,多个连续集会合并为范围。
*/
export function formatSeasonEpisode(
season: number | string | null | undefined,
episodeNumbers: number[],
): string {
const seasonText = season === null || season === undefined || season === '' ? '' : formatSeason(String(season))
const normalizedNumbers = [...new Set(episodeNumbers)]
.map(number => Number(number))
.filter(number => Number.isFinite(number) && number > 0)
.sort((first, second) => first - second)
if (!normalizedNumbers.length) return seasonText
const formatEpisode = (number: number) => `E${String(number).padStart(2, '0')}`
const ranges: string[] = []
let start = normalizedNumbers[0]
let end = normalizedNumbers[0]
for (let index = 1; index < normalizedNumbers.length; index++) {
const currentNumber = normalizedNumbers[index]
if (currentNumber === end + 1) {
end = currentNumber
} else {
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
start = currentNumber
end = currentNumber
}
}
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
return `${seasonText}${ranges.join('、')}`
}
// 格式化为xx[TGMK]B
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
// 负数标记

View File

@@ -331,6 +331,9 @@ function getFabAnchorRect() {
const botRect = bot?.getBoundingClientRect()
const triggerRect = trigger?.getBoundingClientRect()
// 小屏下触发热区留白更明显,气泡按机器人可见图形定位会更贴近。
if (isMobileFabViewport() && botRect && botRect.width > 0 && botRect.height > 0) return botRect
if (triggerRect && triggerRect.width > 0 && triggerRect.height > 0) return triggerRect
if (botRect && botRect.width > 0 && botRect.height > 0) return botRect
@@ -587,7 +590,7 @@ function resetFabPosition() {
function handleWindowResize() {
updateFabPosition(getCurrentFabPosition())
if (fabDocked.value && isFabNearRightEdge()) {
if (fabDocked.value) {
fabPosition.value = {
...getCurrentFabPosition(),
x: getDockedFabX(),

View File

@@ -410,7 +410,7 @@ function handleCardClick() {
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
:class="{
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
'subscribe-card-shell--selected': props.batchMode && props.selected,
}"
>
<VCard
@@ -581,6 +581,23 @@ function handleCardClick() {
inline-size: 100%;
}
/**
* 订阅卡片外壳:选中态虚线框复用同一圆角,避免 outline 在圆角卡片外形成直角。
*/
.subscribe-card-shell {
border-radius: var(--app-surface-radius);
}
.subscribe-card-shell--selected::after {
position: absolute;
z-index: 5;
border: 2px solid rgb(var(--v-theme-primary));
border-radius: inherit;
content: '';
inset: 0;
pointer-events: none;
}
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { useDisplay } from 'vuetify'
import { useDisplay, useTheme } from 'vuetify'
// 显示器宽度
const display = useDisplay()
const theme = useTheme()
// 输入参数
const props = withDefaults(
@@ -41,6 +42,17 @@ const visible = computed({
})
const isFullscreen = computed(() => !display.mdAndUp.value)
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
const isSchedulerDialog = computed(() => props.cardClass.split(/\s+/).includes('scheduler-shortcut-dialog-card'))
// 透明主题下仅定时服务全屏弹窗取消外层 VCard 的背景和模糊,避免整屏磨砂遮住界面。
const cardClasses = computed(() => [
props.cardClass,
{
'scheduler-shortcut-dialog-card--transparent':
isFullscreen.value && isTransparentTheme.value && isSchedulerDialog.value,
},
])
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
const bodyClasses = computed(() => [
@@ -50,17 +62,17 @@ const bodyClasses = computed(() => [
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
},
])
</script>
<template>
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
<VCard :class="props.cardClass">
<VCard :class="cardClasses">
<VCardItem>
<VCardTitle>
<VIcon :icon="props.icon" class="me-2" />
{{ props.title }}
</VCardTitle>
<VCardSubtitle v-if="props.subtitle">{{ props.subtitle }}</VCardSubtitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
@@ -90,4 +102,34 @@ const bodyClasses = computed(() => [
.system-health-dialog-body--fullscreen {
block-size: auto;
}
@media (max-width: 959.98px) {
.scheduler-shortcut-dialog-card--transparent {
background: transparent !important;
background-color: transparent !important;
backdrop-filter: none !important;
}
.cache-shortcut-dialog-card {
display: flex;
overflow: hidden;
flex-direction: column;
background: rgb(var(--v-theme-surface));
}
html[data-theme='transparent'] .cache-shortcut-dialog-card,
.v-theme--transparent .cache-shortcut-dialog-card {
backdrop-filter: blur(var(--transparent-blur, 10px));
background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
}
.cache-shortcut-dialog-body {
display: flex;
overflow: hidden !important;
flex: 1 1 auto;
inline-size: 100%;
min-block-size: 0;
padding: 0 !important;
}
}
</style>

View File

@@ -742,23 +742,28 @@ async function handleResetSettings() {
inline-size: 100%;
min-inline-size: 0;
--theme-customizer-preview-radius: var(--app-vuetify-rounded);
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded);
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded-lg);
}
.theme-customizer-radius-scene--none {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-0);
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded-sm);
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded-sm);
}
.theme-customizer-radius-scene--small {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-sm);
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded);
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded);
}
.theme-customizer-radius-scene--large {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-lg);
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded-lg);
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded-lg);
}
.theme-customizer-radius-scene--extra {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-xl);
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded-xl);
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded-xl);
}
.theme-customizer-radius-scene__card {
@@ -766,7 +771,7 @@ async function handleResetSettings() {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: var(--theme-customizer-preview-radius);
border-radius: var(--theme-customizer-preview-surface-radius);
background: rgb(var(--v-theme-surface));
gap: 8px;
inset: 16px;
@@ -781,14 +786,14 @@ async function handleResetSettings() {
}
.theme-customizer-radius-scene__badge {
border-radius: var(--theme-customizer-preview-radius);
border-radius: var(--theme-customizer-preview-control-radius);
block-size: 8px;
inline-size: 42%;
min-inline-size: 28px;
}
.theme-customizer-radius-scene__line {
border-radius: var(--theme-customizer-preview-radius);
border-radius: var(--theme-customizer-preview-control-radius);
block-size: 7px;
}

View File

@@ -31,6 +31,7 @@ export interface DynamicButtonMenuItem {
icon?: string
color?: string
permission?: UserPermissionKey
disabled?: boolean
action: () => void
}

View File

@@ -540,6 +540,9 @@ onMounted(async () => {
:model-value="openHorizontalNavGroup === group.title"
location="bottom start"
offset="8"
open-on-hover
:open-delay="0"
:close-delay="120"
:close-on-content-click="false"
@update:model-value="openHorizontalNavGroup = $event ? group.title : null"
>
@@ -569,7 +572,11 @@ onMounted(async () => {
:close-on-content-click="true"
>
<template #activator="{ props: subMenuProps }">
<VListItem v-bind="subMenuProps" :active="isHorizontalNavActive(item)">
<VListItem
v-bind="subMenuProps"
:active="isHorizontalNavActive(item)"
class="theme-horizontal-nav__submenu-activator"
>
<template #prepend>
<VIcon :icon="String(item.icon || '')" />
</template>
@@ -829,6 +836,10 @@ onMounted(async () => {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.theme-horizontal-nav__submenu-activator {
cursor: pointer;
}
.theme-horizontal-nav__actions {
display: flex;
flex: 0 0 auto;

View File

@@ -187,6 +187,7 @@ const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
'components.pluginMarketSetting.title': 'dialog.pluginMarketSetting.title',
}
// 解析动态按钮菜单项标题,兼容旧版直接传入 i18n key 的写法。
function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
if (item.titleKey) {
return t(item.titleKey, item.titleParams as any)
@@ -202,14 +203,16 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
}
// 处理页面注册的动态按钮主操作点击。
function handleDynamicButtonClick() {
if (!dynamicButton.value || !hasItemPermission(dynamicButton.value, userPermissions.value)) return
dynamicButton.value.action()
}
// 处理页面注册的动态按钮菜单项点击。
function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
if (!hasItemPermission(item, userPermissions.value)) return
if (item.disabled || !hasItemPermission(item, userPermissions.value)) return
item.action()
}
@@ -292,6 +295,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
v-for="(item, index) in visibleDynamicButtonMenuItems"
:key="item.titleKey || item.title || index"
:base-color="item.color"
:disabled="item.disabled"
@click="handleDynamicMenuItemClick(item)"
>
<template #prepend>

View File

@@ -90,6 +90,8 @@ const shortcuts: ShortcutItem[] = [
subtitle: t('shortcut.cache.subtitle'),
icon: 'mdi-database',
dialog: 'cache',
bodyClass: 'cache-shortcut-dialog-body',
cardClass: 'cache-shortcut-dialog-card',
component: CacheView,
maxWidth: '90rem',
titleText: t('shortcut.cache.subtitle'),
@@ -99,7 +101,8 @@ const shortcuts: ShortcutItem[] = [
subtitle: t('shortcut.scheduler.subtitle'),
icon: 'mdi-list-box',
dialog: 'scheduler',
bodyClass: 'pa-0',
bodyClass: 'scheduler-shortcut-dialog-body pa-0',
cardClass: 'scheduler-shortcut-dialog-card',
component: AccountSettingService,
maxWidth: '60rem',
titleText: t('shortcut.scheduler.subtitle'),
@@ -192,23 +195,35 @@ onMounted(() => {
<div class="grid grid-cols-2 gap-3">
<!-- 循环渲染快捷方式 -->
<div v-for="(item, index) in visibleShortcuts" :key="index">
<VCard
flat
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
hover
@click="openShortcutDialog(item)"
>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
</VAvatar>
<div>
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
<VHover v-slot="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="shortcut-card-hover-area h-full">
<VCard
flat
:ripple="false"
class="app-hover-lift-card pa-2 d-flex align-center cursor-pointer border h-full w-100"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
@click="openShortcutDialog(item)"
>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
</VAvatar>
<div>
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
</div>
</VCard>
</div>
</VCard>
</VHover>
</div>
</div>
</div>
</VCard>
</VMenu>
</template>
<style scoped>
.shortcut-card-hover-area {
inline-size: 100%;
}
</style>

View File

@@ -1105,6 +1105,9 @@ export default {
},
selectedCount: 'Selected {count}/{total} items',
noSelectedItems: 'Please select subscriptions to operate',
batchSelectAll: 'Select All Subscriptions',
batchDeselectAll: 'Deselect All Subscriptions',
exitBatchMode: 'Exit Batch Mode',
batchEnable: 'Batch Enable',
batchPause: 'Batch Pause',
batchDelete: 'Batch Delete',
@@ -1216,9 +1219,27 @@ export default {
currentEpisodeInLibrary: 'Current in library',
currentEpisodePartiallyInLibrary: 'Partially in library',
currentEpisodeNotInLibrary: 'Current not in library',
libraryStateComplete: 'In library',
libraryStatePartial: 'Partial',
libraryStateNone: 'Missing',
compactLibraryProgress: '{state} ({completed}/{total})',
libraryUpdatedAt: 'Updated {time}',
libraryUpdatedAtShort: '{time}',
expandDayEvents: 'Show {count} more items for this day',
mobileFilterTitle: 'Subscription Calendar',
itemCount: '{count} items',
hideExpired: 'Hide expired',
showExpired: 'Show expired',
today: 'Today',
todayUpdated: 'Updated today',
upcoming: 'Upcoming',
expired: 'Aired',
episodeCount: '{count} episodes',
mobileEpisodeTitle: 'Episode {number}',
runtimeMinutes: '{minutes} min',
movie: 'Movie',
imageLoadFailed: 'Failed',
noMatchingEvents: 'No calendar items match the current filters',
},
storage: {
name: 'Name',
@@ -2196,6 +2217,8 @@ export default {
stopped: 'Stopped',
waiting: 'Waiting',
executeSuccess: 'Scheduled job execution request submitted successfully!',
mobileWaitingAfter: 'In {time}',
mobileNoNextRun: 'No schedule',
},
subscribe: {
basicSettings: 'Basic Settings',
@@ -2247,6 +2270,7 @@ export default {
filterByTitle: 'Filter by Title',
filterBySite: 'Filter by Site',
selectSite: 'Select Site',
loadingMore: 'Loading...',
refresh: 'Refresh Cache',
deleteSelected: 'Delete Selected',
clearAll: 'Clear All Cache',
@@ -3269,6 +3293,7 @@ export default {
loading: 'Loading...',
pageSize: 'Items Per Page',
pageInfo: '{begin} - {end} / {total}',
selectedCount: 'Selected {count}/{total} items',
aiRedoDisabled: 'Please enable the AI assistant in system settings first',
aiRedoQueued: 'Assistant organize task submitted: {title}',
aiRedoFailed: 'Failed to submit assistant organize task',
@@ -3276,6 +3301,11 @@ export default {
aiRedo: 'Assistant Organize',
aiRedoPending: 'Assistant Organizing...',
batchAiRedo: 'Assistant Batch Organize',
batchSelect: 'Batch Select',
exitBatchSelect: 'Exit Batch Select',
exitBatchMode: 'Exit Batch Mode',
selectAll: 'Select All',
deselectAll: 'Deselect All',
redo: 'Reorganize',
delete: 'Delete',
batchRedo: 'Batch Reorganize',

View File

@@ -1100,6 +1100,9 @@ export default {
},
selectedCount: '已选择 {count}/{total} 项',
noSelectedItems: '请先选择要操作的订阅',
batchSelectAll: '选择全部订阅',
batchDeselectAll: '取消全选订阅',
exitBatchMode: '退出批量操作',
batchEnable: '批量启用',
batchPause: '批量暂停',
batchDelete: '批量删除',
@@ -1211,9 +1214,27 @@ export default {
currentEpisodeInLibrary: '本集已入库',
currentEpisodePartiallyInLibrary: '本集部分入库',
currentEpisodeNotInLibrary: '本集未入库',
libraryStateComplete: '已入库',
libraryStatePartial: '部分入库',
libraryStateNone: '未入库',
compactLibraryProgress: '{state} ({completed}/{total})',
libraryUpdatedAt: '最近更新 {time}',
libraryUpdatedAtShort: '{time}',
expandDayEvents: '展开当天剩余 {count} 个条目',
mobileFilterTitle: '订阅日历',
itemCount: '{count} 项',
hideExpired: '隐藏过期',
showExpired: '显示过期',
today: '今天',
todayUpdated: '今天更新',
upcoming: '即将播出',
expired: '已播出',
episodeCount: '{count} 集',
mobileEpisodeTitle: '第 {number} 集',
runtimeMinutes: '{minutes} 分钟',
movie: '电影',
imageLoadFailed: '加载失败',
noMatchingEvents: '暂无符合筛选条件的日历内容',
},
storage: {
name: '名称',
@@ -2154,6 +2175,8 @@ export default {
stopped: '已停止',
waiting: '等待',
executeSuccess: '定时作业执行请求提交成功!',
mobileWaitingAfter: '{time}之后',
mobileNoNextRun: '暂无排期',
},
subscribe: {
basicSettings: '基础设置',
@@ -2202,6 +2225,7 @@ export default {
filterByTitle: '按标题筛选',
filterBySite: '按站点筛选',
selectSite: '选择站点',
loadingMore: '加载中...',
refresh: '刷新缓存',
deleteSelected: '删除选中',
clearAll: '清空缓存',
@@ -3212,6 +3236,7 @@ export default {
loading: '加载中...',
pageSize: '每页条数',
pageInfo: '{begin} - {end} / {total}',
selectedCount: '已选择 {count}/{total} 项',
aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',
aiRedoQueued: '已提交智能助手整理任务:{title}',
aiRedoFailed: '提交智能助手整理任务失败',
@@ -3219,6 +3244,11 @@ export default {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
batchSelect: '批量选择',
exitBatchSelect: '退出批量选择',
exitBatchMode: '退出批量操作',
selectAll: '全部选中',
deselectAll: '取消全选',
redo: '重新整理',
delete: '删除',
batchRedo: '批量重新整理',

View File

@@ -1100,6 +1100,9 @@ export default {
},
selectedCount: '已選擇 {count}/{total} 項',
noSelectedItems: '請先選擇要操作的訂閱',
batchSelectAll: '選擇全部訂閱',
batchDeselectAll: '取消全選訂閱',
exitBatchMode: '退出批量操作',
batchEnable: '批量啟用',
batchPause: '批量暫停',
batchDelete: '批量刪除',
@@ -1211,9 +1214,27 @@ export default {
currentEpisodeInLibrary: '本集已入庫',
currentEpisodePartiallyInLibrary: '本集部分入庫',
currentEpisodeNotInLibrary: '本集未入庫',
libraryStateComplete: '已入庫',
libraryStatePartial: '部分入庫',
libraryStateNone: '未入庫',
compactLibraryProgress: '{state} ({completed}/{total})',
libraryUpdatedAt: '最近更新 {time}',
libraryUpdatedAtShort: '{time}',
expandDayEvents: '展開當天剩餘 {count} 個條目',
mobileFilterTitle: '訂閱日曆',
itemCount: '{count} 項',
hideExpired: '隱藏過期',
showExpired: '顯示過期',
today: '今天',
todayUpdated: '今天更新',
upcoming: '即將播出',
expired: '已播出',
episodeCount: '{count} 集',
mobileEpisodeTitle: '第 {number} 集',
runtimeMinutes: '{minutes} 分鐘',
movie: '電影',
imageLoadFailed: '載入失敗',
noMatchingEvents: '暫無符合篩選條件的日曆內容',
},
storage: {
name: '名稱',
@@ -2155,6 +2176,8 @@ export default {
stopped: '已停止',
waiting: '等待',
executeSuccess: '定時作業執行請求提交成功!',
mobileWaitingAfter: '{time}之後',
mobileNoNextRun: '暫無排程',
},
subscribe: {
basicSettings: '基礎設置',
@@ -2203,6 +2226,7 @@ export default {
filterByTitle: '按標題篩選',
filterBySite: '按站點篩選',
selectSite: '選擇站點',
loadingMore: '加載中...',
refresh: '刷新緩存',
deleteSelected: '刪除選中',
clearAll: '清空緩存',
@@ -3211,6 +3235,7 @@ export default {
loading: '加載中...',
pageSize: '每頁條數',
pageInfo: '{begin} - {end} / {total}',
selectedCount: '已選擇 {count}/{total} 項',
aiRedoDisabled: '請先在系統設置中啟用 AI 智能助手',
aiRedoQueued: '已提交智能助手整理任務:{title}',
aiRedoFailed: '提交智能助手整理任務失敗',
@@ -3218,6 +3243,11 @@ export default {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
batchSelect: '批量選擇',
exitBatchSelect: '退出批量選擇',
exitBatchMode: '退出批量操作',
selectAll: '全部選中',
deselectAll: '取消全選',
redo: '重新整理',
delete: '刪除',
batchRedo: '批量重新整理',

View File

@@ -63,6 +63,12 @@ interface DashboardGridLayoutItem {
h?: number
}
// 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。
interface DashboardProfileConfig {
enabled?: DashboardEnableConfig
items: DashboardGridLayoutConfig
}
interface DashboardGridItem {
config: DashboardItem
id: string
@@ -91,7 +97,7 @@ const loadedDashboardGridItemIds = ref<Set<string>>(new Set())
const isSyncingDashboardGrid = ref(false)
// 仪表板本地布局覆盖配置
const dashboardGridLayout = ref<Record<string, DashboardGridLayoutItem>>({})
const dashboardGridLayout = ref<DashboardGridLayoutConfig>({})
// 当前仪表板布局档位,按 GridStack 响应式列数拆分跨端配置。
const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
@@ -99,6 +105,10 @@ const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
const isDashboardGridLayoutResetPending = ref(false)
// 旧版跨设备显示项配置,仅用于首次迁移到按设备拆分的仪表盘配置。
let legacyDashboardEnableConfig: DashboardEnableConfig | undefined
let isLegacyDashboardEnableConfigLoaded = false
const dashboardGridResizeStartHeights = new Map<string, number | undefined>()
const dashboardGridPendingContentResize = new Set<GridItemHTMLElement>()
const dashboardGridObservedContentHeights = new Map<string, number>()
@@ -116,22 +126,10 @@ const isDashboardGridResizing = ref(false)
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
// 仪表板启用配置
const enableConfig = ref<{ [key: string]: boolean }>({
mediaStatistic: true,
scheduler: false,
speed: false,
storage: true,
weeklyOverview: false,
cpu: false,
memory: false,
network: false,
library: true,
playing: true,
latest: true,
})
const enableConfig = ref<DashboardEnableConfig>(getDefaultDashboardEnableConfig())
// 仪表板顺序配置
const orderConfig = ref<{ id: string; key: string }[]>([])
const orderConfig = ref<DashboardOrderConfig>([])
// 仪表板配置
const dashboardConfigs = ref<DashboardItem[]>([
@@ -141,7 +139,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 5,
rows: 9,
elements: [],
},
{
@@ -150,7 +148,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 8 },
rows: 5,
rows: 11,
elements: [],
},
{
@@ -159,7 +157,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
rows: 23,
elements: [],
},
{
@@ -168,7 +166,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
rows: 23,
elements: [],
},
{
@@ -177,7 +175,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
rows: 23,
elements: [],
},
{
@@ -186,7 +184,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
rows: 17,
elements: [],
},
{
@@ -195,7 +193,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
rows: 17,
elements: [],
},
{
@@ -204,7 +202,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
rows: 17,
elements: [],
},
{
@@ -301,6 +299,7 @@ function scheduleDashboardReveal() {
return
}
syncDashboardFillContentState()
resizeAutoDashboardItemsToContent()
if (typeof window === 'undefined') {
@@ -320,6 +319,7 @@ function markDashboardGridItemLoaded(id: string) {
loadedDashboardGridItemIds.value = new Set([...loadedDashboardGridItemIds.value, id])
scheduleDashboardReveal()
void nextTick(syncDashboardFillContentState)
}
// 将未知数值限制到 GridStack 可接受的整数区间。
@@ -330,6 +330,23 @@ function clampGridNumber(value: unknown, min: number, max: number, fallback: num
return Math.min(max, Math.max(min, Math.round(numericValue)))
}
// 获取仪表盘内置组件的默认显示配置。
function getDefaultDashboardEnableConfig(): DashboardEnableConfig {
return {
mediaStatistic: true,
scheduler: false,
speed: false,
storage: true,
weeklyOverview: false,
cpu: false,
memory: false,
network: false,
library: true,
playing: true,
latest: true,
}
}
// 校验并归一化仪表板显示配置,避免异常用户配置影响页面渲染。
function normalizeDashboardEnableConfig(value: unknown): DashboardEnableConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
@@ -360,12 +377,17 @@ function normalizeDashboardOrderConfig(value: unknown): DashboardOrderConfig | u
}, [])
}
// 校验并归一化仪表板 Grid 布局覆盖配置,兼容旧版裸布局和新版服务端包装结构。
// 校验并归一化仪表板 Grid 布局覆盖配置,兼容旧版裸布局和新版 profile 包装结构。
function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const configValue = value as { items?: unknown }
const layoutValue = configValue.items && typeof configValue.items === 'object' ? configValue.items : value
const hasWrappedLayout = Object.prototype.hasOwnProperty.call(configValue, 'items')
const layoutValue = hasWrappedLayout ? configValue.items : value
if (!layoutValue || typeof layoutValue !== 'object' || Array.isArray(layoutValue)) {
return hasWrappedLayout ? {} : undefined
}
const normalizedLayout: DashboardGridLayoutConfig = {}
Object.entries(layoutValue).forEach(([id, layout]) => {
@@ -389,14 +411,53 @@ function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig
return normalizedLayout
}
// 构造服务端 Grid 布局配置,避免空布局被后端按空值删除后又被其他浏览器旧缓存回填
function buildRemoteDashboardGridLayout(layout: DashboardGridLayoutConfig) {
return { items: layout }
// 校验并归一化单个设备档位的仪表盘配置,兼容旧版只保存 Grid 布局的数据
function normalizeDashboardProfileConfig(value: unknown): DashboardProfileConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const configValue = value as { enabled?: unknown; items?: unknown }
const hasProfileField =
Object.prototype.hasOwnProperty.call(configValue, 'items') ||
Object.prototype.hasOwnProperty.call(configValue, 'enabled')
const items = normalizeDashboardGridLayout(hasProfileField ? { items: configValue.items ?? {} } : value)
if (items === undefined) return undefined
const enabled = normalizeDashboardEnableConfig(configValue.enabled)
const profileConfig: DashboardProfileConfig = { items }
if (enabled !== undefined) {
profileConfig.enabled = enabled
}
return profileConfig
}
// 构造设备档位仪表盘配置,让显示项和 Grid 布局始终作为一个整体持久化。
function buildDashboardProfileConfig(
layout: DashboardGridLayoutConfig = dashboardGridLayout.value,
enabled: DashboardEnableConfig = enableConfig.value,
): DashboardProfileConfig {
return {
enabled,
items: layout,
}
}
// 构造服务端设备档位配置,避免空布局被后端按空值删除后又被其他浏览器旧缓存回填。
function buildRemoteDashboardProfileConfig(config: DashboardProfileConfig) {
const remoteConfig: DashboardProfileConfig = { items: config.items }
if (config.enabled !== undefined) {
remoteConfig.enabled = config.enabled
}
return remoteConfig
}
// 根据当前视口判断仪表板布局档位,避免手机和桌面共用 Grid 坐标。
function resolveDashboardLayoutProfile(): DashboardLayoutProfile {
const width = display.width.value || (typeof window === 'undefined' ? DASHBOARD_GRID_DESKTOP_BREAKPOINT : window.innerWidth)
const width =
display.width.value || (typeof window === 'undefined' ? DASHBOARD_GRID_DESKTOP_BREAKPOINT : window.innerWidth)
if (width <= DASHBOARD_GRID_MOBILE_BREAKPOINT) return 'mobile'
if (width <= DASHBOARD_GRID_TABLET_BREAKPOINT) return 'tablet'
@@ -436,14 +497,41 @@ function getDashboardGridLayoutConfigKey(profile: DashboardLayoutProfile) {
return `${DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX}${profile === 'mobile' ? 'Mobile' : 'Tablet'}`
}
// 加载指定布局档位的 Grid 布局配置
async function loadDashboardGridLayoutConfig(profile: DashboardLayoutProfile) {
return await loadSharedDashboardConfig(
getDashboardGridLayoutConfigKey(profile),
getDashboardGridLayoutStorageKey(profile),
normalizeDashboardGridLayout,
buildRemoteDashboardGridLayout,
)
// 加载指定设备档位的仪表盘配置,远端旧布局缺少显示项时保留本地新版显示项
async function loadDashboardProfileConfig(profile: DashboardLayoutProfile) {
const configKey = getDashboardGridLayoutConfigKey(profile)
const storageKey = getDashboardGridLayoutStorageKey(profile)
const localConfig = readLocalDashboardConfig(storageKey, normalizeDashboardProfileConfig)
try {
const response = await api.get(`/user/config/${configKey}`)
const remoteConfig = normalizeDashboardProfileConfig(response?.data?.value)
if (remoteConfig !== undefined) {
const profileConfig: DashboardProfileConfig = { items: remoteConfig.items }
const enabled = remoteConfig.enabled ?? localConfig?.enabled
if (enabled !== undefined) {
profileConfig.enabled = enabled
}
saveLocalDashboardConfig(storageKey, profileConfig)
if (remoteConfig.enabled === undefined && localConfig?.enabled !== undefined) {
await saveUserDashboardConfig(configKey, buildRemoteDashboardProfileConfig(profileConfig))
}
return profileConfig
}
if (localConfig !== undefined) {
await saveUserDashboardConfig(configKey, buildRemoteDashboardProfileConfig(localConfig))
}
} catch (error) {
console.error(error)
}
return localConfig
}
// 从本地存储读取并归一化指定的仪表板配置。
@@ -470,6 +558,25 @@ async function saveUserDashboardConfig(configKey: string, value: unknown) {
await api.post(`/user/config/${configKey}`, value)
}
// 读取旧版全局显示项配置,用于设备档位配置还没有 enabled 字段时迁移。
async function loadLegacyDashboardEnableConfig() {
if (isLegacyDashboardEnableConfigLoaded) return legacyDashboardEnableConfig
const localConfig = readLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, normalizeDashboardEnableConfig)
try {
const response = await api.get(`/user/config/${DASHBOARD_ENABLE_CONFIG_KEY}`)
legacyDashboardEnableConfig = normalizeDashboardEnableConfig(response?.data?.value) ?? localConfig
} catch (error) {
console.error(error)
legacyDashboardEnableConfig = localConfig
}
isLegacyDashboardEnableConfigLoaded = true
return legacyDashboardEnableConfig
}
// 优先加载用户配置;服务端缺失时使用本地历史配置并回填到用户配置。
async function loadSharedDashboardConfig<T>(
configKey: string,
@@ -500,12 +607,20 @@ async function loadSharedDashboardConfig<T>(
}
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
function saveDashboardGridLayout(layout: Record<string, DashboardGridLayoutItem>) {
function saveDashboardProfileConfig(layout = dashboardGridLayout.value, enabled = enableConfig.value) {
const profile = dashboardLayoutProfile.value
saveLocalDashboardConfig(getDashboardGridLayoutStorageKey(profile), layout)
void saveUserDashboardConfig(getDashboardGridLayoutConfigKey(profile), buildRemoteDashboardGridLayout(layout)).catch(
error => console.error(error),
)
const profileConfig = buildDashboardProfileConfig(layout, enabled)
saveLocalDashboardConfig(getDashboardGridLayoutStorageKey(profile), profileConfig)
void saveUserDashboardConfig(
getDashboardGridLayoutConfigKey(profile),
buildRemoteDashboardProfileConfig(profileConfig),
).catch(error => console.error(error))
}
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
function saveDashboardGridLayout(layout: DashboardGridLayoutConfig) {
saveDashboardProfileConfig(layout)
}
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
@@ -582,6 +697,14 @@ function openDashboardSettings() {
)
}
// 同步已打开的仪表盘设置弹窗,避免设备档位切换后继续显示旧档位的开关副本。
function updateDashboardSettingsDialog() {
settingsDialogController?.updateProps({
enabled: enableConfig.value,
items: dashboardConfigs.value,
})
}
// 退出仪表板布局编辑模式;如果刚恢复默认布局,则跳过本次本地持久化。
function exitDashboardLayoutEditing() {
if (isDashboardGridLayoutResetPending.value) {
@@ -591,6 +714,11 @@ function exitDashboardLayoutEditing() {
}
isLayoutEditing.value = false
nextTick(() => {
syncDashboardFillContentState()
resizeAutoDashboardItemsToContent()
notifyDashboardContentResize()
})
}
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。
@@ -602,6 +730,11 @@ async function resetDashboardGridLayout() {
await syncDashboardGrid()
if (isLayoutEditing.value) {
exitDashboardLayoutEditing()
} else {
await nextTick()
syncDashboardFillContentState()
resizeAutoDashboardItemsToContent()
notifyDashboardContentResize()
}
}
@@ -662,15 +795,6 @@ function toggleDashboardLayoutEditing() {
// 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。
async function loadDashboardConfig() {
dashboardLayoutProfile.value = resolveDashboardLayoutProfile()
// 显示配置
const enable = await loadSharedDashboardConfig(
DASHBOARD_ENABLE_CONFIG_KEY,
DASHBOARD_ENABLE_STORAGE_KEY,
normalizeDashboardEnableConfig,
)
if (enable !== undefined) {
enableConfig.value = enable
}
// 顺序配置
const order = await loadSharedDashboardConfig(
DASHBOARD_ORDER_CONFIG_KEY,
@@ -680,10 +804,14 @@ async function loadDashboardConfig() {
if (order !== undefined) {
orderConfig.value = order
}
// Grid 布局覆盖
const gridLayoutProfile = dashboardLayoutProfile.value
const gridLayout = await loadDashboardGridLayoutConfig(gridLayoutProfile)
dashboardGridLayout.value = gridLayout ?? {}
// 设备档位配置同时承载 Grid 布局和显示项,显示项缺失时从旧版全局配置迁移。
const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value)
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
dashboardGridLayout.value = profileConfig?.items ?? {}
enableConfig.value = profileConfig?.enabled ?? legacyEnable ?? getDefaultDashboardEnableConfig()
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
saveDashboardProfileConfig()
}
// 排序
if (orderConfig.value) {
sortDashboardConfigs()
@@ -709,16 +837,13 @@ async function saveDashboardConfig(payload?: { enabled?: Record<string, boolean>
enableConfig.value = payload.enabled
}
// 启用配置
saveLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, enableConfig.value)
// 顺序配置从dashboardConfigs中提取
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
saveLocalDashboardConfig(DASHBOARD_ORDER_STORAGE_KEY, orderObj)
saveDashboardProfileConfig()
// 保存到服务端
try {
await saveUserDashboardConfig(DASHBOARD_ENABLE_CONFIG_KEY, enableConfig.value)
await saveUserDashboardConfig(DASHBOARD_ORDER_CONFIG_KEY, orderObj)
} catch (error) {
console.error(error)
@@ -755,6 +880,7 @@ async function getPluginDashboardMeta() {
}
}
// 清理指定插件仪表板的定时刷新任务。
function clearPluginDashboardTimer(pluginDashboardId: string) {
if (!refreshTimers.value[pluginDashboardId]) return
@@ -762,6 +888,7 @@ function clearPluginDashboardTimer(pluginDashboardId: string) {
delete refreshTimers.value[pluginDashboardId]
}
// 根据插件刷新配置安排下一次仪表板数据刷新。
function schedulePluginDashboardRefresh(item: DashboardItem) {
const pluginDashboardId = buildPluginDashboardId(item.id, item.key)
clearPluginDashboardTimer(pluginDashboardId)
@@ -778,6 +905,7 @@ function schedulePluginDashboardRefresh(item: DashboardItem) {
}
}
// 重新拉取当前启用的插件仪表板数据。
function refreshEnabledPluginDashboards() {
if (isNullOrEmptyObject(pluginDashboardMeta.value)) return
@@ -882,6 +1010,7 @@ async function syncDashboardGrid() {
isSyncingDashboardGrid.value = true
await nextTick()
syncDashboardFillContentState()
const items = dashboardGridItems.value
const itemMap = new Map(items.map(item => [item.id, item]))
@@ -924,8 +1053,10 @@ async function syncDashboardGrid() {
grid.batchUpdate(false)
updateDashboardGridEditableState(isLayoutEditing.value)
syncDashboardFillContentState()
observeDashboardGridContent()
nextTick(() => {
syncDashboardFillContentState()
resizeAutoDashboardItemsToContent()
scheduleDashboardReveal()
})
@@ -939,11 +1070,24 @@ function hasManualDashboardGridHeight(id: string) {
return dashboardGridLayout.value[id]?.h !== undefined
}
// 根据子组件声明的填充标记,同步 GridStack 外层测高节点的填充状态。
function syncDashboardFillContentState(element?: GridItemHTMLElement) {
const gridElement = dashboardGridRef.value
const itemElements = element
? [element]
: Array.from(gridElement?.querySelectorAll<GridItemHTMLElement>('.dashboard-grid-item') ?? [])
itemElements.forEach(itemElement => {
itemElement.classList.toggle('has-fill-content', Boolean(itemElement.querySelector('.dashboard-grid-fill')))
})
}
// 监听仪表板组件内容尺寸变化,让未手动调高的组件按内容高度自适应。
function observeDashboardGridContent() {
const gridElement = dashboardGridRef.value
if (!gridElement || typeof ResizeObserver === 'undefined') return
syncDashboardFillContentState()
dashboardGridContentObserver?.disconnect()
dashboardGridPendingContentResize.clear()
dashboardGridObservedContentHeights.clear()
@@ -992,7 +1136,19 @@ function resizeDashboardItemToContent(element: GridItemHTMLElement) {
const id = element.getAttribute('gs-id') ?? ''
if (!grid || !id || isLayoutEditing.value || isDashboardGridResizing.value || hasManualDashboardGridHeight(id)) return
grid.resizeToContent(element)
syncDashboardFillContentState(element)
const shouldMeasureFillContent = element.classList.contains('has-fill-content')
if (shouldMeasureFillContent) {
element.classList.add('is-measuring-content')
}
try {
grid.resizeToContent(element)
} finally {
if (shouldMeasureFillContent) {
element.classList.remove('is-measuring-content')
}
}
}
// 将所有未手动固定高度的组件高度调整到内容实际高度。
@@ -1000,6 +1156,7 @@ function resizeAutoDashboardItemsToContent() {
const gridElement = dashboardGridRef.value
if (!gridElement) return
syncDashboardFillContentState()
gridElement.querySelectorAll<GridItemHTMLElement>('.dashboard-grid-item').forEach(element => {
resizeDashboardItemToContent(element)
})
@@ -1122,8 +1279,18 @@ watch(
}
dashboardLayoutProfile.value = nextProfile
dashboardGridLayout.value = (await loadDashboardGridLayoutConfig(nextProfile)) ?? {}
dashboardGrid.value?.column(getDashboardGridColumnsForProfile(nextProfile), getDashboardGridColumnLayout(nextProfile))
const profileConfig = await loadDashboardProfileConfig(nextProfile)
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
dashboardGridLayout.value = profileConfig?.items ?? {}
enableConfig.value = profileConfig?.enabled ?? legacyEnable ?? getDefaultDashboardEnableConfig()
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
saveDashboardProfileConfig()
}
updateDashboardSettingsDialog()
dashboardGrid.value?.column(
getDashboardGridColumnsForProfile(nextProfile),
getDashboardGridColumnLayout(nextProfile),
)
dashboardGrid.value?.removeAll(false, false)
await syncDashboardGrid()
notifyDashboardContentResize()
@@ -1277,38 +1444,21 @@ onBeforeUnmount(() => {
block-size: 100%;
}
.dashboard-grid-item.has-fill-content .dashboard-grid-auto-size,
.dashboard-grid-item.has-fill-content .dashboard-grid-content-measure {
block-size: 100%;
min-block-size: 100%;
}
.dashboard-grid-item.has-fill-content.is-measuring-content .dashboard-grid-auto-size,
.dashboard-grid-item.has-fill-content.is-measuring-content .dashboard-grid-content-measure {
block-size: auto;
}
.dashboard-grid.is-editing :deep(.v-card) {
block-size: 100%;
}
.dashboard-grid :deep(.dashboard-chart-card),
.dashboard-grid :deep(.dashboard-summary-card),
.dashboard-grid :deep(.dashboard-work-card),
.dashboard-grid :deep(.dashboard-media-card) {
display: flex;
flex-direction: column;
min-block-size: 0;
}
.dashboard-grid :deep(.dashboard-chart-card .v-card-text),
.dashboard-grid :deep(.dashboard-work-card .v-card-text),
.dashboard-grid :deep(.dashboard-card-grid-wrap) {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-grid:not(.is-editing) .dashboard-grid-item:not(.is-manual-height) :deep(.dashboard-summary-card) {
min-block-size: 160px;
}
.dashboard-grid:not(.is-editing) .dashboard-grid-item:not(.is-manual-height) :deep(.dashboard-chart-card) {
min-block-size: 256px;
}
.dashboard-grid:not(.is-editing) .dashboard-grid-item:not(.is-manual-height) :deep(.dashboard-work-card) {
min-block-size: 352px;
}
.dashboard-grid.is-editing :deep(.v-card-text),
.dashboard-grid-item.is-manual-height :deep(.v-card-text) {
overflow: auto;

View File

@@ -1353,7 +1353,7 @@ onUnmounted(() => {
</VFadeTransition>
<!-- 结果抬头保持和站点管理一致的页面标题结构筛选控制交给下方工具条 -->
<div v-if="showResultHeader" class="resource-page-header d-flex justify-space-between align-center mb-4">
<div v-if="showResultHeader" class="resource-page-header d-flex justify-space-between align-center mb-3">
<div class="resource-page-header__copy">
<VPageContentTitle
:title="isSubtitleSearch ? t('resource.subtitleSearchResults') : t('resource.searchResults')"

View File

@@ -3,7 +3,7 @@ import { debounce } from 'lodash-es'
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useUserStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
@@ -31,6 +31,21 @@ const subId = ref(route.query.id as string)
const activeTab = ref((route.query.tab as string) || '')
const subscribeListViewRef = ref<InstanceType<typeof SubscribeListView> | null>(null)
// 订阅批量模式状态快照,来源于订阅列表组件。
interface SubscribeBatchState {
enabled: boolean
selectedCount: number
totalCount: number
allSelected: boolean
}
const subscribeBatchState = ref<SubscribeBatchState>({
enabled: false,
selectedCount: 0,
totalCount: 0,
allSelected: false,
})
// 获取标签页
const subscribeTabs = computed(() => {
if (subType === '电影') {
@@ -221,6 +236,52 @@ function openShareStatisticsDialog() {
openSharedDialog(SubscribeShareStatisticsDialog, {}, {}, { closeOn: ['close'] })
}
// 订阅列表批量状态变化响应,用于驱动移动端 Footer 和桌面 FAB 操作按钮。
function handleSubscribeBatchStateChange(state: SubscribeBatchState) {
subscribeBatchState.value = state
}
// 重置父页面保存的订阅批量操作状态。
function resetSubscribeBatchState() {
subscribeBatchState.value = {
enabled: false,
selectedCount: 0,
totalCount: 0,
allSelected: false,
}
}
// 进入订阅批量操作模式。
function enterSubscribeBatchMode() {
subscribeListViewRef.value?.enterBatchMode()
}
// 退出订阅批量操作模式。
function exitSubscribeBatchMode() {
resetSubscribeBatchState()
subscribeListViewRef.value?.exitBatchMode()
}
// 切换当前订阅列表全选状态。
function toggleSubscribeBatchSelectAll() {
subscribeListViewRef.value?.toggleSelectAll()
}
// 批量启用已选订阅。
function batchEnableSelectedSubscribes() {
subscribeListViewRef.value?.batchEnableSubscribes()
}
// 批量暂停已选订阅。
function batchPauseSelectedSubscribes() {
subscribeListViewRef.value?.batchPauseSubscribes()
}
// 批量删除已选订阅。
function batchDeleteSelectedSubscribes() {
subscribeListViewRef.value?.batchDeleteSubscribes()
}
// 切换订阅拖拽排序模式,进入时固定使用自定义排序。
function toggleSubscribeSortMode() {
if (!subscribeSortMode.value) {
@@ -241,6 +302,10 @@ watch(activeTab, newTab => {
if (newTab !== 'share') {
searchShareDialog.value = false
}
if (newTab !== 'mysub' && subscribeBatchState.value.enabled) {
exitSubscribeBatchMode()
}
})
watch(subscribeSortBy, newSortBy => {
@@ -251,17 +316,68 @@ onUnmounted(() => {
shareKeywordUpdater.cancel()
})
const subscribeDynamicMenuItems = computed(() => {
const subscribeDynamicMenuItems = computed<DynamicButtonMenuItem[] | undefined>(() => {
if (!appMode.value) return undefined
if (activeTab.value === 'mysub') {
const items: Array<{
titleKey: string
titleParams?: Record<string, unknown>
icon: string
permission: 'admin'
action: () => void
}> = []
if (subscribeBatchState.value.enabled) {
const hasSelectedSubscribes = subscribeBatchState.value.selectedCount > 0
return [
{
titleKey: 'subscribe.selectedCount',
titleParams: {
count: subscribeBatchState.value.selectedCount,
total: subscribeBatchState.value.totalCount,
},
icon: 'mdi-checkbox-multiple-marked-outline',
permission: 'subscribe',
disabled: true,
action: () => {},
},
{
titleKey: subscribeBatchState.value.allSelected
? 'subscribe.batchDeselectAll'
: 'subscribe.batchSelectAll',
icon: subscribeBatchState.value.allSelected ? 'mdi-checkbox-blank-outline' : 'mdi-checkbox-multiple-marked',
permission: 'subscribe',
disabled: subscribeBatchState.value.totalCount === 0,
action: toggleSubscribeBatchSelectAll,
},
{
titleKey: 'subscribe.batchEnable',
icon: 'mdi-play',
color: 'success',
permission: 'subscribe',
disabled: !hasSelectedSubscribes,
action: batchEnableSelectedSubscribes,
},
{
titleKey: 'subscribe.batchPause',
icon: 'mdi-pause',
color: 'info',
permission: 'subscribe',
disabled: !hasSelectedSubscribes,
action: batchPauseSelectedSubscribes,
},
{
titleKey: 'subscribe.batchDelete',
icon: 'mdi-delete',
color: 'error',
permission: 'subscribe',
disabled: !hasSelectedSubscribes,
action: batchDeleteSelectedSubscribes,
},
{
titleKey: 'subscribe.exitBatchMode',
icon: 'mdi-close',
permission: 'subscribe',
action: exitSubscribeBatchMode,
},
]
}
const items: DynamicButtonMenuItem[] = []
if (showSubscribeHistoryAction.value) {
items.push({
@@ -287,12 +403,18 @@ const subscribeDynamicMenuItems = computed(() => {
})
const subscribeDynamicIcon = computed(() => {
if (subscribeBatchState.value.enabled) return 'mdi-checkbox-multiple-marked-outline'
if (showShareStatisticsAction.value) return 'mdi-chart-line'
if (showSubscribeHistoryAction.value) return 'mdi-history'
return 'mdi-clipboard-edit-outline'
})
function handleSubscribeDynamicAction() {
if (subscribeBatchState.value.enabled) {
exitSubscribeBatchMode()
return
}
if (showShareStatisticsAction.value) {
openShareStatisticsDialog()
return
@@ -313,7 +435,9 @@ useDynamicButton({
onClick: handleSubscribeDynamicAction,
menuItems: subscribeDynamicMenuItems,
permission: 'subscribe',
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
show: computed(
() => appMode.value && (subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
),
})
// 使用动态标签页
@@ -348,13 +472,16 @@ registerHeaderTab({
{
icon: 'mdi-checkbox-multiple-marked-outline',
variant: 'text',
color: 'gray',
color: computed(() => (subscribeBatchState.value.enabled ? 'primary' : 'gray')),
class: 'settings-icon-button',
permission: 'subscribe',
action: () => {
// 触发批量管理模式
const event = new CustomEvent('toggle-batch-mode')
window.dispatchEvent(event)
if (subscribeBatchState.value.enabled) {
exitSubscribeBatchMode()
return
}
enterSubscribeBatchMode()
},
show: computed(() => activeTab.value === 'mysub'),
},
@@ -399,6 +526,7 @@ onMounted(() => {
:active="activeTab === 'mysub'"
@update:sort-mode="subscribeSortMode = $event"
@update:sort-by="subscribeSortBy = $event"
@batch-state-change="handleSubscribeBatchStateChange"
/>
</div>
</transition>
@@ -513,7 +641,56 @@ onMounted(() => {
<Teleport to="body" v-if="!appMode && route.path.startsWith(`/subscribe/${subType === '电影' ? 'movie' : 'tv'}`)">
<div class="compact-fab-stack">
<VFab
v-if="showSubscribeHistoryAction"
v-if="subscribeBatchState.enabled"
icon="mdi-close"
color="secondary"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="exitSubscribeBatchMode"
/>
<VFab
v-if="subscribeBatchState.enabled"
:icon="subscribeBatchState.allSelected ? 'mdi-checkbox-blank-outline' : 'mdi-checkbox-multiple-marked'"
color="primary"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
:disabled="subscribeBatchState.totalCount === 0"
@click="toggleSubscribeBatchSelectAll"
/>
<VFab
v-if="subscribeBatchState.enabled"
icon="mdi-delete"
color="error"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
:disabled="subscribeBatchState.selectedCount === 0"
@click="batchDeleteSelectedSubscribes"
/>
<VFab
v-if="subscribeBatchState.enabled"
icon="mdi-pause"
color="info"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
:disabled="subscribeBatchState.selectedCount === 0"
@click="batchPauseSelectedSubscribes"
/>
<VFab
v-if="subscribeBatchState.enabled"
icon="mdi-play"
color="success"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
:disabled="subscribeBatchState.selectedCount === 0"
@click="batchEnableSelectedSubscribes"
/>
<VFab
v-if="!subscribeBatchState.enabled && showSubscribeHistoryAction"
icon="mdi-history"
color="info"
variant="tonal"
@@ -522,7 +699,7 @@ onMounted(() => {
@click="openSubscribeHistoryDialog"
/>
<VFab
v-if="showDefaultRuleAction"
v-if="!subscribeBatchState.enabled && showDefaultRuleAction"
icon="mdi-clipboard-edit-outline"
color="primary"
appear
@@ -530,7 +707,7 @@ onMounted(() => {
@click="openDefaultRuleDialog"
/>
<VFab
v-if="showShareStatisticsAction"
v-if="!subscribeBatchState.enabled && showShareStatisticsAction"
icon="mdi-chart-line"
color="primary"
appear

View File

@@ -75,11 +75,11 @@ html {
--app-elevation-#{$level}: #{app-vuetify-elevation($level)};
}
--app-theme-surface-radius: var(--app-vuetify-rounded);
--app-theme-surface-radius: var(--app-vuetify-rounded-lg);
--app-surface-radius: var(--app-theme-surface-radius);
--app-field-radius: var(--app-vuetify-rounded);
--app-field-radius: var(--app-vuetify-rounded-lg);
--app-control-radius: var(--app-vuetify-rounded);
--app-overlay-radius: var(--app-vuetify-rounded);
--app-overlay-radius: var(--app-vuetify-rounded-lg);
--app-surface-border-opacity: 0.06;
--app-surface-border: 1px solid rgba(var(--v-theme-on-surface), var(--app-surface-border-opacity));
--app-card-rest-shadow: var(--app-elevation-0);
@@ -102,20 +102,28 @@ html[data-theme-skin='bordered'] {
--app-surface-border-opacity: 0.1;
}
html[data-theme-radius='none'] {
--app-theme-surface-radius: var(--app-vuetify-rounded-0);
--app-field-radius: var(--app-vuetify-rounded-0);
--app-control-radius: var(--app-vuetify-rounded-0);
--app-overlay-radius: var(--app-vuetify-rounded-0);
// 默认档位保留按钮等 control 的 Vuetify 原始圆角,同时让卡片、列表、弹窗和输入区使用更舒展的大圆角。
html[data-theme-radius='default'] {
--app-theme-surface-radius: var(--app-vuetify-rounded-lg);
--app-field-radius: var(--app-vuetify-rounded-lg);
--app-control-radius: var(--app-vuetify-rounded);
--app-overlay-radius: var(--app-vuetify-rounded-lg);
}
html[data-theme-radius='small'] {
html[data-theme-radius='none'] {
--app-theme-surface-radius: var(--app-vuetify-rounded-sm);
--app-field-radius: var(--app-vuetify-rounded-sm);
--app-control-radius: var(--app-vuetify-rounded-sm);
--app-overlay-radius: var(--app-vuetify-rounded-sm);
}
html[data-theme-radius='small'] {
--app-theme-surface-radius: var(--app-vuetify-rounded);
--app-field-radius: var(--app-vuetify-rounded);
--app-control-radius: var(--app-vuetify-rounded);
--app-overlay-radius: var(--app-vuetify-rounded);
}
html[data-theme-radius='large'] {
--app-theme-surface-radius: var(--app-vuetify-rounded-lg);
--app-field-radius: var(--app-vuetify-rounded-lg);

View File

@@ -138,7 +138,7 @@ useKeepAliveRefresh(refresh)
</script>
<template>
<VCard class="dashboard-chart-card">
<VCard class="dashboard-chart-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>CPU</VCardTitle>
</VCardItem>
@@ -154,11 +154,19 @@ useKeepAliveRefresh(refresh)
</template>
<style scoped>
.dashboard-chart-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.dashboard-chart-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
}
.dashboard-chart-plot {

View File

@@ -82,12 +82,12 @@ onActivated(() => {
</script>
<template>
<VCard class="dashboard-summary-card">
<VCard class="dashboard-summary-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>{{ t('dashboard.mediaStatistic') }}</VCardTitle>
</VCardItem>
<VCardText>
<VCardText class="dashboard-summary-content">
<VRow>
<VCol v-for="item in statistics" :key="item.title" cols="6" sm="3">
<div class="d-flex align-center">
@@ -111,6 +111,25 @@ onActivated(() => {
</template>
<style lang="scss" scoped>
.dashboard-summary-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.dashboard-summary-content {
display: flex;
flex: 1 1 auto;
align-items: center;
min-block-size: 0;
}
.dashboard-summary-content :deep(.v-row) {
flex: 1 1 auto;
align-items: center;
}
.dashboard-number {
font-variant-numeric: tabular-nums;
}

View File

@@ -144,7 +144,7 @@ useKeepAliveRefresh(refresh)
</script>
<template>
<VCard class="dashboard-chart-card">
<VCard class="dashboard-chart-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>
</VCardItem>
@@ -160,11 +160,19 @@ useKeepAliveRefresh(refresh)
</template>
<style scoped>
.dashboard-chart-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.dashboard-chart-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
}
.dashboard-chart-plot {

View File

@@ -173,7 +173,7 @@ useKeepAliveRefresh(refresh)
</script>
<template>
<VCard class="dashboard-chart-card">
<VCard class="dashboard-chart-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
</VCardItem>
@@ -196,11 +196,19 @@ useKeepAliveRefresh(refresh)
</template>
<style scoped>
.dashboard-chart-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.dashboard-chart-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
}
.dashboard-chart-plot {

View File

@@ -44,7 +44,7 @@ useDataRefresh(
</script>
<template>
<VCard class="dashboard-work-card">
<VCard class="dashboard-work-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>
</VCardItem>
@@ -83,6 +83,13 @@ useDataRefresh(
</template>
<style lang="scss" scoped>
.dashboard-work-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.card-list {
--v-card-list-gap: 1.5rem;
@@ -96,6 +103,7 @@ useDataRefresh(
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
}
.card-list::-webkit-scrollbar {

View File

@@ -112,7 +112,7 @@ const { loading } = useDataRefresh(
</script>
<template>
<VCard class="dashboard-work-card">
<VCard class="dashboard-work-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>{{ t('dashboard.realTimeSpeed') }}</VCardTitle>
</VCardItem>
@@ -146,6 +146,13 @@ const { loading } = useDataRefresh(
</template>
<style lang="scss" scoped>
.dashboard-work-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.card-list {
--v-card-list-gap: 1rem;
@@ -159,6 +166,7 @@ const { loading } = useDataRefresh(
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
}
.dashboard-speed-number {

View File

@@ -63,18 +63,18 @@ onActivated(() => {
</script>
<template>
<VCard class="dashboard-summary-card">
<VCard class="dashboard-summary-card dashboard-grid-fill">
<!-- Triangle Background -->
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
<VCardItem>
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
</VCardItem>
<VCardText>
<h5 class="animated-storage-value text-2xl font-weight-medium text-primary">
<VCardText class="dashboard-summary-content">
<h5 class="animated-storage-value font-weight-medium text-primary">
{{ animatedStorageText }}
</h5>
<p class="mt-2">{{ t('storage.usedPercent', { percent: animatedUsedPercentText }) }} 🚀</p>
<p class="mt-1">
<div class="animated-storage-meta">{{ t('storage.usedPercent', { percent: animatedUsedPercentText }) }} 🚀</div>
<div class="animated-storage-progress-wrap">
<VProgressLinear
:model-value="animatedUsedPercentValue"
class="animated-storage-progress"
@@ -82,7 +82,7 @@ onActivated(() => {
height="6"
rounded
/>
</p>
</div>
</VCardText>
<!-- Trophy -->
<VImg :src="trophy" class="trophy" />
@@ -94,22 +94,50 @@ onActivated(() => {
.v-card .triangle-bg {
position: absolute;
inline-size: 8.75rem;
inline-size: clamp(7rem, 36%, 8.75rem);
inset-block-end: 0;
inset-inline-end: 0;
}
.v-card .trophy {
position: absolute;
inline-size: 4.9375rem;
inset-block-end: 2rem;
inline-size: clamp(3.75rem, 18%, 4.5rem);
inset-block-end: 2.75rem;
inset-inline-end: 2rem;
}
.dashboard-summary-card {
position: relative;
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
overflow: hidden;
}
.dashboard-summary-content {
flex: 1 1 auto;
min-block-size: 0;
padding-block: 0.25rem 1rem;
}
.animated-storage-value {
font-size: clamp(1.375rem, 1.8vw, 1.5rem);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.animated-storage-meta {
margin-block-start: 0.5rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
line-height: 1.2;
}
.animated-storage-progress-wrap {
margin-block-start: 0.35rem;
}
.animated-storage-progress {
overflow: hidden;
}

View File

@@ -133,7 +133,7 @@ onActivated(() => {
</script>
<template>
<VCard class="dashboard-work-card">
<VCard class="dashboard-work-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>{{ t('dashboard.weeklyOverview') }}</VCardTitle>
</VCardItem>
@@ -156,11 +156,19 @@ onActivated(() => {
</template>
<style scoped>
.dashboard-work-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.dashboard-work-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
}
.dashboard-work-chart {

View File

@@ -56,13 +56,13 @@ onActivated(() => {
</script>
<template>
<div class="dashboard-media-stack">
<div class="dashboard-media-stack" :class="{ 'dashboard-grid-fill': Object.keys(latestList).length > 0 }">
<VCard v-for="(data, name) in latestList" :key="name" class="dashboard-work-card dashboard-media-card">
<VCardItem>
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
</VCardItem>
<div class="px-5 pb-3">
<div class="dashboard-media-content px-5 pb-3">
<ProgressiveCardGrid
class="dashboard-media-grid"
:items="data"
@@ -91,7 +91,10 @@ onActivated(() => {
}
.dashboard-media-stack > .dashboard-media-card {
display: flex;
flex-direction: column;
flex: 1 1 auto;
block-size: 100%;
min-block-size: 0;
}
@@ -99,4 +102,16 @@ onActivated(() => {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-media-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: auto;
}
.dashboard-media-content::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -61,11 +61,11 @@ onActivated(() => {
</script>
<template>
<VCard v-if="libraryList.length > 0" class="dashboard-media-card">
<VCard v-if="libraryList.length > 0" class="dashboard-media-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
</VCardItem>
<div class="px-5 pb-3">
<div class="dashboard-media-content px-5 pb-3">
<ProgressiveCardGrid
class="dashboard-media-grid"
:items="libraryList"
@@ -89,4 +89,23 @@ onActivated(() => {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-media-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.dashboard-media-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: auto;
}
.dashboard-media-content::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -61,12 +61,12 @@ onActivated(() => {
</script>
<template>
<VCard v-if="playingList.length > 0" class="dashboard-media-card">
<VCard v-if="playingList.length > 0" class="dashboard-media-card dashboard-grid-fill">
<VCardItem>
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
</VCardItem>
<div class="px-5 pb-3">
<div class="dashboard-media-content px-5 pb-3">
<ProgressiveCardGrid
class="dashboard-media-grid"
:items="playingList"
@@ -90,4 +90,23 @@ onActivated(() => {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-media-card {
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.dashboard-media-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
overflow: auto;
}
.dashboard-media-content::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -557,11 +557,6 @@ async function handleSubtitleSearch() {
await clickSearch('title', 'subtitle')
}
// 搜索单集字幕
async function handleEpisodeSubtitleSearch(season: number | null, episode: number | null) {
await clickSearch('title', 'subtitle', { season, episode })
}
onBeforeMount(() => {
getMediaDetail()
})
@@ -823,25 +818,6 @@ onBeforeMount(() => {
class="ms-2"
size="small"
/>
<VTooltip v-if="canSearch" location="top">
<template #activator="{ props }">
<IconBtn
class="ms-1"
color="info"
variant="text"
v-bind="props"
@click.stop="
handleEpisodeSubtitleSearch(
season.season_number ?? null,
episode.episode_number ?? null,
)
"
>
<VIcon icon="mdi-subtitles-outline" size="small" />
</IconBtn>
</template>
<span>{{ t('media.actions.searchSubtitle') }}</span>
</VTooltip>
</div>
<p>{{ episode.overview }}</p>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -382,7 +382,7 @@ useDynamicButton({
<template>
<div class="card-list-container">
<!-- 页面标题和筛选/排序按钮 -->
<div class="d-flex justify-space-between align-center mb-4">
<div class="d-flex justify-space-between align-center mb-3">
<VPageContentTitle :title="t('navItems.siteManager')" class="my-0" style="margin-block: 0" />
<!-- 右侧按钮组保留筛选和排序其他页面动作移到 FAB -->
<div class="d-flex align-center gap-1">

File diff suppressed because it is too large Load Diff

View File

@@ -53,10 +53,19 @@ const props = defineProps({
const emit = defineEmits<{
'update:sortMode': [value: boolean]
'update:sortBy': [value: SubscribeSortBy]
'batch-state-change': [state: SubscribeBatchState]
}>()
type SubscribeSortBy = 'custom' | 'last_update' | 'date' | 'lack_episode'
// 订阅批量模式状态快照,供父页面渲染外部批量操作按钮。
interface SubscribeBatchState {
enabled: boolean
selectedCount: number
totalCount: number
allSelected: boolean
}
// 是否刷新过
let isRefreshed = ref(false)
@@ -79,6 +88,9 @@ const selectedSubscribes = ref<number[]>([])
const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() || '')
const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value))
const hasCustomOrder = computed(() => orderConfig.value.length > 0)
const isAllSubscribesSelected = computed(
() => displayList.value.length > 0 && selectedSubscribes.value.length === displayList.value.length,
)
// 归一化订阅排序方式,电影订阅不使用缺失集数排序。
const normalizedSortBy = computed<SubscribeSortBy | ''>(() => {
@@ -248,6 +260,12 @@ watch(
{ immediate: true },
)
watch(
[isBatchMode, () => selectedSubscribes.value.length, () => displayList.value.length, isAllSubscribesSelected],
emitBatchStateChange,
{ immediate: true },
)
// 加载顺序
async function loadSubscribeOrderConfig() {
try {
@@ -313,25 +331,47 @@ function openHistoryDialog() {
)
}
// 批量管理相关函数
// 切换批量模式
function toggleBatchMode() {
isBatchMode.value = !isBatchMode.value
if (!isBatchMode.value) {
selectedSubscribes.value = []
}
// 向父组件同步批量操作状态,供 Footer/FAB 动态按钮渲染。
function emitBatchStateChange() {
emit('batch-state-change', {
enabled: isBatchMode.value,
selectedCount: selectedSubscribes.value.length,
totalCount: displayList.value.length,
allSelected: isAllSubscribesSelected.value,
})
}
// 全选/取消全选
// 进入批量模式。
function enterBatchMode() {
isBatchMode.value = true
}
// 退出批量模式并清空已选择的订阅。
function exitBatchMode() {
isBatchMode.value = false
selectedSubscribes.value = []
}
// 切换批量模式。
function toggleBatchMode() {
if (isBatchMode.value) {
exitBatchMode()
return
}
enterBatchMode()
}
// 全选或取消全选当前显示的订阅。
function toggleSelectAll() {
if (selectedSubscribes.value.length === displayList.value.length) {
if (isAllSubscribesSelected.value) {
selectedSubscribes.value = []
} else {
selectedSubscribes.value = displayList.value.map(item => item.id)
}
}
// 选择单个订阅
// 切换单个订阅的选中状态。
function toggleSelectSubscribe(id: number) {
const index = selectedSubscribes.value.indexOf(id)
if (index > -1) {
@@ -341,7 +381,7 @@ function toggleSelectSubscribe(id: number) {
}
}
// 批量删除订阅
// 批量删除已选中的订阅
async function batchDeleteSubscribes() {
if (selectedSubscribes.value.length === 0) {
$toast.warning(t('subscribe.noSelectedItems'))
@@ -370,11 +410,8 @@ async function batchDeleteSubscribes() {
$toast.error(t('subscribe.batchDeleteFailed', { count: failedCount }))
}
// 刷新数据
await fetchData()
// 退出批量模式
isBatchMode.value = false
selectedSubscribes.value = []
exitBatchMode()
} catch (error) {
console.error(error)
$toast.error(t('subscribe.batchDeleteError'))
@@ -383,7 +420,7 @@ async function batchDeleteSubscribes() {
}
}
// 批量启用订阅
// 批量启用已选中的订阅
async function batchEnableSubscribes() {
if (selectedSubscribes.value.length === 0) {
$toast.warning(t('subscribe.noSelectedItems'))
@@ -412,11 +449,8 @@ async function batchEnableSubscribes() {
$toast.error(t('subscribe.batchEnableFailed', { count: failedCount }))
}
// 刷新数据
await fetchData()
// 退出批量模式
isBatchMode.value = false
selectedSubscribes.value = []
exitBatchMode()
} catch (error) {
console.error(error)
$toast.error(t('subscribe.batchEnableError'))
@@ -425,7 +459,7 @@ async function batchEnableSubscribes() {
}
}
// 批量暂停订阅
// 批量暂停已选中的订阅
async function batchPauseSubscribes() {
if (selectedSubscribes.value.length === 0) {
$toast.warning(t('subscribe.noSelectedItems'))
@@ -454,11 +488,8 @@ async function batchPauseSubscribes() {
$toast.error(t('subscribe.batchPauseFailed', { count: failedCount }))
}
// 刷新数据
await fetchData()
// 退出批量模式
isBatchMode.value = false
selectedSubscribes.value = []
exitBatchMode()
} catch (error) {
console.error(error)
$toast.error(t('subscribe.batchPauseError'))
@@ -495,13 +526,6 @@ onMounted(async () => {
}
}
// 监听批量管理模式切换事件
window.addEventListener('toggle-batch-mode', toggleBatchMode)
})
onUnmounted(() => {
// 移除事件监听器
window.removeEventListener('toggle-batch-mode', toggleBatchMode)
})
useKeepAliveRefresh(fetchData, {
@@ -510,68 +534,19 @@ useKeepAliveRefresh(fetchData, {
defineExpose({
openHistoryDialog,
enterBatchMode,
exitBatchMode,
toggleBatchMode,
toggleSelectAll,
batchEnableSubscribes,
batchPauseSubscribes,
batchDeleteSubscribes,
})
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<!-- 批量管理工具栏 -->
<div v-if="isBatchMode" class="mb-4 px-2">
<VCard class="pa-4">
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<VCheckbox
:model-value="selectedSubscribes.length === displayList.length"
:indeterminate="selectedSubscribes.length > 0 && selectedSubscribes.length < displayList.length"
@update:model-value="toggleSelectAll"
hide-details
class="me-4"
/>
<span class="text-body-1 font-weight-medium">
{{ t('subscribe.selectedCount', { count: selectedSubscribes.length, total: displayList.length }) }}
</span>
</div>
<div class="d-flex gap-2">
<VBtn
color="success"
variant="outlined"
size="small"
:disabled="selectedSubscribes.length === 0"
@click="batchEnableSubscribes"
>
<VIcon icon="mdi-play" class="me-sm-1" />
<span class="d-none d-sm-inline">{{ t('subscribe.batchEnable') }}</span>
</VBtn>
<VBtn
color="info"
variant="outlined"
size="small"
:disabled="selectedSubscribes.length === 0"
@click="batchPauseSubscribes"
>
<VIcon icon="mdi-pause" class="me-sm-1" />
<span class="d-none d-sm-inline">{{ t('subscribe.batchPause') }}</span>
</VBtn>
<VBtn
color="error"
variant="outlined"
size="small"
:disabled="selectedSubscribes.length === 0"
@click="batchDeleteSubscribes"
>
<VIcon icon="mdi-delete" class="me-sm-1" />
<span class="d-none d-sm-inline">{{ t('subscribe.batchDelete') }}</span>
</VBtn>
<VBtn color="secondary" variant="outlined" size="small" @click="toggleBatchMode">
<VIcon icon="mdi-close" class="me-sm-1" />
<span class="d-none d-sm-inline">{{ t('common.cancel') }}</span>
</VBtn>
</div>
</div>
</VCard>
</div>
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2 py-0 app-surface-static">
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
<span>{{ t('common.sortModeHint') }}</span>

View File

@@ -8,15 +8,24 @@ import { useConfirm } from '@/composables/useConfirm'
import { useGlobalSettingsStore } from '@/stores'
import { usePWA } from '@/composables/usePWA'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
const CacheReidentifyDialog = defineAsyncComponent(() => import('@/components/dialog/CacheReidentifyDialog.vue'))
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
const MOBILE_CACHE_PAGE_SIZE = 20
// 国际化
const { t } = useI18n()
// PWA模式检测
const { appMode } = usePWA()
// 显示器宽度
const display = useDisplay()
const isMobile = computed(() => display.smAndDown.value)
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
@@ -66,18 +75,37 @@ const loading = ref(false)
const currentReidentifyItem = ref<TorrentCacheItem | null>(null)
// 移动端已经追加到虚拟列表的数据条数
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
let reidentifyDialogController: ReturnType<typeof openSharedDialog> | null = null
const tableStyle = computed(() => {
return appMode ? '' : 'height: calc(100vh - 21rem - env(safe-area-inset-bottom)'
})
// 调用API加载缓存数据
// 移动端虚拟列表数据
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
// 移动端是否还有未追加的数据页
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
// 移动端无限滚动组件刷新键
const mobileInfiniteKey = ref(0)
/** 重置移动端分页,让筛选或刷新后的列表从第一页开始展示。 */
function resetMobilePagination() {
mobileVisibleCount.value = MOBILE_CACHE_PAGE_SIZE
mobileInfiniteKey.value++
}
/** 调用 API 加载缓存数据。 */
async function loadCacheData() {
try {
loading.value = true
const res: any = await api.get('torrent/cache')
cacheData.value = res.data
resetMobilePagination()
} catch (e) {
console.log(e)
$toast.error(t('setting.cache.loadFailed'))
@@ -86,7 +114,23 @@ async function loadCacheData() {
}
}
// 清空所有缓存
/** 追加移动端下一页数据,并通过虚拟滚动限制实际渲染节点数量。 */
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
if (loading.value) {
done('ok')
return
}
if (!mobileHasMore.value) {
done('empty')
return
}
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.length)
done(mobileHasMore.value ? 'ok' : 'empty')
}
/** 清空所有缓存。 */
async function clearAllCache() {
const isConfirmed = await createConfirm({
type: 'warn',
@@ -109,7 +153,7 @@ async function clearAllCache() {
}
}
// 刷新缓存
/** 刷新缓存数据。 */
async function refreshCache() {
try {
loading.value = true
@@ -124,7 +168,7 @@ async function refreshCache() {
}
}
// 删除选中的缓存项
/** 删除桌面端表格中选中的缓存项。 */
async function deleteSelectedItems() {
if (selectedItems.value.length === 0) {
$toast.warning(t('setting.cache.selectDeleteWarning'))
@@ -153,7 +197,7 @@ async function deleteSelectedItems() {
}
}
// 删除单个缓存项
/** 删除单个缓存项。 */
async function deleteSingleItem(item: TorrentCacheItem) {
try {
loading.value = true
@@ -173,7 +217,7 @@ async function deleteSingleItem(item: TorrentCacheItem) {
}
}
// 打开重新识别对话框
/** 打开重新识别对话框。 */
function openReidentifyDialog(item: TorrentCacheItem) {
currentReidentifyItem.value = item
reidentifyDialogController?.close()
@@ -197,7 +241,7 @@ function openReidentifyDialog(item: TorrentCacheItem) {
)
}
// 重新识别
/** 执行缓存项重新识别。 */
async function performReidentify(payload: { doubanId?: string; tmdbId?: number } = {}) {
if (!currentReidentifyItem.value) return
@@ -229,11 +273,13 @@ async function performReidentify(payload: { doubanId?: string; tmdbId?: number }
}
}
// 获取媒体类型颜色
/** 获取媒体类型对应的主题颜色。 */
function getMediaTypeColor(type: string): string {
switch (type) {
case 'movie':
case t('setting.cache.mediaType.movie'):
return 'primary'
case 'tv':
case t('setting.cache.mediaType.tv'):
return 'success'
default:
@@ -241,7 +287,41 @@ function getMediaTypeColor(type: string): string {
}
}
// 打开详情页面
/** 获取移动端类型角标使用的 MediaCard 同款颜色类。 */
function getMobileMediaTypeChipClass(type: string): string {
switch (type) {
case 'movie':
case t('setting.cache.mediaType.movie'):
return 'border-blue-500 bg-blue-600'
case 'tv':
case t('setting.cache.mediaType.tv'):
return 'bg-indigo-500 border-indigo-600'
default:
return 'border-purple-600 bg-purple-600'
}
}
/** 生成移动端缓存卡片的稳定渲染键。 */
function getMobileCacheItemKey(item: TorrentCacheItem, index: number): string {
return item.hash || [item.domain, item.title, index].join('-')
}
/** 获取移动端缓存卡片使用的媒体标题。 */
function getMobileMediaTitle(item: TorrentCacheItem): string {
return item.media_name || item.description || t('setting.cache.unrecognized')
}
/** 获取移动端缓存卡片展示的识别补充信息。 */
function getMobileMediaMeta(item: TorrentCacheItem): string {
return [item.media_year, item.season_episode].filter(Boolean).join(' · ')
}
/** 获取移动端缓存卡片展示的资源补充信息。 */
function getMobileResourceMeta(item: TorrentCacheItem): string {
return [formatDateDifference(item.pubdate || ''), item.resource_term, item.site_name].filter(Boolean).join(' · ')
}
/** 打开缓存项的站点详情页面。 */
function openPageUrl(url: string) {
window.open(url, '_blank')
}
@@ -249,10 +329,178 @@ function openPageUrl(url: string) {
onMounted(() => {
loadCacheData()
})
watch([titleFilter, siteFilter], () => {
resetMobilePagination()
})
</script>
<template>
<div>
<section v-if="isMobile" class="cache-mobile-page">
<div class="cache-mobile-stats">
<div class="cache-mobile-stat cache-mobile-stat--primary">
<VIcon icon="mdi-database" size="32" />
<div>
<strong>{{ cacheData.count }}</strong>
<span>{{ t('setting.cache.totalCount') }}</span>
</div>
</div>
<div class="cache-mobile-stat cache-mobile-stat--success">
<VIcon icon="mdi-web" size="32" />
<div>
<strong>{{ cacheData.sites }}</strong>
<span>{{ t('setting.cache.siteCount') }}</span>
</div>
</div>
</div>
<div class="cache-mobile-filters">
<VTextField
v-model="titleFilter"
class="cache-mobile-filter"
:placeholder="t('setting.cache.filterByTitle')"
:aria-label="t('setting.cache.filterByTitle')"
prepend-inner-icon="mdi-magnify"
clearable
density="comfortable"
variant="outlined"
single-line
hide-details
/>
<VAutocomplete
v-model="siteFilter"
class="cache-mobile-filter"
:placeholder="t('setting.cache.filterBySite')"
:aria-label="t('setting.cache.filterBySite')"
:items="siteOptions"
prepend-inner-icon="mdi-web"
clearable
density="comfortable"
variant="outlined"
single-line
hide-details
/>
</div>
<div class="cache-mobile-actions">
<VBtn variant="tonal" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="refreshCache">
{{ t('setting.cache.refresh') }}
</VBtn>
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
{{ t('setting.cache.clearAll') }}
</VBtn>
</div>
<VInfiniteScroll
v-if="mobileVisibleData.length > 0 || loading"
:key="mobileInfiniteKey"
mode="intersect"
side="end"
:items="mobileVisibleData"
class="cache-mobile-scroll"
@load="loadMoreMobileCache"
>
<template #loading>
<div class="cache-mobile-load-state">
<VProgressCircular indeterminate color="primary" size="22" width="3" />
<span>{{ t('setting.cache.loadingMore') }}</span>
</div>
</template>
<template #empty />
<VVirtualScroll v-if="mobileVisibleData.length > 0" renderless :items="mobileVisibleData" :item-height="156">
<template #default="{ item, index, itemRef }">
<article :ref="itemRef" :key="getMobileCacheItemKey(item, index)" class="cache-mobile-card">
<div class="cache-mobile-card__poster">
<VChip
v-if="item.media_type"
variant="elevated"
size="small"
:class="getMobileMediaTypeChipClass(item.media_type)"
class="cache-mobile-card__type bg-opacity-80 text-white font-bold"
>
{{
item.media_type === 'movie'
? t('setting.cache.mediaType.movie')
: item.media_type === 'tv'
? t('setting.cache.mediaType.tv')
: item.media_type
}}
</VChip>
<VImg
v-if="item.poster_path"
:src="item.poster_path"
:alt="item.media_name || item.title"
cover
class="h-100 w-100"
>
<template #placeholder>
<VSkeletonLoader class="h-100 w-100" />
</template>
</VImg>
<VIcon v-else :icon="item.media_type === 'movie' ? 'mdi-movie-open' : 'mdi-television-play'" size="34" />
</div>
<div class="cache-mobile-card__content">
<div class="cache-mobile-card__torrent">
{{ item.title }}
</div>
<div class="cache-mobile-card__main">
{{ getMobileMediaTitle(item) }}
<span v-if="getMobileMediaMeta(item)">{{ getMobileMediaMeta(item) }}</span>
</div>
<div class="cache-mobile-card__meta">
<span>{{ getMobileResourceMeta(item) }}</span>
<strong>{{ formatFileSize(item.size) }}</strong>
</div>
</div>
<VMenu location="bottom end">
<template #activator="{ props: menuProps }">
<VBtn v-bind="menuProps" icon variant="text" class="cache-mobile-card__menu" :aria-label="t('setting.cache.actions')">
<VIcon icon="mdi-dots-vertical" />
</VBtn>
</template>
<VList density="compact">
<VListItem @click="openReidentifyDialog(item)">
<template #prepend>
<VIcon icon="mdi-text-recognition" color="primary" />
</template>
<VListItemTitle>{{ t('setting.cache.reidentify') }}</VListItemTitle>
</VListItem>
<VListItem v-if="item.page_url" @click="openPageUrl(item.page_url || '')">
<template #prepend>
<VIcon icon="mdi-open-in-new" color="info" />
</template>
<VListItemTitle>{{ t('common.openInNewWindow') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSingleItem(item)">
<template #prepend>
<VIcon icon="mdi-delete" color="error" />
</template>
<VListItemTitle>{{ t('common.delete') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</article>
</template>
</VVirtualScroll>
</VInfiniteScroll>
<div v-else class="cache-mobile-empty">
<VIcon icon="mdi-database-off" size="42" />
<span>{{ t('setting.cache.noData') }}</span>
<small>{{ t('setting.cache.noDataHint') }}</small>
</div>
</section>
<div v-else>
<!-- 工具栏统计信息和操作按钮 -->
<VCard class="mb-4">
<VCardItem>
@@ -468,3 +716,274 @@ onMounted(() => {
</VDataTable>
</div>
</template>
<style scoped>
.cache-mobile-page {
--cache-mobile-control-bg: rgba(var(--v-theme-surface), 0.82);
--cache-mobile-page-bg: rgb(var(--v-theme-surface));
--cache-mobile-surface-bg: rgba(var(--v-theme-surface), 0.94);
--cache-mobile-surface-blur: none;
display: flex;
overflow-y: auto;
flex: 1 1 auto;
flex-direction: column;
block-size: 100%;
inline-size: 100%;
min-block-size: 0;
padding: calc(8px + env(safe-area-inset-top)) 16px calc(18px + env(safe-area-inset-bottom));
background: var(--cache-mobile-page-bg);
gap: 16px;
}
.cache-mobile-stats {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-mobile-stat {
display: flex;
align-items: center;
backdrop-filter: var(--cache-mobile-surface-blur);
border-radius: 18px;
min-block-size: 92px;
padding: 18px;
gap: 14px;
}
.cache-mobile-stat strong {
display: block;
color: rgba(var(--v-theme-on-surface), 0.82);
font-size: 28px;
font-weight: 800;
line-height: 1.05;
}
.cache-mobile-stat span {
display: block;
margin-block-start: 8px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 14px;
font-weight: 600;
}
.cache-mobile-stat--primary {
background: linear-gradient(135deg, rgba(233, 30, 99, 0.14), rgba(233, 30, 99, 0.04));
color: #e91e63;
}
.cache-mobile-stat--success {
background: linear-gradient(135deg, rgba(76, 175, 80, 0.14), rgba(76, 175, 80, 0.04));
color: #16b52b;
}
.cache-mobile-filters {
display: flex;
flex-direction: column;
gap: 10px;
}
.cache-mobile-filter :deep(.v-field) {
backdrop-filter: var(--cache-mobile-surface-blur);
border-radius: 16px;
background: var(--cache-mobile-control-bg);
box-shadow: 0 6px 20px rgba(var(--v-theme-on-surface), 0.04);
}
.cache-mobile-filter :deep(.v-field__outline) {
color: rgba(var(--v-theme-on-surface), 0.18);
}
.cache-mobile-filter :deep(.v-field__input) {
min-block-size: 54px;
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 16px;
}
.cache-mobile-actions {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-mobile-actions :deep(.v-btn) {
min-block-size: 44px;
}
.cache-mobile-scroll {
overflow: visible !important;
min-block-size: 20rem;
}
.cache-mobile-scroll :deep(.v-infinite-scroll__container),
.cache-mobile-scroll :deep(.v-virtual-scroll),
.cache-mobile-scroll :deep(.v-virtual-scroll__container) {
overflow: visible !important;
}
.cache-mobile-scroll :deep(.v-infinite-scroll__side) {
padding-block: 14px 2px;
}
.cache-mobile-card {
position: relative;
display: grid;
overflow: visible;
align-items: start;
backdrop-filter: var(--cache-mobile-surface-blur);
border: 1px solid rgba(var(--v-theme-on-surface), 0.05);
border-radius: 16px;
background: var(--cache-mobile-surface-bg);
box-shadow: 0 10px 30px rgba(var(--v-theme-on-surface), 0.07);
gap: 14px;
grid-template-columns: 72px minmax(0, 1fr);
margin-block-end: 12px;
padding: 14px;
}
.cache-mobile-card__poster {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
border-radius: 9px;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 104px;
color: rgba(var(--v-theme-on-surface), 0.34);
inline-size: 72px;
}
.cache-mobile-card__type {
position: absolute;
z-index: 1;
inset-block-end: 5px;
inset-inline-start: 50%;
transform: translateX(-50%);
}
.cache-mobile-card__content {
min-inline-size: 0;
}
.cache-mobile-card__torrent {
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 14px;
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
padding-inline-end: 34px;
white-space: normal;
word-break: break-word;
}
.cache-mobile-card__main {
margin-block-start: 6px;
color: rgba(var(--v-theme-on-surface), 0.88);
font-size: 15px;
font-weight: 700;
line-height: 1.32;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
}
.cache-mobile-card__main span {
margin-inline-start: 6px;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 14px;
font-weight: 500;
}
.cache-mobile-card__meta {
display: grid;
align-items: end;
grid-template-columns: minmax(0, 1fr) auto;
margin-block-start: 8px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 14px;
gap: 10px;
line-height: 1.35;
}
.cache-mobile-card__meta span {
min-inline-size: 0;
overflow-wrap: anywhere;
white-space: normal;
}
.cache-mobile-card__meta strong {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 14px;
font-weight: 700;
text-align: end;
white-space: nowrap;
}
.cache-mobile-card__menu {
position: absolute;
color: rgba(var(--v-theme-on-surface), 0.5);
inset-block-start: 8px;
inset-inline-end: 8px;
}
.cache-mobile-load-state,
.cache-mobile-empty {
display: flex;
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), 0.58);
text-align: center;
}
.cache-mobile-load-state {
flex-direction: column;
min-block-size: 70px;
font-size: 15px;
font-weight: 700;
gap: 8px;
}
.cache-mobile-empty {
flex: 1 1 auto;
flex-direction: column;
min-block-size: 16rem;
gap: 8px;
}
.cache-mobile-empty span {
color: rgba(var(--v-theme-on-surface), 0.78);
font-size: 16px;
font-weight: 700;
}
.cache-mobile-empty small {
color: rgba(var(--v-theme-on-surface), 0.52);
font-size: 13px;
}
html[data-theme='transparent'] .cache-mobile-page,
.v-theme--transparent .cache-mobile-page {
--cache-mobile-control-bg: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
--cache-mobile-page-bg: transparent;
--cache-mobile-surface-bg: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
--cache-mobile-surface-blur: blur(var(--transparent-blur, 10px));
}
@media (max-width: 374.98px) {
.cache-mobile-page {
padding-inline: 12px;
}
.cache-mobile-card {
grid-template-columns: 64px minmax(0, 1fr);
padding: 12px;
}
.cache-mobile-card__poster {
block-size: 96px;
inline-size: 64px;
}
}
</style>

View File

@@ -5,6 +5,20 @@ import type { ScheduleInfo } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground'
// 移动端任务卡片视觉配置。
type SchedulerMobileVisual = {
color: string
icon: string
rgb: string
}
// 已知定时服务的移动端视觉配置。
type SchedulerMobileVisualRule = SchedulerMobileVisual & {
ids?: string[]
names?: string[]
providers?: string[]
}
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackground()
@@ -15,18 +29,50 @@ const $toast = useToast()
// 定时服务列表
const schedulerList = ref<ScheduleInfo[]>([])
// 调用API加载定时服务列表
// 移动端任务图标按后端 job id 优先匹配,避免列表顺序变化导致图标看起来随机。
const schedulerMobileVisualRules: SchedulerMobileVisualRule[] = [
{ ids: ['cookiecloud'], names: ['CookieCloud'], icon: 'mdi-cloud-sync-outline', color: '#3f8cff', rgb: '63, 140, 255' },
{ ids: ['mediaserver_sync'], names: ['媒体服务器'], icon: 'mdi-television-play', color: '#42c336', rgb: '66, 195, 54' },
{ ids: ['new_subscribe_search', 'subscribe_search'], names: ['订阅搜索', '新增订阅搜索'], icon: 'mdi-magnify', color: '#e91e63', rgb: '233, 30, 99' },
{ ids: ['subscribe_tmdb'], names: ['订阅元数据'], icon: 'mdi-database-search-outline', color: '#9b6cf3', rgb: '155, 108, 243' },
{ ids: ['subscribe_refresh'], names: ['订阅刷新'], icon: 'mdi-refresh', color: '#25b6c8', rgb: '37, 182, 200' },
{ ids: ['subscribe_follow'], names: ['订阅分享'], icon: 'mdi-share-variant-outline', color: '#ff704d', rgb: '255, 112, 77' },
{ ids: ['transfer'], names: ['下载文件整理', '文件整理'], icon: 'mdi-folder-move-outline', color: '#3f8cff', rgb: '63, 140, 255' },
{ ids: ['random_wallpager'], names: ['壁纸'], icon: 'mdi-image-outline', color: '#9b6cf3', rgb: '155, 108, 243' },
{ ids: ['scheduler_job'], names: ['公共定时服务'], icon: 'mdi-clock-outline', color: '#42c336', rgb: '66, 195, 54' },
{ ids: ['clear_cache'], names: ['缓存清理'], icon: 'mdi-delete-sweep-outline', color: '#ffad1f', rgb: '255, 173, 31' },
{ ids: ['data_cleanup'], names: ['数据表清理'], icon: 'mdi-database-remove-outline', color: '#ff704d', rgb: '255, 112, 77' },
{ ids: ['user_auth'], names: ['用户认证'], icon: 'mdi-account-check-outline', color: '#9b6cf3', rgb: '155, 108, 243' },
{ ids: ['sitedata_refresh'], names: ['站点数据'], icon: 'mdi-web-refresh', color: '#25b6c8', rgb: '37, 182, 200' },
{ ids: ['recommend_refresh'], names: ['推荐缓存'], icon: 'mdi-star-outline', color: '#ffad1f', rgb: '255, 173, 31' },
{ ids: ['plugin_market_refresh'], names: ['插件市场'], icon: 'mdi-puzzle-outline', color: '#ff704d', rgb: '255, 112, 77' },
{ ids: ['subscribe_calendar_cache'], names: ['订阅日历'], icon: 'mdi-calendar-refresh-outline', color: '#3f8cff', rgb: '63, 140, 255' },
{ ids: ['full_gc'], names: ['内存回收'], icon: 'mdi-memory', color: '#25b6c8', rgb: '37, 182, 200' },
{ ids: ['agent_heartbeat'], names: ['智能体'], icon: 'mdi-robot-outline', color: '#9b6cf3', rgb: '155, 108, 243' },
{ ids: ['usage_report'], names: ['统计上报'], icon: 'mdi-chart-line', color: '#42c336', rgb: '66, 195, 54' },
{ ids: ['workflow'], providers: ['工作流'], icon: 'mdi-source-branch', color: '#3f8cff', rgb: '63, 140, 255' },
{ ids: ['plugin'], icon: 'mdi-puzzle-outline', color: '#ff704d', rgb: '255, 112, 77' },
]
// 未知服务使用固定兜底视觉,避免用户误以为图标按列表顺序乱跳。
const schedulerMobileFallbackVisual: SchedulerMobileVisual = {
icon: 'mdi-timer-cog-outline',
color: '#25b6c8',
rgb: '37, 182, 200',
}
/** 调用 API 加载定时服务列表。 */
async function loadSchedulerList() {
try {
const res: ScheduleInfo[] = await api.get('dashboard/schedule')
schedulerList.value = res
schedulerList.value = Array.isArray(res) ? res : []
} catch (e) {
console.log(e)
}
}
// 任务状态颜色
/** 根据任务状态返回桌面端状态标签颜色。 */
function getSchedulerColor(status: string) {
switch (status) {
case t('setting.scheduler.running'):
@@ -40,7 +86,62 @@ function getSchedulerColor(status: string) {
}
}
// 执行命令
/** 根据任务状态返回移动端状态胶囊的语义样式。 */
function getSchedulerStatusVariant(status: string) {
switch (status) {
case t('setting.scheduler.running'):
return 'running'
case t('setting.scheduler.stopped'):
return 'stopped'
case t('setting.scheduler.waiting'):
return 'waiting'
default:
return 'default'
}
}
/** 判断规则列表是否命中指定文本。 */
function hasSchedulerRuleMatch(values: string[] | undefined, target: string) {
if (!values?.length) return false
return values.some(value => target.includes(value.toLocaleLowerCase()))
}
/** 使用后端 job id、服务名和提供者为移动端任务卡片选择图标和主题色。 */
function getMobileSchedulerVisual(scheduler: ScheduleInfo): SchedulerMobileVisual {
const schedulerId = (scheduler.id || '').toLocaleLowerCase()
const schedulerName = (scheduler.name || '').toLocaleLowerCase()
const schedulerProvider = (scheduler.provider || '').toLocaleLowerCase()
const matchedRule = schedulerMobileVisualRules.find(rule => {
const matchedId = hasSchedulerRuleMatch(rule.ids, schedulerId)
const matchedName = hasSchedulerRuleMatch(rule.names, schedulerName)
const matchedProvider = hasSchedulerRuleMatch(rule.providers, schedulerProvider)
return matchedId || matchedName || matchedProvider
})
return matchedRule ?? schedulerMobileFallbackVisual
}
/** 将后端返回的紧凑时间差转换为更适合移动端展示的文本。 */
function formatMobileNextRunTime(nextRun?: string) {
return nextRun?.trim() || ''
}
/** 获取移动端状态胶囊文案;等待状态展示为下次运行倒计时。 */
function getMobileSchedulerStatusText(scheduler: ScheduleInfo) {
if (scheduler.status === t('setting.scheduler.waiting')) {
const readableNextRun = formatMobileNextRunTime(scheduler.next_run)
return readableNextRun
? t('setting.scheduler.mobileWaitingAfter', { time: readableNextRun })
: t('setting.scheduler.mobileNoNextRun')
}
return scheduler.status
}
/** 执行指定定时服务,并在短延迟后刷新列表。 */
function runCommand(id: string) {
try {
// 异步提交
@@ -59,8 +160,18 @@ function runCommand(id: string) {
}
}
// 移动端任务卡片展示模型。
const mobileSchedulerCards = computed(() =>
schedulerList.value.map(scheduler => ({
scheduler,
statusText: getMobileSchedulerStatusText(scheduler),
statusVariant: getSchedulerStatusVariant(scheduler.status),
visual: getMobileSchedulerVisual(scheduler),
})),
)
// 使用数据刷新定时器
useDataRefresh(
const { loading: schedulerLoading } = useDataRefresh(
'scheduler-list',
loadSchedulerList,
5000, // 5秒间隔
@@ -69,8 +180,8 @@ useDataRefresh(
</script>
<template>
<VCard>
<VTable class="text-no-wrap">
<VCard class="d-none d-md-block">
<VTable v-if="schedulerList.length" class="text-no-wrap">
<thead>
<tr>
<th scope="col">{{ t('setting.scheduler.provider') }}</th>
@@ -109,10 +220,272 @@ useDataRefresh(
</VBtn>
</td>
</tr>
<tr v-if="schedulerList.length === 0">
<td colspan="4" class="text-center">{{ t('setting.scheduler.noService') }}</td>
</tr>
</tbody>
</VTable>
<div v-else-if="!schedulerLoading" class="desktop-scheduler-empty">
<VIcon icon="mdi-timer-off-outline" size="48" />
<p>{{ t('setting.scheduler.noService') }}</p>
</div>
<div v-else class="desktop-scheduler-empty">
<VProgressCircular indeterminate color="primary" size="22" width="2" />
<p>{{ t('common.loadingText') }}</p>
</div>
</VCard>
<div class="mobile-scheduler-view d-md-none">
<div v-if="mobileSchedulerCards.length" class="mobile-scheduler-list">
<article
v-for="{ scheduler, visual, statusText, statusVariant } in mobileSchedulerCards"
:key="scheduler.id"
class="mobile-scheduler-card"
:style="{
'--scheduler-accent': visual.color,
'--scheduler-accent-rgb': visual.rgb,
}"
>
<div class="mobile-scheduler-icon">
<VIcon :icon="visual.icon" size="30" />
</div>
<div class="mobile-scheduler-content">
<h3>{{ scheduler.name }}</h3>
<p>{{ scheduler.provider }}</p>
</div>
<div class="mobile-scheduler-actions">
<span class="mobile-scheduler-status" :class="`mobile-scheduler-status--${statusVariant}`">
{{ statusText }}
</span>
<VBtn
icon
class="mobile-scheduler-run-btn"
:aria-label="t('setting.scheduler.execute')"
:disabled="scheduler.status === t('setting.scheduler.running')"
@click="runCommand(scheduler.id)"
>
<VIcon icon="mdi-play" size="24" />
</VBtn>
</div>
</article>
</div>
<div v-else-if="!schedulerLoading" class="mobile-scheduler-empty">
<VIcon icon="mdi-timer-off-outline" size="44" />
<p>{{ t('setting.scheduler.noService') }}</p>
</div>
<footer v-if="schedulerLoading" class="mobile-scheduler-footer">
<div class="mobile-scheduler-loading">
<VProgressCircular indeterminate color="primary" size="18" width="2" />
<span>{{ t('common.loadingText') }}</span>
</div>
</footer>
</div>
</template>
<style scoped>
.desktop-scheduler-empty {
display: flex;
align-items: center;
justify-content: center;
min-block-size: 260px;
color: rgba(var(--v-theme-on-surface), 0.52);
flex-direction: column;
gap: 12px;
}
.desktop-scheduler-empty p {
margin: 0;
font-size: 15px;
}
.mobile-scheduler-view {
min-block-size: 100%;
padding: 12px 18px calc(22px + env(safe-area-inset-bottom));
background: transparent;
}
.mobile-scheduler-list {
display: flex;
flex-direction: column;
gap: 18px;
}
.mobile-scheduler-card {
display: grid;
align-items: center;
padding: 18px;
border: 0;
border-radius: var(--app-surface-radius);
background: rgb(var(--v-theme-surface));
backdrop-filter: none;
box-shadow: none;
column-gap: 14px;
grid-template-columns: 62px minmax(0, 1fr) auto;
}
.mobile-scheduler-icon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--scheduler-accent-rgb), 0.14);
block-size: 58px;
color: var(--scheduler-accent);
inline-size: 58px;
}
.mobile-scheduler-content {
min-inline-size: 0;
}
.mobile-scheduler-content h3 {
overflow: hidden;
margin: 0;
color: rgba(var(--v-theme-on-surface), 0.92);
font-size: 18px;
font-weight: 700;
letter-spacing: 0.01em;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-scheduler-content p {
overflow: hidden;
margin: 6px 0 0;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 14px;
font-weight: 500;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-scheduler-actions {
display: inline-flex;
align-items: center;
gap: 14px;
}
.mobile-scheduler-status {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.mobile-scheduler-status--running {
background: rgba(var(--v-theme-success), 0.14);
color: rgb(var(--v-theme-success));
}
.mobile-scheduler-status--stopped {
background: rgba(var(--v-theme-error), 0.12);
color: rgb(var(--v-theme-error));
}
.mobile-scheduler-run-btn {
border-radius: 50%;
background: linear-gradient(135deg, #ff4f87, #e91e63) !important;
block-size: 46px;
box-shadow: 0 10px 22px rgba(233, 30, 99, 0.28);
color: #fff !important;
inline-size: 46px;
}
.mobile-scheduler-run-btn.v-btn--disabled {
background: rgba(var(--v-theme-on-surface), 0.12) !important;
box-shadow: none;
color: rgba(var(--v-theme-on-surface), 0.42) !important;
}
.mobile-scheduler-empty {
display: flex;
align-items: center;
justify-content: center;
min-block-size: 42vh;
color: rgba(var(--v-theme-on-surface), 0.52);
flex-direction: column;
gap: 12px;
}
.mobile-scheduler-empty p {
margin: 0;
font-size: 15px;
}
.mobile-scheduler-footer {
display: flex;
align-items: center;
justify-content: center;
padding-block: 28px 4px;
color: rgba(var(--v-theme-on-surface), 0.55);
flex-direction: column;
font-size: 14px;
gap: 8px;
}
.mobile-scheduler-loading {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 16px;
font-weight: 600;
gap: 10px;
}
html[data-theme='transparent'] .mobile-scheduler-card,
.v-theme--transparent .mobile-scheduler-card {
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
backdrop-filter: blur(var(--transparent-blur, 10px));
}
@media (max-width: 480px) {
.mobile-scheduler-view {
padding-inline: 14px;
}
.mobile-scheduler-card {
padding: 16px;
column-gap: 12px;
grid-template-columns: 54px minmax(0, 1fr) auto;
}
.mobile-scheduler-icon {
block-size: 50px;
inline-size: 50px;
}
.mobile-scheduler-content h3 {
font-size: 16px;
}
.mobile-scheduler-content p {
font-size: 13px;
}
.mobile-scheduler-actions {
gap: 8px;
}
.mobile-scheduler-status {
padding: 5px 9px;
font-size: 12px;
}
.mobile-scheduler-run-btn {
block-size: 42px;
inline-size: 42px;
}
}
</style>

View File

@@ -91,7 +91,9 @@ useDynamicButton({
<template>
<!-- 页面标题 -->
<VPageContentTitle :title="t('user.management')" />
<div class="d-flex justify-space-between align-center mb-3">
<VPageContentTitle :title="t('user.management')" />
</div>
<div class="card-list-container">
<!-- 加载中提示 -->
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
@@ -127,6 +129,5 @@ useDynamicButton({
/>
</div>
</Teleport>
</div>
</template>