mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-14 08:53:31 +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
|
||||
// 用户数量
|
||||
user_count: number
|
||||
// 本月新增电影数量
|
||||
movie_count_month: number
|
||||
// 本月新增电视剧数量
|
||||
tv_count_month: number
|
||||
// 本月新增剧集数量
|
||||
episode_count_month: number
|
||||
}
|
||||
|
||||
// 后台进程
|
||||
@@ -1020,6 +1026,18 @@ export interface Process {
|
||||
memory: number
|
||||
}
|
||||
|
||||
// 仪表板系统摘要
|
||||
export interface DashboardSystemInfo {
|
||||
// 主机名称
|
||||
hostname: string
|
||||
// 操作系统名称
|
||||
operating_system: string
|
||||
// MoviePilot 运行时间,单位秒
|
||||
runtime: number
|
||||
// MoviePilot 后端版本
|
||||
version: string
|
||||
}
|
||||
|
||||
// 下载器信息
|
||||
export interface DownloaderInfo {
|
||||
// 下载速度
|
||||
|
||||
@@ -33,6 +33,9 @@ const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader>
|
||||
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
|
||||
playing: () => import('@/views/dashboard/MediaServerPlaying.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>>()
|
||||
@@ -74,6 +77,9 @@ const AnalyticsNetwork = createAsyncDashboardComponent('network')
|
||||
const MediaServerLibrary = createAsyncDashboardComponent('library')
|
||||
const MediaServerPlaying = createAsyncDashboardComponent('playing')
|
||||
const MediaServerLatest = createAsyncDashboardComponent('latest')
|
||||
const DashboardRecentImports = createAsyncDashboardComponent('recentImports')
|
||||
const DashboardQuickActions = createAsyncDashboardComponent('quickActions')
|
||||
const DashboardSystemInfo = createAsyncDashboardComponent('systemInfo')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -205,6 +211,9 @@ onUnmounted(() => {
|
||||
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
||||
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||
<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)">
|
||||
<!-- Vue 渲染模式 -->
|
||||
|
||||
152
src/composables/useShortcutTools.ts
Normal file
152
src/composables/useShortcutTools.ts
Normal file
@@ -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>
|
||||
import type { Component } from 'vue'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useShortcutTools } from '@/composables/useShortcutTools'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
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
|
||||
}
|
||||
const { visibleShortcuts, openShortcutDialog: openShortcutTool } = useShortcutTools()
|
||||
|
||||
// App捷径
|
||||
const appsMenu = ref(false)
|
||||
@@ -42,113 +13,10 @@ const appsMenu = ref(false)
|
||||
// 菜单最大宽度
|
||||
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]) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
function openShortcutDialog(item: (typeof visibleShortcuts.value)[number]) {
|
||||
appsMenu.value = false
|
||||
|
||||
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'] },
|
||||
)
|
||||
openShortcutTool(item)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -15,6 +15,8 @@ export default {
|
||||
error: 'Error',
|
||||
openInNewWindow: 'Open in new window',
|
||||
download: 'Download',
|
||||
uploadSpeed: 'Upload speed',
|
||||
downloadSpeed: 'Download speed',
|
||||
inputMessage: 'Enter message or command',
|
||||
send: 'Send',
|
||||
noData: 'No data',
|
||||
@@ -947,18 +949,26 @@ export default {
|
||||
},
|
||||
dashboard: {
|
||||
storage: 'Storage',
|
||||
storageSummary: '{available} available / {total} total',
|
||||
mediaStatistic: 'Media Statistics',
|
||||
weeklyOverview: 'Recent Imports',
|
||||
realTimeSpeed: 'Real-time Speed',
|
||||
scheduler: 'Background Tasks',
|
||||
cpu: 'CPU',
|
||||
cpuUsage: 'CPU Usage',
|
||||
memory: 'Memory',
|
||||
memoryUsage: 'Memory Usage',
|
||||
memoryUsed: 'Used',
|
||||
memoryAvailable: 'Available',
|
||||
averageUsage: 'Average',
|
||||
network: 'Network Traffic',
|
||||
upload: 'Upload',
|
||||
download: 'Download',
|
||||
library: 'My Media Library',
|
||||
playing: 'Continue Watching',
|
||||
latest: 'Recently Added',
|
||||
recentImports: 'Recent Imports',
|
||||
viewAll: 'View All',
|
||||
settings: 'Dashboard Settings',
|
||||
chooseContent: 'Choose content to display',
|
||||
editLayout: 'Edit Layout',
|
||||
@@ -968,7 +978,32 @@ export default {
|
||||
current: 'Current',
|
||||
episodes: 'Episodes',
|
||||
users: 'Users',
|
||||
activeUsers: 'Active users',
|
||||
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',
|
||||
speed: {
|
||||
totalUpload: 'Total Upload',
|
||||
|
||||
@@ -943,18 +943,28 @@ export default {
|
||||
},
|
||||
dashboard: {
|
||||
storage: '存储空间',
|
||||
storageSummary: '可用 {available} / 总容量 {total}',
|
||||
mediaStatistic: '媒体统计',
|
||||
weeklyOverview: '最近入库',
|
||||
realTimeSpeed: '实时速率',
|
||||
scheduler: '后台任务',
|
||||
cpu: 'CPU',
|
||||
cpuUsage: 'CPU 使用率',
|
||||
memory: '内存',
|
||||
memoryUsage: '内存使用',
|
||||
memoryUsed: '已使用',
|
||||
memoryAvailable: '可用',
|
||||
averageUsage: '平均使用',
|
||||
network: '网络流量',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
uploadSpeed: '上传速度',
|
||||
downloadSpeed: '下载速度',
|
||||
library: '我的媒体库',
|
||||
playing: '继续观看',
|
||||
latest: '最近添加',
|
||||
recentImports: '最近导入',
|
||||
viewAll: '查看全部',
|
||||
settings: '设置仪表板',
|
||||
chooseContent: '选择您想在页面显示的内容',
|
||||
editLayout: '编辑布局',
|
||||
@@ -964,7 +974,32 @@ export default {
|
||||
current: '当前',
|
||||
episodes: '剧集',
|
||||
users: '用户',
|
||||
activeUsers: '活跃用户',
|
||||
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} 部影片',
|
||||
speed: {
|
||||
totalUpload: '总上传量',
|
||||
|
||||
@@ -943,18 +943,28 @@ export default {
|
||||
},
|
||||
dashboard: {
|
||||
storage: '存儲空間',
|
||||
storageSummary: '可用 {available} / 總容量 {total}',
|
||||
mediaStatistic: '媒體統計',
|
||||
weeklyOverview: '最近入庫',
|
||||
realTimeSpeed: '實時速率',
|
||||
scheduler: '後台任務',
|
||||
cpu: 'CPU',
|
||||
cpuUsage: 'CPU 使用率',
|
||||
memory: '內存',
|
||||
memoryUsage: '內存使用',
|
||||
memoryUsed: '已使用',
|
||||
memoryAvailable: '可用',
|
||||
averageUsage: '平均使用',
|
||||
network: '網絡流量',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
uploadSpeed: '上傳速度',
|
||||
downloadSpeed: '下載速度',
|
||||
library: '我的媒體庫',
|
||||
playing: '繼續觀看',
|
||||
latest: '最近添加',
|
||||
recentImports: '最近導入',
|
||||
viewAll: '查看全部',
|
||||
settings: '設置儀表板',
|
||||
chooseContent: '選擇您想在頁面顯示的內容',
|
||||
editLayout: '編輯佈局',
|
||||
@@ -964,7 +974,32 @@ export default {
|
||||
current: '當前',
|
||||
episodes: '劇集',
|
||||
users: '用戶',
|
||||
activeUsers: '活躍用戶',
|
||||
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} 部影片',
|
||||
speed: {
|
||||
totalUpload: '總上傳量',
|
||||
|
||||
@@ -63,6 +63,19 @@ interface DashboardGridLayoutItem {
|
||||
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 {
|
||||
enabled?: DashboardEnableConfig
|
||||
@@ -139,7 +152,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 4 },
|
||||
rows: 9,
|
||||
rows: 8,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -148,7 +161,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 8 },
|
||||
rows: 11,
|
||||
rows: 8,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -166,7 +179,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 4 },
|
||||
rows: 23,
|
||||
rows: 15,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -175,7 +188,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 4 },
|
||||
rows: 23,
|
||||
rows: 15,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -183,8 +196,8 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
name: t('dashboard.cpu'),
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 6 },
|
||||
rows: 17,
|
||||
cols: { cols: 12, sm: 3, md: 4 },
|
||||
rows: 11,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -192,8 +205,8 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
name: t('dashboard.memory'),
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 6 },
|
||||
rows: 17,
|
||||
cols: { cols: 12, sm: 3, md: 4 },
|
||||
rows: 11,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -229,6 +242,33 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
cols: { cols: 12 },
|
||||
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 {
|
||||
return {
|
||||
mediaStatistic: true,
|
||||
scheduler: false,
|
||||
speed: false,
|
||||
scheduler: true,
|
||||
speed: true,
|
||||
storage: true,
|
||||
weeklyOverview: false,
|
||||
cpu: false,
|
||||
memory: false,
|
||||
cpu: true,
|
||||
memory: true,
|
||||
network: false,
|
||||
library: true,
|
||||
playing: true,
|
||||
latest: true,
|
||||
library: false,
|
||||
playing: false,
|
||||
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 配置。
|
||||
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。
|
||||
@@ -636,9 +693,10 @@ function getDefaultDashboardGridRows(item?: DashboardItem) {
|
||||
// 合并插件/内置组件默认尺寸与用户本地布局覆盖。
|
||||
function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWidget {
|
||||
const savedLayout = dashboardGridLayout.value[id]
|
||||
const defaultLayout = dashboardLayoutProfile.value === 'desktop' ? DASHBOARD_DESKTOP_DEFAULT_LAYOUT[id] : undefined
|
||||
const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
||||
const width = savedLayout?.w ?? getDefaultDashboardGridWidth(item)
|
||||
const height = savedLayout?.h ?? getDefaultDashboardGridRows(item)
|
||||
const width = savedLayout?.w ?? defaultLayout?.w ?? getDefaultDashboardGridWidth(item)
|
||||
const height = savedLayout?.h ?? defaultLayout?.h ?? getDefaultDashboardGridRows(item)
|
||||
const normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns)
|
||||
const widget: GridStackWidget = {
|
||||
id,
|
||||
@@ -648,9 +706,11 @@ function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWid
|
||||
minH: 1,
|
||||
}
|
||||
|
||||
if (savedLayout?.x !== undefined && savedLayout?.y !== undefined) {
|
||||
widget.x = clampGridNumber(savedLayout.x, 0, gridColumns - normalizedWidth, 0)
|
||||
widget.y = clampGridNumber(savedLayout.y, 0, 999, 0)
|
||||
const x = savedLayout?.x ?? defaultLayout?.x
|
||||
const y = savedLayout?.y ?? defaultLayout?.y
|
||||
if (x !== undefined && y !== undefined) {
|
||||
widget.x = clampGridNumber(x, 0, gridColumns - normalizedWidth, 0)
|
||||
widget.y = clampGridNumber(y, 0, 999, 0)
|
||||
} else {
|
||||
widget.autoPosition = true
|
||||
}
|
||||
@@ -808,7 +868,7 @@ async function loadDashboardConfig() {
|
||||
const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value)
|
||||
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
||||
dashboardGridLayout.value = profileConfig?.items ?? {}
|
||||
enableConfig.value = profileConfig?.enabled ?? legacyEnable ?? getDefaultDashboardEnableConfig()
|
||||
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
|
||||
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
||||
saveDashboardProfileConfig()
|
||||
}
|
||||
@@ -1282,7 +1342,7 @@ watch(
|
||||
const profileConfig = await loadDashboardProfileConfig(nextProfile)
|
||||
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
||||
dashboardGridLayout.value = profileConfig?.items ?? {}
|
||||
enableConfig.value = profileConfig?.enabled ?? legacyEnable ?? getDefaultDashboardEnableConfig()
|
||||
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
|
||||
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
||||
saveDashboardProfileConfig()
|
||||
}
|
||||
@@ -1420,10 +1480,31 @@ onBeforeUnmount(() => {
|
||||
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) {
|
||||
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 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -45,14 +45,31 @@ const animatedCurrent = useAnimatedDashboardNumber(current, {
|
||||
})
|
||||
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(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => {
|
||||
const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})`
|
||||
|
||||
return {
|
||||
chart: {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: false },
|
||||
foreColor: axisLabelColor,
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
grid: {
|
||||
@@ -102,8 +119,17 @@ const chartOptions = controlledComputed(
|
||||
axisBorder: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
labels: { show: false },
|
||||
labels: {
|
||||
show: true,
|
||||
formatter: (value: number) => `${Math.round(value)}%`,
|
||||
style: {
|
||||
colors: axisLabelColor,
|
||||
fontSize: '10px',
|
||||
},
|
||||
},
|
||||
tickAmount: 2,
|
||||
max: 100,
|
||||
min: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -140,15 +166,18 @@ useKeepAliveRefresh(refresh)
|
||||
<template>
|
||||
<VCard class="dashboard-chart-card dashboard-grid-fill">
|
||||
<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>
|
||||
<VCardText class="dashboard-chart-content">
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="line" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
{{ t('dashboard.current') }}:{{ animatedCurrentText }}%
|
||||
</p>
|
||||
<div class="dashboard-chart-footer">
|
||||
<span>{{ t('dashboard.averageUsage') }}</span>
|
||||
<span v-for="item in averageUsages" :key="item.label"><strong>{{ item.value }}</strong> {{ item.label }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -158,7 +187,7 @@ useKeepAliveRefresh(refresh)
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
min-block-size: 270px;
|
||||
}
|
||||
|
||||
.dashboard-chart-content {
|
||||
@@ -171,11 +200,26 @@ useKeepAliveRefresh(refresh)
|
||||
|
||||
.dashboard-chart-plot {
|
||||
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;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -11,6 +11,9 @@ const movieCount = ref(0)
|
||||
const tvCount = ref(0)
|
||||
const episodeCount = ref<number | null>(null)
|
||||
const userCount = ref(0)
|
||||
const movieCountMonth = ref(0)
|
||||
const tvCountMonth = ref(0)
|
||||
const episodeCountMonth = ref(0)
|
||||
|
||||
const animatedMovieCount = useAnimatedDashboardNumber(movieCount, {
|
||||
duration: 720,
|
||||
@@ -37,24 +40,28 @@ const statistics = computed(() => [
|
||||
stats: formatDashboardCount(animatedMovieCount.value),
|
||||
icon: 'mdi-movie-roll',
|
||||
color: 'primary',
|
||||
addition: movieCountMonth.value,
|
||||
},
|
||||
{
|
||||
title: t('mediaType.tv'),
|
||||
stats: formatDashboardCount(animatedTvCount.value),
|
||||
icon: 'mdi-television-box',
|
||||
color: 'success',
|
||||
addition: tvCountMonth.value,
|
||||
},
|
||||
{
|
||||
title: t('dashboard.episodes'),
|
||||
stats: episodeCount.value == null ? t('common.notFetched') : formatDashboardCount(animatedEpisodeCount.value),
|
||||
icon: 'mdi-television-classic',
|
||||
color: 'warning',
|
||||
addition: episodeCountMonth.value,
|
||||
},
|
||||
{
|
||||
title: t('dashboard.users'),
|
||||
stats: formatDashboardCount(animatedUserCount.value),
|
||||
icon: 'mdi-account',
|
||||
color: 'info',
|
||||
addition: null,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -67,6 +74,9 @@ async function loadMediaStatistic() {
|
||||
tvCount.value = Number(res.tv_count) || 0
|
||||
episodeCount.value = res.episode_count == null ? null : Number(res.episode_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) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -88,24 +98,21 @@ onActivated(() => {
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="dashboard-summary-content">
|
||||
<VRow>
|
||||
<VCol v-for="item in statistics" :key="item.title" cols="6" sm="3">
|
||||
<div class="d-flex align-center">
|
||||
<div class="me-3">
|
||||
<VAvatar :color="item.color" rounded size="42" class="elevation-1">
|
||||
<VIcon size="24" :icon="item.icon" />
|
||||
</VAvatar>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-caption">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="dashboard-number text-h6">{{ item.stats }}</span>
|
||||
</div>
|
||||
<div class="dashboard-stat-grid">
|
||||
<div v-for="item in statistics" :key="item.title" class="dashboard-stat-item">
|
||||
<VAvatar :color="item.color" size="46" class="dashboard-stat-icon">
|
||||
<VIcon size="24" :icon="item.icon" />
|
||||
</VAvatar>
|
||||
<div class="dashboard-stat-copy">
|
||||
<span class="dashboard-stat-label">{{ item.title }}</span>
|
||||
<span class="dashboard-number">{{ item.stats }}</span>
|
||||
<span v-if="item.addition !== null" class="dashboard-stat-addition">
|
||||
{{ t('dashboard.monthlyAddition', { count: item.addition }) }}
|
||||
</span>
|
||||
<span v-else class="dashboard-stat-addition text-medium-emphasis">{{ t('dashboard.activeUsers') }}</span>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -115,7 +122,7 @@ onActivated(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
min-block-size: 190px;
|
||||
}
|
||||
|
||||
.dashboard-summary-content {
|
||||
@@ -125,12 +132,71 @@ onActivated(() => {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-summary-content :deep(.v-row) {
|
||||
.dashboard-stat-grid {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-stat-item {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
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 {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
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>
|
||||
|
||||
@@ -46,15 +46,22 @@ const animatedUsedMemory = useAnimatedDashboardNumber(usedMemory, {
|
||||
duration: 650,
|
||||
})
|
||||
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(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => {
|
||||
const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})`
|
||||
|
||||
return {
|
||||
chart: {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: false },
|
||||
foreColor: axisLabelColor,
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
grid: {
|
||||
@@ -107,8 +114,17 @@ const chartOptions = controlledComputed(
|
||||
axisBorder: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
labels: { show: false },
|
||||
labels: {
|
||||
show: true,
|
||||
formatter: (value: number) => `${Math.round(value)}%`,
|
||||
style: {
|
||||
colors: axisLabelColor,
|
||||
fontSize: '10px',
|
||||
},
|
||||
},
|
||||
tickAmount: 2,
|
||||
max: 100,
|
||||
min: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -146,15 +162,22 @@ useKeepAliveRefresh(refresh)
|
||||
<template>
|
||||
<VCard class="dashboard-chart-card dashboard-grid-fill">
|
||||
<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>
|
||||
<VCardText class="dashboard-chart-content">
|
||||
<div class="dashboard-memory-value">
|
||||
<strong>{{ animatedUsedMemoryText }}</strong>
|
||||
<span>/ {{ totalMemoryText }}</span>
|
||||
</div>
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
{{ t('dashboard.current') }}:{{ animatedUsedMemoryText }}
|
||||
</p>
|
||||
<div class="dashboard-chart-footer">
|
||||
<span><i class="memory-dot memory-dot--used" />{{ t('dashboard.memoryUsed') }} {{ animatedUsedMemoryText }}</span>
|
||||
<span><i class="memory-dot memory-dot--available" />{{ t('dashboard.memoryAvailable') }} {{ availableMemoryText }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -164,7 +187,7 @@ useKeepAliveRefresh(refresh)
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
min-block-size: 270px;
|
||||
}
|
||||
|
||||
.dashboard-chart-content {
|
||||
@@ -177,10 +200,57 @@ useKeepAliveRefresh(refresh)
|
||||
|
||||
.dashboard-chart-plot {
|
||||
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;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { ScheduleInfo } from '@/api/types'
|
||||
import type { ScheduleInfo, TransferQueue } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
@@ -19,6 +19,49 @@ const props = defineProps({
|
||||
|
||||
// 定时服务列表
|
||||
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加载定时服务列表
|
||||
async function loadSchedulerList() {
|
||||
@@ -26,9 +69,12 @@ async function loadSchedulerList() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: ScheduleInfo[] = await api.get('dashboard/schedule')
|
||||
|
||||
schedulerList.value = res
|
||||
const [schedulers, queue] = await Promise.all([
|
||||
api.get('dashboard/schedule'),
|
||||
api.get('transfer/queue'),
|
||||
])
|
||||
schedulerList.value = schedulers as unknown as ScheduleInfo[]
|
||||
transferQueue.value = queue as unknown as TransferQueue[]
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -38,7 +84,7 @@ async function loadSchedulerList() {
|
||||
useDataRefresh(
|
||||
'dashboard-scheduler',
|
||||
loadSchedulerList,
|
||||
60000, // 60秒间隔
|
||||
10000, // 10秒间隔,及时反映整理队列和运行状态
|
||||
true // 立即执行
|
||||
)
|
||||
</script>
|
||||
@@ -47,34 +93,41 @@ useDataRefresh(
|
||||
<VCard class="dashboard-work-card dashboard-grid-fill">
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>
|
||||
<template #append>
|
||||
<VBtn size="small" variant="outlined" to="/history">{{ t('dashboard.viewAll') }}</VBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="dashboard-work-content">
|
||||
<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>
|
||||
<VAvatar size="40" variant="tonal" color="" class="me-3">
|
||||
{{ item.name[0] }}
|
||||
<VAvatar size="38" variant="tonal" :color="item.color" class="me-3">
|
||||
<VIcon :icon="item.icon" size="20" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
|
||||
<VListItemTitle class="mb-1">
|
||||
<span class="text-sm font-weight-medium">{{ item.name }}</span>
|
||||
<VListItemTitle class="background-task-title">
|
||||
{{ item.title }}
|
||||
</VListItemTitle>
|
||||
|
||||
<VListItemSubtitle class="text-xs">
|
||||
{{ item.next_run }}
|
||||
<VListItemSubtitle class="background-task-subtitle">
|
||||
{{ item.subtitle }}
|
||||
</VListItemSubtitle>
|
||||
<VProgressLinear
|
||||
v-if="item.progress !== undefined"
|
||||
:model-value="item.progress"
|
||||
:color="item.color"
|
||||
height="2"
|
||||
rounded
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<template #append>
|
||||
<div>
|
||||
<h4 class="font-weight-medium">
|
||||
{{ item.status }}
|
||||
</h4>
|
||||
</div>
|
||||
<span class="background-task-status">{{ item.status }}</span>
|
||||
<VIcon icon="mdi-check-circle" :color="item.progress === 100 ? 'success' : item.color" size="15" />
|
||||
</template>
|
||||
</VListItem>
|
||||
<VListItem v-if="schedulerList.length === 0">
|
||||
<VListItem v-if="backgroundTasks.length === 0">
|
||||
<VListItemTitle class="text-center"> {{ t('dashboard.noSchedulers') }} </VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
@@ -87,15 +140,42 @@ useDataRefresh(
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
max-block-size: 350px;
|
||||
min-block-size: 350px;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
--v-card-list-gap: 1.5rem;
|
||||
--v-card-list-gap: 0.2rem;
|
||||
|
||||
flex: 1 1 auto;
|
||||
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 {
|
||||
|
||||
@@ -117,12 +117,21 @@ const { loading } = useDataRefresh(
|
||||
<VCardTitle>{{ t('dashboard.realTimeSpeed') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="dashboard-work-content pt-4">
|
||||
<div>
|
||||
<p class="dashboard-speed-number text-h5 me-2">↑{{ uploadSpeedText }}</p>
|
||||
<p class="dashboard-speed-number text-h4 me-2">↓{{ downloadSpeedText }}</p>
|
||||
<VCardText class="dashboard-work-content">
|
||||
<div class="dashboard-speed-overview">
|
||||
<div class="dashboard-speed-rate">
|
||||
<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>
|
||||
<VList class="card-list mt-9">
|
||||
<VDivider class="my-3" />
|
||||
<VList class="card-list">
|
||||
<VListItem v-for="item in infoItems" :key="item.title">
|
||||
<template #prepend>
|
||||
<VIcon rounded :icon="item.avatar" />
|
||||
@@ -150,11 +159,11 @@ const { loading } = useDataRefresh(
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
min-block-size: 350px;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
--v-card-list-gap: 1rem;
|
||||
--v-card-list-gap: 0.15rem;
|
||||
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
@@ -173,4 +182,26 @@ const { loading } = useDataRefresh(
|
||||
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>
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import type { Storage } from '@/api/types'
|
||||
import trophy from '@images/misc/storage.png'
|
||||
import triangleDark from '@images/misc/triangle-dark.png'
|
||||
import triangleLight from '@images/misc/triangle-light.png'
|
||||
import storageImage from '@images/misc/storage.png'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const { global } = useTheme()
|
||||
|
||||
const triangleBg = computed(() => (global.name.value === 'light' ? triangleLight : triangleDark))
|
||||
|
||||
// 总存储空间
|
||||
const storage = ref(0)
|
||||
|
||||
@@ -31,6 +24,10 @@ const usedPercent = computed(() => {
|
||||
const animatedStorage = useAnimatedDashboardNumber(storage, {
|
||||
duration: 900,
|
||||
})
|
||||
const animatedUsed = useAnimatedDashboardNumber(used, {
|
||||
delay: 60,
|
||||
duration: 820,
|
||||
})
|
||||
|
||||
const animatedUsedPercent = useAnimatedDashboardNumber(usedPercent, {
|
||||
delay: 80,
|
||||
@@ -38,6 +35,9 @@ const animatedUsedPercent = useAnimatedDashboardNumber(usedPercent, {
|
||||
})
|
||||
|
||||
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 animatedUsedPercentText = computed(() => animatedUsedPercentValue.value.toFixed(1))
|
||||
|
||||
@@ -64,16 +64,14 @@ onActivated(() => {
|
||||
|
||||
<template>
|
||||
<VCard class="dashboard-summary-card dashboard-grid-fill">
|
||||
<!-- Triangle Background -->
|
||||
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText class="dashboard-summary-content">
|
||||
<h5 class="animated-storage-value font-weight-medium text-primary">
|
||||
<h5 class="animated-storage-value">
|
||||
{{ animatedStorageText }}
|
||||
</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">
|
||||
<VProgressLinear
|
||||
:model-value="animatedUsedPercentValue"
|
||||
@@ -83,27 +81,23 @@ onActivated(() => {
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
<div class="animated-storage-caption">
|
||||
{{ t('dashboard.storageSummary', { available: availableText, total: animatedStorageText, used: animatedUsedText }) }}
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- Trophy -->
|
||||
<VImg :src="trophy" class="trophy" />
|
||||
<VImg :src="storageImage" class="storage-image" />
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@layouts/styles/mixins' as layoutsMixins;
|
||||
|
||||
.v-card .triangle-bg {
|
||||
.v-card .storage-image {
|
||||
position: absolute;
|
||||
inline-size: clamp(7rem, 36%, 8.75rem);
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.v-card .trophy {
|
||||
position: absolute;
|
||||
inline-size: clamp(3.75rem, 18%, 4.5rem);
|
||||
inset-block-end: 2.75rem;
|
||||
inset-inline-end: 2rem;
|
||||
inline-size: clamp(4.6rem, 22%, 5.8rem);
|
||||
filter: hue-rotate(225deg) saturate(0.72);
|
||||
inset-block-start: 2.4rem;
|
||||
inset-inline-end: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-summary-card {
|
||||
@@ -111,18 +105,21 @@ onActivated(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
min-block-size: 190px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-summary-content {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
padding-block: 0.25rem 1rem;
|
||||
padding-block: 0.1rem 0.85rem;
|
||||
padding-inline-end: 7rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -135,11 +132,18 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
.animated-storage-progress-wrap {
|
||||
margin-block-start: 0.35rem;
|
||||
margin-block-start: 0.55rem;
|
||||
}
|
||||
|
||||
.animated-storage-progress {
|
||||
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>
|
||||
|
||||
@@ -17,7 +17,7 @@ const options = controlledComputed(
|
||||
const variableTheme = ref(vuetifyTheme.current.value.variables)
|
||||
|
||||
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']))},${
|
||||
|
||||
85
src/views/dashboard/DashboardQuickActions.vue
Normal file
85
src/views/dashboard/DashboardQuickActions.vue
Normal file
@@ -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>
|
||||
155
src/views/dashboard/DashboardRecentImports.vue
Normal file
155
src/views/dashboard/DashboardRecentImports.vue
Normal file
@@ -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>
|
||||
184
src/views/dashboard/DashboardSystemInfo.vue
Normal file
184
src/views/dashboard/DashboardSystemInfo.vue
Normal file
@@ -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