mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-03 05:21:41 +08:00
Refactor frontend dialogs and add missing annotations
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '清空缓存',
|
||||
|
||||
@@ -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: '清空緩存',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user