Refactor frontend dialogs and add missing annotations

This commit is contained in:
jxxghp
2026-06-28 12:08:24 +08:00
parent ca800f7ae7
commit 7574719e04
7 changed files with 1018 additions and 36 deletions

View File

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

View File

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

View File

@@ -2214,6 +2214,8 @@ export default {
stopped: 'Stopped',
waiting: 'Waiting',
executeSuccess: 'Scheduled job execution request submitted successfully!',
mobileWaitingAfter: 'In {time}',
mobileNoNextRun: 'No schedule',
},
subscribe: {
basicSettings: 'Basic Settings',
@@ -2265,6 +2267,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',

View File

@@ -2172,6 +2172,8 @@ export default {
stopped: '已停止',
waiting: '等待',
executeSuccess: '定时作业执行请求提交成功!',
mobileWaitingAfter: '{time}之后',
mobileNoNextRun: '暂无排期',
},
subscribe: {
basicSettings: '基础设置',
@@ -2220,6 +2222,7 @@ export default {
filterByTitle: '按标题筛选',
filterBySite: '按站点筛选',
selectSite: '选择站点',
loadingMore: '加载中...',
refresh: '刷新缓存',
deleteSelected: '删除选中',
clearAll: '清空缓存',

View File

@@ -2173,6 +2173,8 @@ export default {
stopped: '已停止',
waiting: '等待',
executeSuccess: '定時作業執行請求提交成功!',
mobileWaitingAfter: '{time}之後',
mobileNoNextRun: '暫無排程',
},
subscribe: {
basicSettings: '基礎設置',
@@ -2221,6 +2223,7 @@ export default {
filterByTitle: '按標題篩選',
filterBySite: '按站點篩選',
selectSite: '選擇站點',
loadingMore: '加載中...',
refresh: '刷新緩存',
deleteSelected: '刪除選中',
clearAll: '清空緩存',

View File

@@ -8,15 +8,24 @@ import { useConfirm } from '@/composables/useConfirm'
import { useGlobalSettingsStore } from '@/stores'
import { usePWA } from '@/composables/usePWA'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
const CacheReidentifyDialog = defineAsyncComponent(() => import('@/components/dialog/CacheReidentifyDialog.vue'))
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
const MOBILE_CACHE_PAGE_SIZE = 20
// 国际化
const { t } = useI18n()
// PWA模式检测
const { appMode } = usePWA()
// 显示器宽度
const display = useDisplay()
const isMobile = computed(() => display.smAndDown.value)
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
@@ -66,18 +75,37 @@ const loading = ref(false)
const currentReidentifyItem = ref<TorrentCacheItem | null>(null)
// 移动端已经追加到虚拟列表的数据条数
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
let reidentifyDialogController: ReturnType<typeof openSharedDialog> | null = null
const tableStyle = computed(() => {
return appMode ? '' : 'height: calc(100vh - 21rem - env(safe-area-inset-bottom)'
})
// 调用API加载缓存数据
// 移动端虚拟列表数据
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
// 移动端是否还有未追加的数据页
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
// 移动端无限滚动组件刷新键
const mobileInfiniteKey = ref(0)
/** 重置移动端分页,让筛选或刷新后的列表从第一页开始展示。 */
function resetMobilePagination() {
mobileVisibleCount.value = MOBILE_CACHE_PAGE_SIZE
mobileInfiniteKey.value++
}
/** 调用 API 加载缓存数据。 */
async function loadCacheData() {
try {
loading.value = true
const res: any = await api.get('torrent/cache')
cacheData.value = res.data
resetMobilePagination()
} catch (e) {
console.log(e)
$toast.error(t('setting.cache.loadFailed'))
@@ -86,7 +114,23 @@ async function loadCacheData() {
}
}
// 清空所有缓存
/** 追加移动端下一页数据,并通过虚拟滚动限制实际渲染节点数量。 */
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
if (loading.value) {
done('ok')
return
}
if (!mobileHasMore.value) {
done('empty')
return
}
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.length)
done(mobileHasMore.value ? 'ok' : 'empty')
}
/** 清空所有缓存。 */
async function clearAllCache() {
const isConfirmed = await createConfirm({
type: 'warn',
@@ -109,7 +153,7 @@ async function clearAllCache() {
}
}
// 刷新缓存
/** 刷新缓存数据。 */
async function refreshCache() {
try {
loading.value = true
@@ -124,7 +168,7 @@ async function refreshCache() {
}
}
// 删除选中的缓存项
/** 删除桌面端表格中选中的缓存项。 */
async function deleteSelectedItems() {
if (selectedItems.value.length === 0) {
$toast.warning(t('setting.cache.selectDeleteWarning'))
@@ -153,7 +197,7 @@ async function deleteSelectedItems() {
}
}
// 删除单个缓存项
/** 删除单个缓存项。 */
async function deleteSingleItem(item: TorrentCacheItem) {
try {
loading.value = true
@@ -173,7 +217,7 @@ async function deleteSingleItem(item: TorrentCacheItem) {
}
}
// 打开重新识别对话框
/** 打开重新识别对话框。 */
function openReidentifyDialog(item: TorrentCacheItem) {
currentReidentifyItem.value = item
reidentifyDialogController?.close()
@@ -197,7 +241,7 @@ function openReidentifyDialog(item: TorrentCacheItem) {
)
}
// 重新识别
/** 执行缓存项重新识别。 */
async function performReidentify(payload: { doubanId?: string; tmdbId?: number } = {}) {
if (!currentReidentifyItem.value) return
@@ -229,11 +273,13 @@ async function performReidentify(payload: { doubanId?: string; tmdbId?: number }
}
}
// 获取媒体类型颜色
/** 获取媒体类型对应的主题颜色。 */
function getMediaTypeColor(type: string): string {
switch (type) {
case 'movie':
case t('setting.cache.mediaType.movie'):
return 'primary'
case 'tv':
case t('setting.cache.mediaType.tv'):
return 'success'
default:
@@ -241,7 +287,27 @@ function getMediaTypeColor(type: string): string {
}
}
// 打开详情页面
/** 生成移动端缓存卡片的稳定渲染键。 */
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 +315,175 @@ 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">
<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__chips">
<VChip v-if="item.media_type" :color="getMediaTypeColor(item.media_type)" size="x-small" variant="tonal">
{{
item.media_type === 'movie'
? t('setting.cache.mediaType.movie')
: item.media_type === 'tv'
? t('setting.cache.mediaType.tv')
: item.media_type
}}
</VChip>
</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 +699,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(18px + 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) 32px;
margin-block-end: 12px;
padding: 14px;
}
.cache-mobile-card__poster {
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__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;
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: 18px;
font-weight: 800;
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: 16px;
font-weight: 600;
}
.cache-mobile-card__chips {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-block-start: 8px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 14px;
font-weight: 600;
gap: 8px;
}
.cache-mobile-card__meta {
display: flex;
align-items: flex-end;
justify-content: space-between;
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 {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.cache-mobile-card__menu {
align-self: center;
color: rgba(var(--v-theme-on-surface), 0.5);
justify-self: end;
}
.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) 28px;
padding: 12px;
}
.cache-mobile-card__poster {
block-size: 96px;
inline-size: 64px;
}
}
</style>

View File

@@ -4,10 +4,26 @@ import api from '@/api'
import type { ScheduleInfo } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground'
import { useTheme } from 'vuetify'
// 移动端任务卡片视觉配置。
type SchedulerMobileVisual = {
color: string
icon: string
rgb: string
}
// 已知定时服务的移动端视觉配置。
type SchedulerMobileVisualRule = SchedulerMobileVisual & {
ids?: string[]
names?: string[]
providers?: string[]
}
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackground()
const theme = useTheme()
// 提示框
const $toast = useToast()
@@ -15,18 +31,54 @@ const $toast = useToast()
// 定时服务列表
const schedulerList = ref<ScheduleInfo[]>([])
// 调用API加载定时服务列表
// 当前是否为透明主题,用于限制透明适配只作用于本组件内部节点。
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
const isDarkTheme = computed(() => theme.current.value.dark && !isTransparentTheme.value)
// 移动端任务图标按后端 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 +92,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 +166,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 +186,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 +226,306 @@ 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"
:class="{ 'scheduler-empty--transparent': isTransparentTheme }"
>
<VIcon icon="mdi-timer-off-outline" size="48" />
<p>{{ t('setting.scheduler.noService') }}</p>
</div>
<div
v-else
class="desktop-scheduler-empty"
:class="{ 'scheduler-empty--transparent': isTransparentTheme }"
>
<VProgressCircular indeterminate color="primary" size="22" width="2" />
<p>{{ t('common.loadingText') }}</p>
</div>
</VCard>
<div
class="mobile-scheduler-view d-md-none"
:class="{
'mobile-scheduler-view--dark': isDarkTheme,
'mobile-scheduler-view--transparent': isTransparentTheme,
}"
>
<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"
:class="{ 'scheduler-empty--transparent': isTransparentTheme }"
>
<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;
}
.scheduler-empty--transparent {
background: transparent;
}
.mobile-scheduler-view {
--scheduler-mobile-card-bg: rgba(var(--v-theme-surface), 0.94);
--scheduler-mobile-card-shadow: none;
--scheduler-mobile-view-bg: transparent;
min-block-size: 100%;
padding: 12px 18px calc(22px + env(safe-area-inset-bottom));
background: var(--scheduler-mobile-view-bg);
}
.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: var(--scheduler-mobile-card-bg);
box-shadow: var(--scheduler-mobile-card-shadow);
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: 6px 14px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 14px;
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;
}
.mobile-scheduler-view--transparent {
--scheduler-mobile-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
--scheduler-mobile-card-shadow: none;
--scheduler-mobile-view-bg: transparent;
}
.mobile-scheduler-view--transparent .mobile-scheduler-card {
backdrop-filter: blur(var(--transparent-blur, 10px));
box-shadow: none;
}
.mobile-scheduler-view--dark {
--scheduler-mobile-card-bg: rgba(var(--v-theme-surface), 0.82);
}
@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: 6px 11px;
font-size: 13px;
}
.mobile-scheduler-run-btn {
block-size: 42px;
inline-size: 42px;
}
}
</style>