mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-03 05:21:41 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98769a313f | ||
|
|
1c28d98d70 | ||
|
|
7a430b095a | ||
|
|
864af36b1c | ||
|
|
e3fa0b9dae | ||
|
|
ed28832484 | ||
|
|
e692a91113 | ||
|
|
7574719e04 | ||
|
|
ca800f7ae7 | ||
|
|
7d0550825b | ||
|
|
59abda63e3 | ||
|
|
0776bc9fa0 | ||
|
|
8c78f9ca90 | ||
|
|
1889d5d1e3 | ||
|
|
165247b263 | ||
|
|
9a2384bf02 | ||
|
|
71c4f587d3 | ||
|
|
8edbfa0b0e | ||
|
|
ee187a1c75 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.15",
|
||||
"version": "2.13.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -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) {
|
||||
// 负数标记
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface DynamicButtonMenuItem {
|
||||
icon?: string
|
||||
color?: string
|
||||
permission?: UserPermissionKey
|
||||
disabled?: boolean
|
||||
action: () => void
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '批量重新整理',
|
||||
|
||||
@@ -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: '批量重新整理',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user