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:
jxxghp
2026-06-28 17:48:50 +08:00
parent 98769a313f
commit fffb526a87
18 changed files with 1205 additions and 253 deletions

View File

@@ -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 {
// 下载速度

View File

@@ -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 渲染模式 -->

View 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,
}
}

View File

@@ -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(() => {

View File

@@ -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',

View File

@@ -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: '总上传量',

View File

@@ -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: '總上傳量',

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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']))},${

View 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>

View 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>

View 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>