mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
feat(dashboard): enhance media statistics and memory analytics
- Added monthly media counts (movies, TV shows, episodes) to the AnalyticsMediaStatistic component. - Improved layout and styling for better responsiveness in the dashboard summary. - Updated AnalyticsMemory component to display total and available memory with enhanced visuals. - Refactored AnalyticsScheduler to include transfer queue management and improved task display. - Enhanced AnalyticsSpeed component with a more structured speed overview. - Introduced new components for quick actions and recent imports in the dashboard. - Added system information display with runtime and version details in DashboardSystemInfo. - Created a new composable for shortcut tools to streamline dialog management.
This commit is contained in:
@@ -1000,6 +1000,12 @@ export interface MediaStatistic {
|
|||||||
episode_count: number | null
|
episode_count: number | null
|
||||||
// 用户数量
|
// 用户数量
|
||||||
user_count: number
|
user_count: number
|
||||||
|
// 本月新增电影数量
|
||||||
|
movie_count_month: number
|
||||||
|
// 本月新增电视剧数量
|
||||||
|
tv_count_month: number
|
||||||
|
// 本月新增剧集数量
|
||||||
|
episode_count_month: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后台进程
|
// 后台进程
|
||||||
@@ -1020,6 +1026,18 @@ export interface Process {
|
|||||||
memory: number
|
memory: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仪表板系统摘要
|
||||||
|
export interface DashboardSystemInfo {
|
||||||
|
// 主机名称
|
||||||
|
hostname: string
|
||||||
|
// 操作系统名称
|
||||||
|
operating_system: string
|
||||||
|
// MoviePilot 运行时间,单位秒
|
||||||
|
runtime: number
|
||||||
|
// MoviePilot 后端版本
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
// 下载器信息
|
// 下载器信息
|
||||||
export interface DownloaderInfo {
|
export interface DownloaderInfo {
|
||||||
// 下载速度
|
// 下载速度
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader>
|
|||||||
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
|
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
|
||||||
playing: () => import('@/views/dashboard/MediaServerPlaying.vue'),
|
playing: () => import('@/views/dashboard/MediaServerPlaying.vue'),
|
||||||
latest: () => import('@/views/dashboard/MediaServerLatest.vue'),
|
latest: () => import('@/views/dashboard/MediaServerLatest.vue'),
|
||||||
|
recentImports: () => import('@/views/dashboard/DashboardRecentImports.vue'),
|
||||||
|
quickActions: () => import('@/views/dashboard/DashboardQuickActions.vue'),
|
||||||
|
systemInfo: () => import('@/views/dashboard/DashboardSystemInfo.vue'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const builtInDashboardComponentPromises = new Map<string, Promise<any>>()
|
const builtInDashboardComponentPromises = new Map<string, Promise<any>>()
|
||||||
@@ -74,6 +77,9 @@ const AnalyticsNetwork = createAsyncDashboardComponent('network')
|
|||||||
const MediaServerLibrary = createAsyncDashboardComponent('library')
|
const MediaServerLibrary = createAsyncDashboardComponent('library')
|
||||||
const MediaServerPlaying = createAsyncDashboardComponent('playing')
|
const MediaServerPlaying = createAsyncDashboardComponent('playing')
|
||||||
const MediaServerLatest = createAsyncDashboardComponent('latest')
|
const MediaServerLatest = createAsyncDashboardComponent('latest')
|
||||||
|
const DashboardRecentImports = createAsyncDashboardComponent('recentImports')
|
||||||
|
const DashboardQuickActions = createAsyncDashboardComponent('quickActions')
|
||||||
|
const DashboardSystemInfo = createAsyncDashboardComponent('systemInfo')
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -205,6 +211,9 @@ onUnmounted(() => {
|
|||||||
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
||||||
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||||
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
||||||
|
<DashboardRecentImports v-else-if="config?.id === 'recentImports'" />
|
||||||
|
<DashboardQuickActions v-else-if="config?.id === 'quickActions'" />
|
||||||
|
<DashboardSystemInfo v-else-if="config?.id === 'systemInfo'" :allow-refresh="props.allowRefresh" />
|
||||||
<!-- 插件仪表板 -->
|
<!-- 插件仪表板 -->
|
||||||
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
||||||
<!-- Vue 渲染模式 -->
|
<!-- Vue 渲染模式 -->
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { Component } from 'vue'
|
||||||
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import {
|
||||||
|
buildUserPermissionContext,
|
||||||
|
filterItemsByPermission,
|
||||||
|
hasItemPermission,
|
||||||
|
type PermissionProtectedItem,
|
||||||
|
} from '@/utils/permission'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
||||||
|
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
|
||||||
|
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
|
||||||
|
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.vue'))
|
||||||
|
const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vue'))
|
||||||
|
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||||
|
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||||
|
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
||||||
|
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||||
|
|
||||||
|
export type ShortcutToolItem = PermissionProtectedItem & {
|
||||||
|
bodyClass?: string
|
||||||
|
cardClass?: string
|
||||||
|
component?: Component
|
||||||
|
customDialog?: Component
|
||||||
|
dialog: string
|
||||||
|
dialogSubtitle?: string
|
||||||
|
icon: string
|
||||||
|
maxWidth?: string
|
||||||
|
subtitle: string
|
||||||
|
title: string
|
||||||
|
titleText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提供顶部捷径与仪表板共用的工具定义和打开逻辑。 */
|
||||||
|
export function useShortcutTools() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
|
|
||||||
|
const shortcuts: ShortcutToolItem[] = [
|
||||||
|
{
|
||||||
|
title: t('shortcut.recognition.title'),
|
||||||
|
subtitle: t('shortcut.recognition.subtitle'),
|
||||||
|
icon: 'mdi-text-recognition',
|
||||||
|
dialog: 'nameTest',
|
||||||
|
component: NameTestView,
|
||||||
|
maxWidth: '45rem',
|
||||||
|
titleText: t('shortcut.recognition.title'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('shortcut.rule.title'),
|
||||||
|
subtitle: t('shortcut.rule.subtitle'),
|
||||||
|
icon: 'mdi-filter-cog',
|
||||||
|
dialog: 'ruleTest',
|
||||||
|
component: RuleTestView,
|
||||||
|
titleText: t('shortcut.rule.subtitle'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('shortcut.log.title'),
|
||||||
|
subtitle: t('shortcut.log.subtitle'),
|
||||||
|
icon: 'mdi-file-document',
|
||||||
|
dialog: 'logging',
|
||||||
|
customDialog: ShortcutLogDialog,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('shortcut.network.title'),
|
||||||
|
subtitle: t('shortcut.network.subtitle'),
|
||||||
|
icon: 'mdi-network',
|
||||||
|
dialog: 'netTest',
|
||||||
|
component: NetTestView,
|
||||||
|
titleText: t('shortcut.network.subtitle'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('shortcut.words.title'),
|
||||||
|
subtitle: t('shortcut.words.subtitle'),
|
||||||
|
icon: 'mdi-file-word-box',
|
||||||
|
dialog: 'words',
|
||||||
|
component: WordsView,
|
||||||
|
maxWidth: '60rem',
|
||||||
|
titleText: t('shortcut.words.subtitle'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('shortcut.cache.title'),
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('shortcut.scheduler.title'),
|
||||||
|
subtitle: t('shortcut.scheduler.subtitle'),
|
||||||
|
icon: 'mdi-list-box',
|
||||||
|
dialog: 'scheduler',
|
||||||
|
bodyClass: 'scheduler-shortcut-dialog-body pa-0',
|
||||||
|
cardClass: 'scheduler-shortcut-dialog-card',
|
||||||
|
component: AccountSettingService,
|
||||||
|
maxWidth: '60rem',
|
||||||
|
titleText: t('shortcut.scheduler.subtitle'),
|
||||||
|
dialogSubtitle: t('setting.scheduler.subtitle'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('shortcut.system.title'),
|
||||||
|
subtitle: t('shortcut.system.subtitle'),
|
||||||
|
icon: 'mdi-cog',
|
||||||
|
dialog: 'systemTest',
|
||||||
|
bodyClass: 'system-health-dialog-body pa-0',
|
||||||
|
cardClass: 'system-health-dialog-card',
|
||||||
|
component: ModuleTestView,
|
||||||
|
titleText: t('shortcut.system.subtitle'),
|
||||||
|
},
|
||||||
|
].map(item => ({ ...item, permission: 'admin' }))
|
||||||
|
|
||||||
|
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||||
|
|
||||||
|
/** 打开工具对应的共享弹窗。 */
|
||||||
|
function openShortcutDialog(item: ShortcutToolItem) {
|
||||||
|
if (!hasItemPermission(item, userPermissions.value)) return
|
||||||
|
|
||||||
|
if (item.customDialog) {
|
||||||
|
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.component) return
|
||||||
|
|
||||||
|
openSharedDialog(
|
||||||
|
ShortcutToolDialog,
|
||||||
|
{
|
||||||
|
bodyClass: item.bodyClass,
|
||||||
|
cardClass: item.cardClass,
|
||||||
|
icon: item.icon,
|
||||||
|
maxWidth: item.maxWidth ?? '35rem',
|
||||||
|
subtitle: item.dialogSubtitle,
|
||||||
|
title: item.titleText ?? item.title,
|
||||||
|
view: item.component,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openShortcutDialog,
|
||||||
|
visibleShortcuts,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Component } from 'vue'
|
|
||||||
import { getQueryValue } from '@/@core/utils'
|
import { getQueryValue } from '@/@core/utils'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { useShortcutTools } from '@/composables/useShortcutTools'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUserStore } from '@/stores'
|
|
||||||
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const userStore = useUserStore()
|
const { visibleShortcuts, openShortcutDialog: openShortcutTool } = useShortcutTools()
|
||||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
|
||||||
|
|
||||||
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
|
|
||||||
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
|
||||||
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
|
|
||||||
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
|
|
||||||
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.vue'))
|
|
||||||
const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vue'))
|
|
||||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
|
||||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
|
||||||
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
|
||||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
|
||||||
|
|
||||||
type ShortcutItem = PermissionProtectedItem & {
|
|
||||||
bodyClass?: string
|
|
||||||
cardClass?: string
|
|
||||||
component?: Component
|
|
||||||
customDialog?: Component
|
|
||||||
dialog: string
|
|
||||||
dialogSubtitle?: string
|
|
||||||
icon: string
|
|
||||||
maxWidth?: string
|
|
||||||
subtitle: string
|
|
||||||
title: string
|
|
||||||
titleText?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// App捷径
|
// App捷径
|
||||||
const appsMenu = ref(false)
|
const appsMenu = ref(false)
|
||||||
@@ -42,113 +13,10 @@ const appsMenu = ref(false)
|
|||||||
// 菜单最大宽度
|
// 菜单最大宽度
|
||||||
const menuMaxWidth = ref(420)
|
const menuMaxWidth = ref(420)
|
||||||
|
|
||||||
// 定义捷径列表
|
|
||||||
const shortcuts: ShortcutItem[] = [
|
|
||||||
{
|
|
||||||
title: t('shortcut.recognition.title'),
|
|
||||||
subtitle: t('shortcut.recognition.subtitle'),
|
|
||||||
icon: 'mdi-text-recognition',
|
|
||||||
dialog: 'nameTest',
|
|
||||||
component: NameTestView,
|
|
||||||
maxWidth: '45rem',
|
|
||||||
titleText: t('shortcut.recognition.title'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('shortcut.rule.title'),
|
|
||||||
subtitle: t('shortcut.rule.subtitle'),
|
|
||||||
icon: 'mdi-filter-cog',
|
|
||||||
dialog: 'ruleTest',
|
|
||||||
component: RuleTestView,
|
|
||||||
titleText: t('shortcut.rule.subtitle'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('shortcut.log.title'),
|
|
||||||
subtitle: t('shortcut.log.subtitle'),
|
|
||||||
icon: 'mdi-file-document',
|
|
||||||
dialog: 'logging',
|
|
||||||
customDialog: ShortcutLogDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('shortcut.network.title'),
|
|
||||||
subtitle: t('shortcut.network.subtitle'),
|
|
||||||
icon: 'mdi-network',
|
|
||||||
dialog: 'netTest',
|
|
||||||
component: NetTestView,
|
|
||||||
titleText: t('shortcut.network.subtitle'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('shortcut.words.title'),
|
|
||||||
subtitle: t('shortcut.words.subtitle'),
|
|
||||||
icon: 'mdi-file-word-box',
|
|
||||||
dialog: 'words',
|
|
||||||
component: WordsView,
|
|
||||||
maxWidth: '60rem',
|
|
||||||
titleText: t('shortcut.words.subtitle'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('shortcut.cache.title'),
|
|
||||||
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'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('shortcut.scheduler.title'),
|
|
||||||
subtitle: t('shortcut.scheduler.subtitle'),
|
|
||||||
icon: 'mdi-list-box',
|
|
||||||
dialog: 'scheduler',
|
|
||||||
bodyClass: 'scheduler-shortcut-dialog-body pa-0',
|
|
||||||
cardClass: 'scheduler-shortcut-dialog-card',
|
|
||||||
component: AccountSettingService,
|
|
||||||
maxWidth: '60rem',
|
|
||||||
titleText: t('shortcut.scheduler.subtitle'),
|
|
||||||
dialogSubtitle: t('setting.scheduler.subtitle'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('shortcut.system.title'),
|
|
||||||
subtitle: t('shortcut.system.subtitle'),
|
|
||||||
icon: 'mdi-cog',
|
|
||||||
dialog: 'systemTest',
|
|
||||||
bodyClass: 'system-health-dialog-body pa-0',
|
|
||||||
cardClass: 'system-health-dialog-card',
|
|
||||||
component: ModuleTestView,
|
|
||||||
titleText: t('shortcut.system.subtitle'),
|
|
||||||
},
|
|
||||||
].map(item => ({ ...item, permission: 'admin' }))
|
|
||||||
|
|
||||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
|
||||||
|
|
||||||
/** 打开快捷工具对应的共享弹窗。 */
|
/** 打开快捷工具对应的共享弹窗。 */
|
||||||
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
function openShortcutDialog(item: (typeof visibleShortcuts.value)[number]) {
|
||||||
if (!hasItemPermission(item, userPermissions.value)) return
|
|
||||||
|
|
||||||
appsMenu.value = false
|
appsMenu.value = false
|
||||||
|
openShortcutTool(item)
|
||||||
if (item.customDialog) {
|
|
||||||
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.component) return
|
|
||||||
|
|
||||||
openSharedDialog(
|
|
||||||
ShortcutToolDialog,
|
|
||||||
{
|
|
||||||
bodyClass: item.bodyClass,
|
|
||||||
cardClass: item.cardClass,
|
|
||||||
icon: item.icon,
|
|
||||||
maxWidth: item.maxWidth ?? '35rem',
|
|
||||||
subtitle: item.dialogSubtitle,
|
|
||||||
title: item.titleText ?? item.title,
|
|
||||||
view: item.component,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
{ closeOn: ['close', 'update:modelValue'] },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default {
|
|||||||
error: 'Error',
|
error: 'Error',
|
||||||
openInNewWindow: 'Open in new window',
|
openInNewWindow: 'Open in new window',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
|
uploadSpeed: 'Upload speed',
|
||||||
|
downloadSpeed: 'Download speed',
|
||||||
inputMessage: 'Enter message or command',
|
inputMessage: 'Enter message or command',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
noData: 'No data',
|
noData: 'No data',
|
||||||
@@ -947,18 +949,26 @@ export default {
|
|||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
storage: 'Storage',
|
storage: 'Storage',
|
||||||
|
storageSummary: '{available} available / {total} total',
|
||||||
mediaStatistic: 'Media Statistics',
|
mediaStatistic: 'Media Statistics',
|
||||||
weeklyOverview: 'Recent Imports',
|
weeklyOverview: 'Recent Imports',
|
||||||
realTimeSpeed: 'Real-time Speed',
|
realTimeSpeed: 'Real-time Speed',
|
||||||
scheduler: 'Background Tasks',
|
scheduler: 'Background Tasks',
|
||||||
cpu: 'CPU',
|
cpu: 'CPU',
|
||||||
|
cpuUsage: 'CPU Usage',
|
||||||
memory: 'Memory',
|
memory: 'Memory',
|
||||||
|
memoryUsage: 'Memory Usage',
|
||||||
|
memoryUsed: 'Used',
|
||||||
|
memoryAvailable: 'Available',
|
||||||
|
averageUsage: 'Average',
|
||||||
network: 'Network Traffic',
|
network: 'Network Traffic',
|
||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
library: 'My Media Library',
|
library: 'My Media Library',
|
||||||
playing: 'Continue Watching',
|
playing: 'Continue Watching',
|
||||||
latest: 'Recently Added',
|
latest: 'Recently Added',
|
||||||
|
recentImports: 'Recent Imports',
|
||||||
|
viewAll: 'View All',
|
||||||
settings: 'Dashboard Settings',
|
settings: 'Dashboard Settings',
|
||||||
chooseContent: 'Choose content to display',
|
chooseContent: 'Choose content to display',
|
||||||
editLayout: 'Edit Layout',
|
editLayout: 'Edit Layout',
|
||||||
@@ -968,7 +978,32 @@ export default {
|
|||||||
current: 'Current',
|
current: 'Current',
|
||||||
episodes: 'Episodes',
|
episodes: 'Episodes',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
|
activeUsers: 'Active users',
|
||||||
noSchedulers: 'No Background Services',
|
noSchedulers: 'No Background Services',
|
||||||
|
transferQueue: 'Transfer Queue',
|
||||||
|
transferProgress: '{completed} / {total} files',
|
||||||
|
taskRunning: 'Running',
|
||||||
|
taskWaiting: 'Waiting',
|
||||||
|
noRecentImports: 'No recent import records',
|
||||||
|
monthlyAddition: '+{count} this month',
|
||||||
|
quickActions: {
|
||||||
|
title: 'Quick Actions',
|
||||||
|
downloadManager: 'Downloads',
|
||||||
|
globalSearch: 'Global Search',
|
||||||
|
fileManager: 'Files',
|
||||||
|
systemSettings: 'Settings',
|
||||||
|
cacheManager: 'Cache',
|
||||||
|
pluginCenter: 'Plugins',
|
||||||
|
},
|
||||||
|
systemInfo: {
|
||||||
|
title: 'System Information',
|
||||||
|
hostname: 'Host Name',
|
||||||
|
operatingSystem: 'Operating System',
|
||||||
|
runtime: 'Runtime',
|
||||||
|
runtimeValue: '{days}d {hours}h {minutes}m',
|
||||||
|
version: 'Version',
|
||||||
|
checkUpdate: 'Check Update',
|
||||||
|
},
|
||||||
weeklyOverviewDescription: 'Added {count} media in the last week',
|
weeklyOverviewDescription: 'Added {count} media in the last week',
|
||||||
speed: {
|
speed: {
|
||||||
totalUpload: 'Total Upload',
|
totalUpload: 'Total Upload',
|
||||||
|
|||||||
@@ -943,18 +943,28 @@ export default {
|
|||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
storage: '存储空间',
|
storage: '存储空间',
|
||||||
|
storageSummary: '可用 {available} / 总容量 {total}',
|
||||||
mediaStatistic: '媒体统计',
|
mediaStatistic: '媒体统计',
|
||||||
weeklyOverview: '最近入库',
|
weeklyOverview: '最近入库',
|
||||||
realTimeSpeed: '实时速率',
|
realTimeSpeed: '实时速率',
|
||||||
scheduler: '后台任务',
|
scheduler: '后台任务',
|
||||||
cpu: 'CPU',
|
cpu: 'CPU',
|
||||||
|
cpuUsage: 'CPU 使用率',
|
||||||
memory: '内存',
|
memory: '内存',
|
||||||
|
memoryUsage: '内存使用',
|
||||||
|
memoryUsed: '已使用',
|
||||||
|
memoryAvailable: '可用',
|
||||||
|
averageUsage: '平均使用',
|
||||||
network: '网络流量',
|
network: '网络流量',
|
||||||
upload: '上行',
|
upload: '上行',
|
||||||
download: '下行',
|
download: '下行',
|
||||||
|
uploadSpeed: '上传速度',
|
||||||
|
downloadSpeed: '下载速度',
|
||||||
library: '我的媒体库',
|
library: '我的媒体库',
|
||||||
playing: '继续观看',
|
playing: '继续观看',
|
||||||
latest: '最近添加',
|
latest: '最近添加',
|
||||||
|
recentImports: '最近导入',
|
||||||
|
viewAll: '查看全部',
|
||||||
settings: '设置仪表板',
|
settings: '设置仪表板',
|
||||||
chooseContent: '选择您想在页面显示的内容',
|
chooseContent: '选择您想在页面显示的内容',
|
||||||
editLayout: '编辑布局',
|
editLayout: '编辑布局',
|
||||||
@@ -964,7 +974,32 @@ export default {
|
|||||||
current: '当前',
|
current: '当前',
|
||||||
episodes: '剧集',
|
episodes: '剧集',
|
||||||
users: '用户',
|
users: '用户',
|
||||||
|
activeUsers: '活跃用户',
|
||||||
noSchedulers: '没有后台服务',
|
noSchedulers: '没有后台服务',
|
||||||
|
transferQueue: '整理队列',
|
||||||
|
transferProgress: '{completed} / {total} 个文件',
|
||||||
|
taskRunning: '进行中',
|
||||||
|
taskWaiting: '等待中',
|
||||||
|
noRecentImports: '暂无最近导入记录',
|
||||||
|
monthlyAddition: '+{count} 本月新增',
|
||||||
|
quickActions: {
|
||||||
|
title: '快捷操作',
|
||||||
|
downloadManager: '下载管理',
|
||||||
|
globalSearch: '全局搜索',
|
||||||
|
fileManager: '文件管理',
|
||||||
|
systemSettings: '系统设置',
|
||||||
|
cacheManager: '缓存管理',
|
||||||
|
pluginCenter: '插件中心',
|
||||||
|
},
|
||||||
|
systemInfo: {
|
||||||
|
title: '系统信息',
|
||||||
|
hostname: '主机名称',
|
||||||
|
operatingSystem: '操作系统',
|
||||||
|
runtime: '运行时间',
|
||||||
|
runtimeValue: '{days}天 {hours}小时 {minutes}分',
|
||||||
|
version: '版本',
|
||||||
|
checkUpdate: '检查更新',
|
||||||
|
},
|
||||||
weeklyOverviewDescription: '最近一周入库了 {count} 部影片',
|
weeklyOverviewDescription: '最近一周入库了 {count} 部影片',
|
||||||
speed: {
|
speed: {
|
||||||
totalUpload: '总上传量',
|
totalUpload: '总上传量',
|
||||||
|
|||||||
@@ -943,18 +943,28 @@ export default {
|
|||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
storage: '存儲空間',
|
storage: '存儲空間',
|
||||||
|
storageSummary: '可用 {available} / 總容量 {total}',
|
||||||
mediaStatistic: '媒體統計',
|
mediaStatistic: '媒體統計',
|
||||||
weeklyOverview: '最近入庫',
|
weeklyOverview: '最近入庫',
|
||||||
realTimeSpeed: '實時速率',
|
realTimeSpeed: '實時速率',
|
||||||
scheduler: '後台任務',
|
scheduler: '後台任務',
|
||||||
cpu: 'CPU',
|
cpu: 'CPU',
|
||||||
|
cpuUsage: 'CPU 使用率',
|
||||||
memory: '內存',
|
memory: '內存',
|
||||||
|
memoryUsage: '內存使用',
|
||||||
|
memoryUsed: '已使用',
|
||||||
|
memoryAvailable: '可用',
|
||||||
|
averageUsage: '平均使用',
|
||||||
network: '網絡流量',
|
network: '網絡流量',
|
||||||
upload: '上行',
|
upload: '上行',
|
||||||
download: '下行',
|
download: '下行',
|
||||||
|
uploadSpeed: '上傳速度',
|
||||||
|
downloadSpeed: '下載速度',
|
||||||
library: '我的媒體庫',
|
library: '我的媒體庫',
|
||||||
playing: '繼續觀看',
|
playing: '繼續觀看',
|
||||||
latest: '最近添加',
|
latest: '最近添加',
|
||||||
|
recentImports: '最近導入',
|
||||||
|
viewAll: '查看全部',
|
||||||
settings: '設置儀表板',
|
settings: '設置儀表板',
|
||||||
chooseContent: '選擇您想在頁面顯示的內容',
|
chooseContent: '選擇您想在頁面顯示的內容',
|
||||||
editLayout: '編輯佈局',
|
editLayout: '編輯佈局',
|
||||||
@@ -964,7 +974,32 @@ export default {
|
|||||||
current: '當前',
|
current: '當前',
|
||||||
episodes: '劇集',
|
episodes: '劇集',
|
||||||
users: '用戶',
|
users: '用戶',
|
||||||
|
activeUsers: '活躍用戶',
|
||||||
noSchedulers: '沒有後台服務',
|
noSchedulers: '沒有後台服務',
|
||||||
|
transferQueue: '整理隊列',
|
||||||
|
transferProgress: '{completed} / {total} 個文件',
|
||||||
|
taskRunning: '進行中',
|
||||||
|
taskWaiting: '等待中',
|
||||||
|
noRecentImports: '暫無最近導入記錄',
|
||||||
|
monthlyAddition: '+{count} 本月新增',
|
||||||
|
quickActions: {
|
||||||
|
title: '快捷操作',
|
||||||
|
downloadManager: '下載管理',
|
||||||
|
globalSearch: '全局搜索',
|
||||||
|
fileManager: '文件管理',
|
||||||
|
systemSettings: '系統設置',
|
||||||
|
cacheManager: '緩存管理',
|
||||||
|
pluginCenter: '插件中心',
|
||||||
|
},
|
||||||
|
systemInfo: {
|
||||||
|
title: '系統信息',
|
||||||
|
hostname: '主機名稱',
|
||||||
|
operatingSystem: '操作系統',
|
||||||
|
runtime: '運行時間',
|
||||||
|
runtimeValue: '{days}天 {hours}小時 {minutes}分',
|
||||||
|
version: '版本',
|
||||||
|
checkUpdate: '檢查更新',
|
||||||
|
},
|
||||||
weeklyOverviewDescription: '最近一週入庫了 {count} 部影片',
|
weeklyOverviewDescription: '最近一週入庫了 {count} 部影片',
|
||||||
speed: {
|
speed: {
|
||||||
totalUpload: '總上傳量',
|
totalUpload: '總上傳量',
|
||||||
|
|||||||
+104
-23
@@ -63,6 +63,19 @@ interface DashboardGridLayoutItem {
|
|||||||
h?: number
|
h?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 参考桌面端设计稿定义默认排布;用户保存过的布局仍优先于这里的初始值。
|
||||||
|
const DASHBOARD_DESKTOP_DEFAULT_LAYOUT: DashboardGridLayoutConfig = {
|
||||||
|
storage: { x: 0, y: 0, w: 4, h: 8 },
|
||||||
|
mediaStatistic: { x: 4, y: 0, w: 8, h: 8 },
|
||||||
|
speed: { x: 0, y: 8, w: 4, h: 15 },
|
||||||
|
recentImports: { x: 4, y: 8, w: 4, h: 15 },
|
||||||
|
scheduler: { x: 8, y: 8, w: 4, h: 15 },
|
||||||
|
memory: { x: 0, y: 23, w: 4, h: 11 },
|
||||||
|
cpu: { x: 4, y: 23, w: 4, h: 11 },
|
||||||
|
quickActions: { x: 8, y: 23, w: 4, h: 5 },
|
||||||
|
systemInfo: { x: 8, y: 28, w: 4, h: 6 },
|
||||||
|
}
|
||||||
|
|
||||||
// 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。
|
// 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。
|
||||||
interface DashboardProfileConfig {
|
interface DashboardProfileConfig {
|
||||||
enabled?: DashboardEnableConfig
|
enabled?: DashboardEnableConfig
|
||||||
@@ -139,7 +152,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 4 },
|
cols: { cols: 12, md: 4 },
|
||||||
rows: 9,
|
rows: 8,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -148,7 +161,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 8 },
|
cols: { cols: 12, md: 8 },
|
||||||
rows: 11,
|
rows: 8,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -166,7 +179,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 4 },
|
cols: { cols: 12, md: 4 },
|
||||||
rows: 23,
|
rows: 15,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -175,7 +188,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 4 },
|
cols: { cols: 12, md: 4 },
|
||||||
rows: 23,
|
rows: 15,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -183,8 +196,8 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
name: t('dashboard.cpu'),
|
name: t('dashboard.cpu'),
|
||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 6 },
|
cols: { cols: 12, sm: 3, md: 4 },
|
||||||
rows: 17,
|
rows: 11,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -192,8 +205,8 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
name: t('dashboard.memory'),
|
name: t('dashboard.memory'),
|
||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 6 },
|
cols: { cols: 12, sm: 3, md: 4 },
|
||||||
rows: 17,
|
rows: 11,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -229,6 +242,33 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
cols: { cols: 12 },
|
cols: { cols: 12 },
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'recentImports',
|
||||||
|
name: t('dashboard.recentImports'),
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, sm: 2, md: 4 },
|
||||||
|
rows: 15,
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quickActions',
|
||||||
|
name: t('dashboard.quickActions.title'),
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, sm: 3, md: 4 },
|
||||||
|
rows: 5,
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'systemInfo',
|
||||||
|
name: t('dashboard.systemInfo.title'),
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, sm: 3, md: 4 },
|
||||||
|
rows: 6,
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
// 插件的仪表板元信息
|
// 插件的仪表板元信息
|
||||||
@@ -334,16 +374,27 @@ function clampGridNumber(value: unknown, min: number, max: number, fallback: num
|
|||||||
function getDefaultDashboardEnableConfig(): DashboardEnableConfig {
|
function getDefaultDashboardEnableConfig(): DashboardEnableConfig {
|
||||||
return {
|
return {
|
||||||
mediaStatistic: true,
|
mediaStatistic: true,
|
||||||
scheduler: false,
|
scheduler: true,
|
||||||
speed: false,
|
speed: true,
|
||||||
storage: true,
|
storage: true,
|
||||||
weeklyOverview: false,
|
weeklyOverview: false,
|
||||||
cpu: false,
|
cpu: true,
|
||||||
memory: false,
|
memory: true,
|
||||||
network: false,
|
network: false,
|
||||||
library: true,
|
library: false,
|
||||||
playing: true,
|
playing: false,
|
||||||
latest: true,
|
latest: false,
|
||||||
|
recentImports: true,
|
||||||
|
quickActions: true,
|
||||||
|
systemInfo: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用默认开关补齐旧配置中新出现的组件,同时保留用户已有选择。
|
||||||
|
function mergeDashboardEnableConfig(config?: DashboardEnableConfig): DashboardEnableConfig {
|
||||||
|
return {
|
||||||
|
...getDefaultDashboardEnableConfig(),
|
||||||
|
...config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +676,13 @@ function saveDashboardGridLayout(layout: DashboardGridLayoutConfig) {
|
|||||||
|
|
||||||
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
|
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
|
||||||
function getDefaultDashboardGridWidth(item: DashboardItem) {
|
function getDefaultDashboardGridWidth(item: DashboardItem) {
|
||||||
return clampGridNumber(item.cols?.md ?? item.cols?.cols, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
|
const profile = dashboardLayoutProfile.value
|
||||||
|
if (profile === 'mobile') return 1
|
||||||
|
|
||||||
|
const columns = getDashboardGridColumnsForProfile(profile)
|
||||||
|
const requestedWidth = profile === 'tablet' ? item.cols?.sm ?? item.cols?.md : item.cols?.md ?? item.cols?.cols
|
||||||
|
|
||||||
|
return clampGridNumber(requestedWidth, 1, columns, columns)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取仪表板组件测量前的兜底高度,兼容未来 rows 字段和插件 attrs.rows。
|
// 获取仪表板组件测量前的兜底高度,兼容未来 rows 字段和插件 attrs.rows。
|
||||||
@@ -636,9 +693,10 @@ function getDefaultDashboardGridRows(item?: DashboardItem) {
|
|||||||
// 合并插件/内置组件默认尺寸与用户本地布局覆盖。
|
// 合并插件/内置组件默认尺寸与用户本地布局覆盖。
|
||||||
function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWidget {
|
function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWidget {
|
||||||
const savedLayout = dashboardGridLayout.value[id]
|
const savedLayout = dashboardGridLayout.value[id]
|
||||||
|
const defaultLayout = dashboardLayoutProfile.value === 'desktop' ? DASHBOARD_DESKTOP_DEFAULT_LAYOUT[id] : undefined
|
||||||
const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
||||||
const width = savedLayout?.w ?? getDefaultDashboardGridWidth(item)
|
const width = savedLayout?.w ?? defaultLayout?.w ?? getDefaultDashboardGridWidth(item)
|
||||||
const height = savedLayout?.h ?? getDefaultDashboardGridRows(item)
|
const height = savedLayout?.h ?? defaultLayout?.h ?? getDefaultDashboardGridRows(item)
|
||||||
const normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns)
|
const normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns)
|
||||||
const widget: GridStackWidget = {
|
const widget: GridStackWidget = {
|
||||||
id,
|
id,
|
||||||
@@ -648,9 +706,11 @@ function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWid
|
|||||||
minH: 1,
|
minH: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedLayout?.x !== undefined && savedLayout?.y !== undefined) {
|
const x = savedLayout?.x ?? defaultLayout?.x
|
||||||
widget.x = clampGridNumber(savedLayout.x, 0, gridColumns - normalizedWidth, 0)
|
const y = savedLayout?.y ?? defaultLayout?.y
|
||||||
widget.y = clampGridNumber(savedLayout.y, 0, 999, 0)
|
if (x !== undefined && y !== undefined) {
|
||||||
|
widget.x = clampGridNumber(x, 0, gridColumns - normalizedWidth, 0)
|
||||||
|
widget.y = clampGridNumber(y, 0, 999, 0)
|
||||||
} else {
|
} else {
|
||||||
widget.autoPosition = true
|
widget.autoPosition = true
|
||||||
}
|
}
|
||||||
@@ -808,7 +868,7 @@ async function loadDashboardConfig() {
|
|||||||
const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value)
|
const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value)
|
||||||
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
||||||
dashboardGridLayout.value = profileConfig?.items ?? {}
|
dashboardGridLayout.value = profileConfig?.items ?? {}
|
||||||
enableConfig.value = profileConfig?.enabled ?? legacyEnable ?? getDefaultDashboardEnableConfig()
|
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
|
||||||
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
||||||
saveDashboardProfileConfig()
|
saveDashboardProfileConfig()
|
||||||
}
|
}
|
||||||
@@ -1282,7 +1342,7 @@ watch(
|
|||||||
const profileConfig = await loadDashboardProfileConfig(nextProfile)
|
const profileConfig = await loadDashboardProfileConfig(nextProfile)
|
||||||
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
||||||
dashboardGridLayout.value = profileConfig?.items ?? {}
|
dashboardGridLayout.value = profileConfig?.items ?? {}
|
||||||
enableConfig.value = profileConfig?.enabled ?? legacyEnable ?? getDefaultDashboardEnableConfig()
|
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
|
||||||
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
||||||
saveDashboardProfileConfig()
|
saveDashboardProfileConfig()
|
||||||
}
|
}
|
||||||
@@ -1420,10 +1480,31 @@ onBeforeUnmount(() => {
|
|||||||
will-change: opacity, transform;
|
will-change: opacity, transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-grid :deep(.v-card) {
|
||||||
|
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.72));
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 3px 14px rgba(15, 23, 42, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid :deep(.v-card-title) {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 媒体卡片上浮 4px 时保留安全区,避免被标题栏或滚动容器顶边裁切。 */
|
||||||
|
.dashboard-grid :deep(.dashboard-media-content) {
|
||||||
|
padding-block-start: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-grid-item.is-manual-height :deep(.v-card) {
|
.dashboard-grid-item.is-manual-height :deep(.v-card) {
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-grid-item.is-manual-height :deep(.dashboard-work-card),
|
||||||
|
.dashboard-grid.is-editing :deep(.dashboard-work-card) {
|
||||||
|
max-block-size: none;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-grid-item-content {
|
.dashboard-grid-item-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,14 +45,31 @@ const animatedCurrent = useAnimatedDashboardNumber(current, {
|
|||||||
})
|
})
|
||||||
const animatedCurrentText = computed(() => Math.round(animatedCurrent.value).toLocaleString())
|
const animatedCurrentText = computed(() => Math.round(animatedCurrent.value).toLocaleString())
|
||||||
|
|
||||||
|
/** 计算指定采样窗口内的平均 CPU 使用率。 */
|
||||||
|
function getAverageUsage(sampleCount: number) {
|
||||||
|
const samples = series.value[0].data.slice(-sampleCount)
|
||||||
|
if (!samples.length) return '0.0'
|
||||||
|
|
||||||
|
return (samples.reduce((total, value) => total + value, 0) / samples.length).toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageUsages = computed(() => [
|
||||||
|
{ label: '1m', value: getAverageUsage(3) },
|
||||||
|
{ label: '5m', value: getAverageUsage(10) },
|
||||||
|
{ label: '15m', value: getAverageUsage(30) },
|
||||||
|
])
|
||||||
|
|
||||||
const chartOptions = controlledComputed(
|
const chartOptions = controlledComputed(
|
||||||
() => vuetifyTheme.name.value,
|
() => vuetifyTheme.name.value,
|
||||||
() => {
|
() => {
|
||||||
|
const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chart: {
|
chart: {
|
||||||
parentHeightOffset: 0,
|
parentHeightOffset: 0,
|
||||||
toolbar: { show: false },
|
toolbar: { show: false },
|
||||||
animations: { enabled: false },
|
animations: { enabled: false },
|
||||||
|
foreColor: axisLabelColor,
|
||||||
},
|
},
|
||||||
tooltip: { enabled: false },
|
tooltip: { enabled: false },
|
||||||
grid: {
|
grid: {
|
||||||
@@ -102,8 +119,17 @@ const chartOptions = controlledComputed(
|
|||||||
axisBorder: { show: false },
|
axisBorder: { show: false },
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
labels: { show: false },
|
labels: {
|
||||||
|
show: true,
|
||||||
|
formatter: (value: number) => `${Math.round(value)}%`,
|
||||||
|
style: {
|
||||||
|
colors: axisLabelColor,
|
||||||
|
fontSize: '10px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tickAmount: 2,
|
||||||
max: 100,
|
max: 100,
|
||||||
|
min: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -140,15 +166,18 @@ useKeepAliveRefresh(refresh)
|
|||||||
<template>
|
<template>
|
||||||
<VCard class="dashboard-chart-card dashboard-grid-fill">
|
<VCard class="dashboard-chart-card dashboard-grid-fill">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VCardTitle>CPU</VCardTitle>
|
<template #prepend><VIcon icon="mdi-cpu-64-bit" size="20" class="me-2" /></template>
|
||||||
|
<VCardTitle>{{ t('dashboard.cpuUsage') }}</VCardTitle>
|
||||||
|
<template #append><strong class="dashboard-chart-current">{{ animatedCurrentText }}%</strong></template>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardText class="dashboard-chart-content">
|
<VCardText class="dashboard-chart-content">
|
||||||
<div class="dashboard-chart-plot">
|
<div class="dashboard-chart-plot">
|
||||||
<VApexChart type="line" :options="chartOptions" :series="series" height="100%" />
|
<VApexChart type="line" :options="chartOptions" :series="series" height="100%" />
|
||||||
</div>
|
</div>
|
||||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
<div class="dashboard-chart-footer">
|
||||||
{{ t('dashboard.current') }}:{{ animatedCurrentText }}%
|
<span>{{ t('dashboard.averageUsage') }}</span>
|
||||||
</p>
|
<span v-for="item in averageUsages" :key="item.label"><strong>{{ item.value }}</strong> {{ item.label }}</span>
|
||||||
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
@@ -158,7 +187,7 @@ useKeepAliveRefresh(refresh)
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
min-block-size: 0;
|
min-block-size: 270px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-chart-content {
|
.dashboard-chart-content {
|
||||||
@@ -171,11 +200,26 @@ useKeepAliveRefresh(refresh)
|
|||||||
|
|
||||||
.dashboard-chart-plot {
|
.dashboard-chart-plot {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
min-block-size: 135px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-chart-value {
|
.dashboard-chart-current,
|
||||||
|
.dashboard-chart-footer strong {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-current {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.68rem;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding-block-start: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const movieCount = ref(0)
|
|||||||
const tvCount = ref(0)
|
const tvCount = ref(0)
|
||||||
const episodeCount = ref<number | null>(null)
|
const episodeCount = ref<number | null>(null)
|
||||||
const userCount = ref(0)
|
const userCount = ref(0)
|
||||||
|
const movieCountMonth = ref(0)
|
||||||
|
const tvCountMonth = ref(0)
|
||||||
|
const episodeCountMonth = ref(0)
|
||||||
|
|
||||||
const animatedMovieCount = useAnimatedDashboardNumber(movieCount, {
|
const animatedMovieCount = useAnimatedDashboardNumber(movieCount, {
|
||||||
duration: 720,
|
duration: 720,
|
||||||
@@ -37,24 +40,28 @@ const statistics = computed(() => [
|
|||||||
stats: formatDashboardCount(animatedMovieCount.value),
|
stats: formatDashboardCount(animatedMovieCount.value),
|
||||||
icon: 'mdi-movie-roll',
|
icon: 'mdi-movie-roll',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
|
addition: movieCountMonth.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('mediaType.tv'),
|
title: t('mediaType.tv'),
|
||||||
stats: formatDashboardCount(animatedTvCount.value),
|
stats: formatDashboardCount(animatedTvCount.value),
|
||||||
icon: 'mdi-television-box',
|
icon: 'mdi-television-box',
|
||||||
color: 'success',
|
color: 'success',
|
||||||
|
addition: tvCountMonth.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('dashboard.episodes'),
|
title: t('dashboard.episodes'),
|
||||||
stats: episodeCount.value == null ? t('common.notFetched') : formatDashboardCount(animatedEpisodeCount.value),
|
stats: episodeCount.value == null ? t('common.notFetched') : formatDashboardCount(animatedEpisodeCount.value),
|
||||||
icon: 'mdi-television-classic',
|
icon: 'mdi-television-classic',
|
||||||
color: 'warning',
|
color: 'warning',
|
||||||
|
addition: episodeCountMonth.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('dashboard.users'),
|
title: t('dashboard.users'),
|
||||||
stats: formatDashboardCount(animatedUserCount.value),
|
stats: formatDashboardCount(animatedUserCount.value),
|
||||||
icon: 'mdi-account',
|
icon: 'mdi-account',
|
||||||
color: 'info',
|
color: 'info',
|
||||||
|
addition: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -67,6 +74,9 @@ async function loadMediaStatistic() {
|
|||||||
tvCount.value = Number(res.tv_count) || 0
|
tvCount.value = Number(res.tv_count) || 0
|
||||||
episodeCount.value = res.episode_count == null ? null : Number(res.episode_count) || 0
|
episodeCount.value = res.episode_count == null ? null : Number(res.episode_count) || 0
|
||||||
userCount.value = Number(res.user_count) || 0
|
userCount.value = Number(res.user_count) || 0
|
||||||
|
movieCountMonth.value = Number(res.movie_count_month) || 0
|
||||||
|
tvCountMonth.value = Number(res.tv_count_month) || 0
|
||||||
|
episodeCountMonth.value = Number(res.episode_count_month) || 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
@@ -88,24 +98,21 @@ onActivated(() => {
|
|||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<VCardText class="dashboard-summary-content">
|
<VCardText class="dashboard-summary-content">
|
||||||
<VRow>
|
<div class="dashboard-stat-grid">
|
||||||
<VCol v-for="item in statistics" :key="item.title" cols="6" sm="3">
|
<div v-for="item in statistics" :key="item.title" class="dashboard-stat-item">
|
||||||
<div class="d-flex align-center">
|
<VAvatar :color="item.color" size="46" class="dashboard-stat-icon">
|
||||||
<div class="me-3">
|
<VIcon size="24" :icon="item.icon" />
|
||||||
<VAvatar :color="item.color" rounded size="42" class="elevation-1">
|
</VAvatar>
|
||||||
<VIcon size="24" :icon="item.icon" />
|
<div class="dashboard-stat-copy">
|
||||||
</VAvatar>
|
<span class="dashboard-stat-label">{{ item.title }}</span>
|
||||||
</div>
|
<span class="dashboard-number">{{ item.stats }}</span>
|
||||||
|
<span v-if="item.addition !== null" class="dashboard-stat-addition">
|
||||||
<div class="d-flex flex-column">
|
{{ t('dashboard.monthlyAddition', { count: item.addition }) }}
|
||||||
<span class="text-caption">
|
</span>
|
||||||
{{ item.title }}
|
<span v-else class="dashboard-stat-addition text-medium-emphasis">{{ t('dashboard.activeUsers') }}</span>
|
||||||
</span>
|
|
||||||
<span class="dashboard-number text-h6">{{ item.stats }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</VCol>
|
</div>
|
||||||
</VRow>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
@@ -115,7 +122,7 @@ onActivated(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
min-block-size: 0;
|
min-block-size: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-summary-content {
|
.dashboard-summary-content {
|
||||||
@@ -125,12 +132,71 @@ onActivated(() => {
|
|||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-summary-content :deep(.v-row) {
|
.dashboard-stat-grid {
|
||||||
|
display: grid;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-item {
|
||||||
|
display: flex;
|
||||||
|
min-inline-size: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding-inline: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-item:first-child {
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-item + .dashboard-stat-item {
|
||||||
|
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-copy {
|
||||||
|
display: flex;
|
||||||
|
min-inline-size: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-label,
|
||||||
|
.dashboard-stat-addition {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-label {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-number {
|
.dashboard-number {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-addition {
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgb(var(--v-theme-success));
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 740px) {
|
||||||
|
.dashboard-stat-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
row-gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-item {
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-item:nth-child(odd) {
|
||||||
|
border-inline-start: 0;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,15 +46,22 @@ const animatedUsedMemory = useAnimatedDashboardNumber(usedMemory, {
|
|||||||
duration: 650,
|
duration: 650,
|
||||||
})
|
})
|
||||||
const animatedUsedMemoryText = computed(() => formatDashboardFileSize(animatedUsedMemory.value, 2, usedMemory.value))
|
const animatedUsedMemoryText = computed(() => formatDashboardFileSize(animatedUsedMemory.value, 2, usedMemory.value))
|
||||||
|
const totalMemory = computed(() => (memoryUsage.value > 0 ? usedMemory.value / (memoryUsage.value / 100) : 0))
|
||||||
|
const availableMemory = computed(() => Math.max(0, totalMemory.value - usedMemory.value))
|
||||||
|
const totalMemoryText = computed(() => formatDashboardFileSize(totalMemory.value, 2, totalMemory.value))
|
||||||
|
const availableMemoryText = computed(() => formatDashboardFileSize(availableMemory.value, 2, availableMemory.value))
|
||||||
|
|
||||||
const chartOptions = controlledComputed(
|
const chartOptions = controlledComputed(
|
||||||
() => vuetifyTheme.name.value,
|
() => vuetifyTheme.name.value,
|
||||||
() => {
|
() => {
|
||||||
|
const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chart: {
|
chart: {
|
||||||
parentHeightOffset: 0,
|
parentHeightOffset: 0,
|
||||||
toolbar: { show: false },
|
toolbar: { show: false },
|
||||||
animations: { enabled: false },
|
animations: { enabled: false },
|
||||||
|
foreColor: axisLabelColor,
|
||||||
},
|
},
|
||||||
tooltip: { enabled: false },
|
tooltip: { enabled: false },
|
||||||
grid: {
|
grid: {
|
||||||
@@ -107,8 +114,17 @@ const chartOptions = controlledComputed(
|
|||||||
axisBorder: { show: false },
|
axisBorder: { show: false },
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
labels: { show: false },
|
labels: {
|
||||||
|
show: true,
|
||||||
|
formatter: (value: number) => `${Math.round(value)}%`,
|
||||||
|
style: {
|
||||||
|
colors: axisLabelColor,
|
||||||
|
fontSize: '10px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tickAmount: 2,
|
||||||
max: 100,
|
max: 100,
|
||||||
|
min: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -146,15 +162,22 @@ useKeepAliveRefresh(refresh)
|
|||||||
<template>
|
<template>
|
||||||
<VCard class="dashboard-chart-card dashboard-grid-fill">
|
<VCard class="dashboard-chart-card dashboard-grid-fill">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>
|
<template #prepend><VIcon icon="mdi-memory" size="20" class="me-2" /></template>
|
||||||
|
<VCardTitle>{{ t('dashboard.memoryUsage') }}</VCardTitle>
|
||||||
|
<template #append><strong class="dashboard-chart-current">{{ memoryUsage.toFixed(1) }}%</strong></template>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardText class="dashboard-chart-content">
|
<VCardText class="dashboard-chart-content">
|
||||||
|
<div class="dashboard-memory-value">
|
||||||
|
<strong>{{ animatedUsedMemoryText }}</strong>
|
||||||
|
<span>/ {{ totalMemoryText }}</span>
|
||||||
|
</div>
|
||||||
<div class="dashboard-chart-plot">
|
<div class="dashboard-chart-plot">
|
||||||
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
|
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
|
||||||
</div>
|
</div>
|
||||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
<div class="dashboard-chart-footer">
|
||||||
{{ t('dashboard.current') }}:{{ animatedUsedMemoryText }}
|
<span><i class="memory-dot memory-dot--used" />{{ t('dashboard.memoryUsed') }} {{ animatedUsedMemoryText }}</span>
|
||||||
</p>
|
<span><i class="memory-dot memory-dot--available" />{{ t('dashboard.memoryAvailable') }} {{ availableMemoryText }}</span>
|
||||||
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
@@ -164,7 +187,7 @@ useKeepAliveRefresh(refresh)
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
min-block-size: 0;
|
min-block-size: 270px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-chart-content {
|
.dashboard-chart-content {
|
||||||
@@ -177,10 +200,57 @@ useKeepAliveRefresh(refresh)
|
|||||||
|
|
||||||
.dashboard-chart-plot {
|
.dashboard-chart-plot {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
min-block-size: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-chart-value {
|
.dashboard-chart-current,
|
||||||
|
.dashboard-memory-value strong,
|
||||||
|
.dashboard-chart-footer {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-current {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-memory-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-block-end: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-memory-value strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-memory-value span,
|
||||||
|
.dashboard-chart-footer {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding-block-start: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-dot {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 50%;
|
||||||
|
block-size: 6px;
|
||||||
|
inline-size: 6px;
|
||||||
|
margin-inline-end: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-dot--used {
|
||||||
|
background: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-dot--available {
|
||||||
|
background: rgb(var(--v-theme-info));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { ScheduleInfo } from '@/api/types'
|
import type { ScheduleInfo, TransferQueue } from '@/api/types'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBackground } from '@/composables/useBackground'
|
import { useBackground } from '@/composables/useBackground'
|
||||||
|
|
||||||
@@ -19,6 +19,49 @@ const props = defineProps({
|
|||||||
|
|
||||||
// 定时服务列表
|
// 定时服务列表
|
||||||
const schedulerList = ref<ScheduleInfo[]>([])
|
const schedulerList = ref<ScheduleInfo[]>([])
|
||||||
|
const transferQueue = ref<TransferQueue[]>([])
|
||||||
|
|
||||||
|
interface BackgroundTaskItem {
|
||||||
|
color: string
|
||||||
|
icon: string
|
||||||
|
id: string
|
||||||
|
progress?: number
|
||||||
|
status: string
|
||||||
|
subtitle: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将正在运行的服务和整理队列排在前面,再补充最近即将执行的定时任务。
|
||||||
|
const backgroundTasks = computed<BackgroundTaskItem[]>(() => {
|
||||||
|
const runningSchedulers = schedulerList.value.filter(item => item.status === '正在运行')
|
||||||
|
const waitingSchedulers = schedulerList.value.filter(item => item.status !== '正在运行')
|
||||||
|
const schedulerTasks = [...runningSchedulers, ...waitingSchedulers].map(item => ({
|
||||||
|
id: `schedule-${item.id}`,
|
||||||
|
title: item.name || t('dashboard.scheduler'),
|
||||||
|
subtitle: item.provider || item.next_run || '',
|
||||||
|
status: item.status || t('dashboard.taskWaiting'),
|
||||||
|
icon: item.status === '正在运行' ? 'mdi-progress-clock' : 'mdi-clock-outline',
|
||||||
|
color: item.status === '正在运行' ? 'primary' : 'info',
|
||||||
|
progress: item.status === '正在运行' ? undefined : 0,
|
||||||
|
}))
|
||||||
|
const transferTasks = transferQueue.value.map((item, index) => {
|
||||||
|
const tasks = item.tasks ?? []
|
||||||
|
const completed = tasks.filter(task => task.state === 'completed').length
|
||||||
|
const progress = tasks.length ? Math.round((completed / tasks.length) * 100) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `transfer-${item.media?.tmdb_id ?? index}-${item.season ?? ''}`,
|
||||||
|
title: item.media?.title_year || item.media?.title || t('dashboard.transferQueue'),
|
||||||
|
subtitle: t('dashboard.transferProgress', { completed, total: tasks.length }),
|
||||||
|
status: tasks.some(task => task.state === 'running') ? t('dashboard.taskRunning') : t('dashboard.taskWaiting'),
|
||||||
|
icon: 'mdi-folder-sync-outline',
|
||||||
|
color: 'warning',
|
||||||
|
progress,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...transferTasks, ...schedulerTasks]
|
||||||
|
})
|
||||||
|
|
||||||
// 调用API加载定时服务列表
|
// 调用API加载定时服务列表
|
||||||
async function loadSchedulerList() {
|
async function loadSchedulerList() {
|
||||||
@@ -26,9 +69,12 @@ async function loadSchedulerList() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res: ScheduleInfo[] = await api.get('dashboard/schedule')
|
const [schedulers, queue] = await Promise.all([
|
||||||
|
api.get('dashboard/schedule'),
|
||||||
schedulerList.value = res
|
api.get('transfer/queue'),
|
||||||
|
])
|
||||||
|
schedulerList.value = schedulers as unknown as ScheduleInfo[]
|
||||||
|
transferQueue.value = queue as unknown as TransferQueue[]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
@@ -38,7 +84,7 @@ async function loadSchedulerList() {
|
|||||||
useDataRefresh(
|
useDataRefresh(
|
||||||
'dashboard-scheduler',
|
'dashboard-scheduler',
|
||||||
loadSchedulerList,
|
loadSchedulerList,
|
||||||
60000, // 60秒间隔
|
10000, // 10秒间隔,及时反映整理队列和运行状态
|
||||||
true // 立即执行
|
true // 立即执行
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -47,34 +93,41 @@ useDataRefresh(
|
|||||||
<VCard class="dashboard-work-card dashboard-grid-fill">
|
<VCard class="dashboard-work-card dashboard-grid-fill">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>
|
||||||
|
<template #append>
|
||||||
|
<VBtn size="small" variant="outlined" to="/history">{{ t('dashboard.viewAll') }}</VBtn>
|
||||||
|
</template>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<VCardText class="dashboard-work-content">
|
<VCardText class="dashboard-work-content">
|
||||||
<VList class="card-list">
|
<VList class="card-list">
|
||||||
<VListItem v-for="item in schedulerList" :key="item.id">
|
<VListItem v-for="item in backgroundTasks" :key="item.id" class="background-task-item">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VAvatar size="40" variant="tonal" color="" class="me-3">
|
<VAvatar size="38" variant="tonal" :color="item.color" class="me-3">
|
||||||
{{ item.name[0] }}
|
<VIcon :icon="item.icon" size="20" />
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<VListItemTitle class="mb-1">
|
<VListItemTitle class="background-task-title">
|
||||||
<span class="text-sm font-weight-medium">{{ item.name }}</span>
|
{{ item.title }}
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle class="background-task-subtitle">
|
||||||
<VListItemSubtitle class="text-xs">
|
{{ item.subtitle }}
|
||||||
{{ item.next_run }}
|
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
|
<VProgressLinear
|
||||||
|
v-if="item.progress !== undefined"
|
||||||
|
:model-value="item.progress"
|
||||||
|
:color="item.color"
|
||||||
|
height="2"
|
||||||
|
rounded
|
||||||
|
class="mt-2"
|
||||||
|
/>
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<div>
|
<span class="background-task-status">{{ item.status }}</span>
|
||||||
<h4 class="font-weight-medium">
|
<VIcon icon="mdi-check-circle" :color="item.progress === 100 ? 'success' : item.color" size="15" />
|
||||||
{{ item.status }}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem v-if="schedulerList.length === 0">
|
<VListItem v-if="backgroundTasks.length === 0">
|
||||||
<VListItemTitle class="text-center"> {{ t('dashboard.noSchedulers') }} </VListItemTitle>
|
<VListItemTitle class="text-center"> {{ t('dashboard.noSchedulers') }} </VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
@@ -87,15 +140,42 @@ useDataRefresh(
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
min-block-size: 0;
|
max-block-size: 350px;
|
||||||
|
min-block-size: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-list {
|
.card-list {
|
||||||
--v-card-list-gap: 1.5rem;
|
--v-card-list-gap: 0.2rem;
|
||||||
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
overflow: auto;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-task-item {
|
||||||
|
min-block-size: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-task-item + .background-task-item {
|
||||||
|
border-block-start: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-task-title {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-task-subtitle,
|
||||||
|
.background-task-status {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-task-status {
|
||||||
|
margin-inline-end: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-work-content {
|
.dashboard-work-content {
|
||||||
|
|||||||
@@ -117,12 +117,21 @@ const { loading } = useDataRefresh(
|
|||||||
<VCardTitle>{{ t('dashboard.realTimeSpeed') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.realTimeSpeed') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<VCardText class="dashboard-work-content pt-4">
|
<VCardText class="dashboard-work-content">
|
||||||
<div>
|
<div class="dashboard-speed-overview">
|
||||||
<p class="dashboard-speed-number text-h5 me-2">↑{{ uploadSpeedText }}</p>
|
<div class="dashboard-speed-rate">
|
||||||
<p class="dashboard-speed-number text-h4 me-2">↓{{ downloadSpeedText }}</p>
|
<VIcon icon="mdi-arrow-up" color="primary" size="26" />
|
||||||
|
<strong class="dashboard-speed-number">{{ uploadSpeedText }}</strong>
|
||||||
|
<span>{{ t('dashboard.uploadSpeed') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-speed-rate">
|
||||||
|
<VIcon icon="mdi-arrow-down" color="primary" size="26" />
|
||||||
|
<strong class="dashboard-speed-number">{{ downloadSpeedText }}</strong>
|
||||||
|
<span>{{ t('dashboard.downloadSpeed') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VList class="card-list mt-9">
|
<VDivider class="my-3" />
|
||||||
|
<VList class="card-list">
|
||||||
<VListItem v-for="item in infoItems" :key="item.title">
|
<VListItem v-for="item in infoItems" :key="item.title">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon rounded :icon="item.avatar" />
|
<VIcon rounded :icon="item.avatar" />
|
||||||
@@ -150,11 +159,11 @@ const { loading } = useDataRefresh(
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
min-block-size: 0;
|
min-block-size: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-list {
|
.card-list {
|
||||||
--v-card-list-gap: 1rem;
|
--v-card-list-gap: 0.15rem;
|
||||||
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
@@ -173,4 +182,26 @@ const { loading } = useDataRefresh(
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-speed-overview {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-speed-rate {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-speed-rate strong {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-speed-rate span {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTheme } from 'vuetify'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Storage } from '@/api/types'
|
import type { Storage } from '@/api/types'
|
||||||
import trophy from '@images/misc/storage.png'
|
import storageImage from '@images/misc/storage.png'
|
||||||
import triangleDark from '@images/misc/triangle-dark.png'
|
|
||||||
import triangleLight from '@images/misc/triangle-light.png'
|
|
||||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { global } = useTheme()
|
|
||||||
|
|
||||||
const triangleBg = computed(() => (global.name.value === 'light' ? triangleLight : triangleDark))
|
|
||||||
|
|
||||||
// 总存储空间
|
// 总存储空间
|
||||||
const storage = ref(0)
|
const storage = ref(0)
|
||||||
|
|
||||||
@@ -31,6 +24,10 @@ const usedPercent = computed(() => {
|
|||||||
const animatedStorage = useAnimatedDashboardNumber(storage, {
|
const animatedStorage = useAnimatedDashboardNumber(storage, {
|
||||||
duration: 900,
|
duration: 900,
|
||||||
})
|
})
|
||||||
|
const animatedUsed = useAnimatedDashboardNumber(used, {
|
||||||
|
delay: 60,
|
||||||
|
duration: 820,
|
||||||
|
})
|
||||||
|
|
||||||
const animatedUsedPercent = useAnimatedDashboardNumber(usedPercent, {
|
const animatedUsedPercent = useAnimatedDashboardNumber(usedPercent, {
|
||||||
delay: 80,
|
delay: 80,
|
||||||
@@ -38,6 +35,9 @@ const animatedUsedPercent = useAnimatedDashboardNumber(usedPercent, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const animatedStorageText = computed(() => formatDashboardFileSize(animatedStorage.value, 2, storage.value))
|
const animatedStorageText = computed(() => formatDashboardFileSize(animatedStorage.value, 2, storage.value))
|
||||||
|
const animatedUsedText = computed(() => formatDashboardFileSize(animatedUsed.value, 2, used.value))
|
||||||
|
const available = computed(() => Math.max(0, storage.value - used.value))
|
||||||
|
const availableText = computed(() => formatDashboardFileSize(available.value, 2, available.value))
|
||||||
const animatedUsedPercentValue = computed(() => Math.round(animatedUsedPercent.value * 10) / 10)
|
const animatedUsedPercentValue = computed(() => Math.round(animatedUsedPercent.value * 10) / 10)
|
||||||
const animatedUsedPercentText = computed(() => animatedUsedPercentValue.value.toFixed(1))
|
const animatedUsedPercentText = computed(() => animatedUsedPercentValue.value.toFixed(1))
|
||||||
|
|
||||||
@@ -64,16 +64,14 @@ onActivated(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard class="dashboard-summary-card dashboard-grid-fill">
|
<VCard class="dashboard-summary-card dashboard-grid-fill">
|
||||||
<!-- Triangle Background -->
|
|
||||||
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
|
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardText class="dashboard-summary-content">
|
<VCardText class="dashboard-summary-content">
|
||||||
<h5 class="animated-storage-value font-weight-medium text-primary">
|
<h5 class="animated-storage-value">
|
||||||
{{ animatedStorageText }}
|
{{ animatedStorageText }}
|
||||||
</h5>
|
</h5>
|
||||||
<div class="animated-storage-meta">{{ t('storage.usedPercent', { percent: animatedUsedPercentText }) }} 🚀</div>
|
<div class="animated-storage-meta">{{ t('storage.usedPercent', { percent: animatedUsedPercentText }) }}</div>
|
||||||
<div class="animated-storage-progress-wrap">
|
<div class="animated-storage-progress-wrap">
|
||||||
<VProgressLinear
|
<VProgressLinear
|
||||||
:model-value="animatedUsedPercentValue"
|
:model-value="animatedUsedPercentValue"
|
||||||
@@ -83,27 +81,23 @@ onActivated(() => {
|
|||||||
rounded
|
rounded
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="animated-storage-caption">
|
||||||
|
{{ t('dashboard.storageSummary', { available: availableText, total: animatedStorageText, used: animatedUsedText }) }}
|
||||||
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<!-- Trophy -->
|
<VImg :src="storageImage" class="storage-image" />
|
||||||
<VImg :src="trophy" class="trophy" />
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use '@layouts/styles/mixins' as layoutsMixins;
|
@use '@layouts/styles/mixins' as layoutsMixins;
|
||||||
|
|
||||||
.v-card .triangle-bg {
|
.v-card .storage-image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: clamp(7rem, 36%, 8.75rem);
|
inline-size: clamp(4.6rem, 22%, 5.8rem);
|
||||||
inset-block-end: 0;
|
filter: hue-rotate(225deg) saturate(0.72);
|
||||||
inset-inline-end: 0;
|
inset-block-start: 2.4rem;
|
||||||
}
|
inset-inline-end: 1.5rem;
|
||||||
|
|
||||||
.v-card .trophy {
|
|
||||||
position: absolute;
|
|
||||||
inline-size: clamp(3.75rem, 18%, 4.5rem);
|
|
||||||
inset-block-end: 2.75rem;
|
|
||||||
inset-inline-end: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-summary-card {
|
.dashboard-summary-card {
|
||||||
@@ -111,18 +105,21 @@ onActivated(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
min-block-size: 0;
|
min-block-size: 190px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-summary-content {
|
.dashboard-summary-content {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
padding-block: 0.25rem 1rem;
|
padding-block: 0.1rem 0.85rem;
|
||||||
|
padding-inline-end: 7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animated-storage-value {
|
.animated-storage-value {
|
||||||
font-size: clamp(1.375rem, 1.8vw, 1.5rem);
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: clamp(1.65rem, 2vw, 1.9rem);
|
||||||
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -135,11 +132,18 @@ onActivated(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.animated-storage-progress-wrap {
|
.animated-storage-progress-wrap {
|
||||||
margin-block-start: 0.35rem;
|
margin-block-start: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animated-storage-progress {
|
.animated-storage-progress {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animated-storage-caption {
|
||||||
|
margin-block-start: 0.45rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.68rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const options = controlledComputed(
|
|||||||
const variableTheme = ref(vuetifyTheme.current.value.variables)
|
const variableTheme = ref(vuetifyTheme.current.value.variables)
|
||||||
|
|
||||||
const disabledColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${
|
const disabledColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${
|
||||||
variableTheme.value['disabled-opacity']
|
variableTheme.value['medium-emphasis-opacity']
|
||||||
})`
|
})`
|
||||||
|
|
||||||
const borderColor = `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
|
const borderColor = `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useShortcutTools } from '@/composables/useShortcutTools'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { visibleShortcuts, openShortcutDialog } = useShortcutTools()
|
||||||
|
const actionColors = ['primary', 'success', 'warning', 'info', 'secondary', 'error', 'teal', 'primary']
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard class="dashboard-quick-card dashboard-grid-fill">
|
||||||
|
<VCardItem class="dashboard-quick-heading">
|
||||||
|
<VCardTitle>{{ t('dashboard.quickActions.title') }}</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VCardText class="dashboard-quick-actions">
|
||||||
|
<button
|
||||||
|
v-for="(action, index) in visibleShortcuts"
|
||||||
|
:key="action.dialog"
|
||||||
|
type="button"
|
||||||
|
class="dashboard-quick-action dashboard-grid-no-drag"
|
||||||
|
@click="openShortcutDialog(action)"
|
||||||
|
>
|
||||||
|
<VAvatar :color="actionColors[index]" variant="tonal" rounded="lg" size="38">
|
||||||
|
<VIcon :icon="action.icon" size="23" />
|
||||||
|
</VAvatar>
|
||||||
|
<span>{{ action.title }}</span>
|
||||||
|
</button>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-quick-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-heading {
|
||||||
|
padding-block: 0.8rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-actions {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-block: 0.35rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action {
|
||||||
|
display: flex;
|
||||||
|
min-inline-size: 0;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.1rem;
|
||||||
|
transition: background-color 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action:hover,
|
||||||
|
.dashboard-quick-action:focus-visible {
|
||||||
|
background: rgba(var(--v-theme-primary), 0.06);
|
||||||
|
outline: none;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action span {
|
||||||
|
overflow: hidden;
|
||||||
|
inline-size: 100%;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 740px) {
|
||||||
|
.dashboard-quick-actions { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import api from '@/api'
|
||||||
|
import type { TransferHistory } from '@/api/types'
|
||||||
|
import noImage from '@images/no-image.jpeg'
|
||||||
|
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 最近成功整理入库的记录。
|
||||||
|
const recentImports = ref<TransferHistory[]>([])
|
||||||
|
|
||||||
|
/** 查询最近成功整理的媒体记录。 */
|
||||||
|
async function loadRecentImports() {
|
||||||
|
try {
|
||||||
|
const response: { data?: { list?: TransferHistory[] } } = await api.get('history/transfer', {
|
||||||
|
params: { page: 1, count: 4, status: true },
|
||||||
|
})
|
||||||
|
recentImports.value = response.data?.list ?? []
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回经过后端图片代理的海报地址。 */
|
||||||
|
function getPosterUrl(item: TransferHistory) {
|
||||||
|
if (!item.image) return noImage
|
||||||
|
|
||||||
|
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(item.image)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组合媒体类型、季集和文件大小作为记录副标题。 */
|
||||||
|
function getImportMeta(item: TransferHistory) {
|
||||||
|
const values = [item.type, item.seasons, item.episodes]
|
||||||
|
const fileSize = Number(item.src_fileitem?.size ?? 0)
|
||||||
|
if (fileSize > 0) values.push(formatFileSize(fileSize))
|
||||||
|
|
||||||
|
return values.filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadRecentImports)
|
||||||
|
onActivated(loadRecentImports)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard class="dashboard-list-card dashboard-grid-fill">
|
||||||
|
<VCardItem class="dashboard-card-heading">
|
||||||
|
<VCardTitle>{{ t('dashboard.recentImports') }}</VCardTitle>
|
||||||
|
<template #append>
|
||||||
|
<VBtn size="small" variant="outlined" to="/history">{{ t('dashboard.viewAll') }}</VBtn>
|
||||||
|
</template>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
|
<VCardText class="recent-import-list">
|
||||||
|
<div v-for="item in recentImports" :key="item.id" class="recent-import-item">
|
||||||
|
<VImg :src="getPosterUrl(item)" :alt="item.title" class="recent-import-poster" cover />
|
||||||
|
<div class="recent-import-copy">
|
||||||
|
<div class="recent-import-title">{{ item.title }}<span v-if="item.year"> ({{ item.year }})</span></div>
|
||||||
|
<div class="recent-import-meta">{{ getImportMeta(item) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recent-import-time">
|
||||||
|
{{ item.date ? formatDateDifference(item.date) : '' }}
|
||||||
|
<VIcon icon="mdi-check-circle" color="success" size="16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recentImports.length === 0" class="recent-import-empty text-medium-emphasis">
|
||||||
|
<VIcon icon="mdi-movie-open-plus-outline" size="32" />
|
||||||
|
<span>{{ t('dashboard.noRecentImports') }}</span>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-list-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
block-size: 100%;
|
||||||
|
min-block-size: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card-heading {
|
||||||
|
padding-block: 0.9rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-list {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-block-size: 0;
|
||||||
|
padding-block-start: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-block-size: 62px;
|
||||||
|
padding-block: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-item + .recent-import-item {
|
||||||
|
border-block-start: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-poster {
|
||||||
|
border-radius: 6px;
|
||||||
|
block-size: 54px;
|
||||||
|
inline-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-copy {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-meta,
|
||||||
|
.recent-import-time {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-meta {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-block-start: 0.2rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-import-empty {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import api from '@/api'
|
||||||
|
import type { DashboardSystemInfo } from '@/api/types'
|
||||||
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useBackground } from '@/composables/useBackground'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 是否允许刷新数据
|
||||||
|
allowRefresh: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { useDataRefresh } = useBackground()
|
||||||
|
|
||||||
|
// 系统摘要与本地运行时间校准点。
|
||||||
|
const systemInfo = ref<DashboardSystemInfo | null>(null)
|
||||||
|
const runtimeSyncedAt = ref(Date.now())
|
||||||
|
const runtimeNow = ref(Date.now())
|
||||||
|
let runtimeTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const displayedRuntime = computed(() => {
|
||||||
|
if (!systemInfo.value) return 0
|
||||||
|
|
||||||
|
return systemInfo.value.runtime + Math.floor((runtimeNow.value - runtimeSyncedAt.value) / 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 查询系统摘要并重新校准本地运行时间。 */
|
||||||
|
async function loadSystemInfo() {
|
||||||
|
if (!props.allowRefresh) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
systemInfo.value = await api.get('dashboard/system')
|
||||||
|
runtimeSyncedAt.value = Date.now()
|
||||||
|
runtimeNow.value = runtimeSyncedAt.value
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将运行秒数压缩为天、小时、分钟三级文本。 */
|
||||||
|
function formatRuntime(totalSeconds: number) {
|
||||||
|
const days = Math.floor(totalSeconds / 86400)
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
|
||||||
|
return t('dashboard.systemInfo.runtimeValue', { days, hours, minutes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开关于页复用现有版本检查能力。 */
|
||||||
|
function openVersionDetails() {
|
||||||
|
openSharedDialog(AboutDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
useDataRefresh('dashboard-system-info', loadSystemInfo, 60000, true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
runtimeTimer = setInterval(() => {
|
||||||
|
runtimeNow.value = Date.now()
|
||||||
|
}, 60000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (runtimeTimer) clearInterval(runtimeTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard class="dashboard-system-card dashboard-grid-fill">
|
||||||
|
<VCardItem class="dashboard-system-heading">
|
||||||
|
<VCardTitle>{{ t('dashboard.systemInfo.title') }}</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VCardText class="dashboard-system-content">
|
||||||
|
<dl class="dashboard-system-grid">
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('dashboard.systemInfo.hostname') }}</dt>
|
||||||
|
<dd>{{ systemInfo?.hostname || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('dashboard.systemInfo.operatingSystem') }}</dt>
|
||||||
|
<dd>{{ systemInfo?.operating_system || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('dashboard.systemInfo.runtime') }}</dt>
|
||||||
|
<dd>{{ systemInfo ? formatRuntime(displayedRuntime) : '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="dashboard-system-footer">
|
||||||
|
<span>{{ t('dashboard.systemInfo.version') }}</span>
|
||||||
|
<strong>{{ systemInfo?.version || '—' }}</strong>
|
||||||
|
<VBtn size="small" variant="text" color="primary" class="dashboard-grid-no-drag" @click="openVersionDetails">
|
||||||
|
{{ t('dashboard.systemInfo.checkUpdate') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-system-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-heading {
|
||||||
|
padding-block: 0.8rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-block: 0.35rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.8fr 1.25fr 1fr;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-grid > div {
|
||||||
|
min-inline-size: 0;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-grid > div:first-child {
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-grid > div + div {
|
||||||
|
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-grid dt,
|
||||||
|
.dashboard-system-footer > span {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-grid dd {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-footer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-block-start: auto;
|
||||||
|
padding-block-start: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-footer strong {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 740px) {
|
||||||
|
.dashboard-system-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-grid > div {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-system-grid > div + div {
|
||||||
|
border-inline-start: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user