From a3d74929b377d57dbba3a0ce8e0183bd8b43c094 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 28 Jun 2026 22:59:36 +0800 Subject: [PATCH] feat(dashboard): add memory and scheduler progress interfaces, enhance dashboard layout and responsiveness --- src/api/types.ts | 50 ++++++++++ src/composables/useScheduleProgress.ts | 85 ++++++++++++++++ src/composables/useShortcutTools.ts | 5 +- src/locales/en-US.ts | 5 +- src/locales/zh-CN.ts | 5 +- src/locales/zh-TW.ts | 5 +- src/pages/dashboard.vue | 31 +++--- src/views/dashboard/AnalyticsCpu.vue | 30 ++++-- .../dashboard/AnalyticsMediaStatistic.vue | 6 +- src/views/dashboard/AnalyticsMemory.vue | 71 +++++++++---- src/views/dashboard/AnalyticsScheduler.vue | 43 ++++---- src/views/dashboard/AnalyticsSpeed.vue | 4 +- src/views/dashboard/AnalyticsStorage.vue | 25 +++-- src/views/system/ServiceView.vue | 99 ++++++++++++++++--- 14 files changed, 374 insertions(+), 90 deletions(-) create mode 100644 src/composables/useScheduleProgress.ts diff --git a/src/api/types.ts b/src/api/types.ts index 71126f0e..0f9b05f1 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1052,6 +1052,48 @@ export interface DownloaderInfo { free_space: number } +// 仪表板系统内存信息 +export interface DashboardMemoryInfo { + // 总内存字节数 + total: number + // 已使用内存字节数,不包含缓存 + used: number + // 缓存与缓冲区占用字节数 + cached: number + // 可用内存字节数 + available: number + // 已使用内存占总内存百分比,不包含缓存 + usage: number +} + +// 定时服务进度信息 +export interface ScheduleProgress { + // ID + id?: string + // 名称 + name?: string + // 提供者 + provider?: string + // 是否正在执行 + enable?: boolean + // 当前完成百分比 + value?: number + // 当前进度文本 + text?: string + // 执行状态 + status?: string + // 最近一次执行是否成功 + success?: boolean + // 最近一次开始时间 + started_at?: string + // 最近一次结束时间 + finished_at?: string + // 最近一次错误信息 + error?: string + // 扩展数据 + data?: Record +} + // 定时服务信息 export interface ScheduleInfo { // ID @@ -1064,6 +1106,14 @@ export interface ScheduleInfo { status: string // 下次运行时间 next_run: string + // 当前完成百分比 + progress?: number + // 进度文本 + progress_text?: string + // 是否正在更新进度 + progress_enable?: boolean + // 进度详情 + progress_detail?: ScheduleProgress } // 消息通知 diff --git a/src/composables/useScheduleProgress.ts b/src/composables/useScheduleProgress.ts new file mode 100644 index 00000000..52908af0 --- /dev/null +++ b/src/composables/useScheduleProgress.ts @@ -0,0 +1,85 @@ +import api from '@/api' +import type { ApiResponse, ScheduleInfo, ScheduleProgress } from '@/api/types' +import { useBackground } from '@/composables/useBackground' +import type { Ref } from 'vue' + +const SCHEDULE_PROGRESS_REFRESH_INTERVAL = 1000 + +/** 判断定时服务是否仍在运行,兼容列表状态与进度详情两种后端信号。 */ +export function isScheduleRunning(schedule: ScheduleInfo) { + return ( + schedule.status === '正在运行' || + schedule.progress_enable === true || + schedule.progress_detail?.enable === true || + schedule.progress_detail?.status === 'running' + ) +} + +/** 为定时服务列表提供仅针对运行中任务的实时进度轮询。 */ +export function useScheduleProgress(schedules: Ref, refreshId: string) { + const { useDataRefresh } = useBackground() + const progressById = ref>({}) + + /** 请求指定运行中任务的最新进度。 */ + async function loadScheduleProgress(schedule: ScheduleInfo) { + const response = (await api.get( + `dashboard/schedule/${encodeURIComponent(schedule.id)}/progress`, + )) as ApiResponse + + return response.success ? response.data : undefined + } + + /** 刷新所有运行中任务的进度,并清理已经停止任务的缓存。 */ + async function refreshRunningProgress() { + const runningSchedules = schedules.value.filter( + schedule => schedule.id && isScheduleRunning(schedule), + ) + const runningIds = new Set(runningSchedules.map(schedule => schedule.id)) + const nextProgress = Object.fromEntries( + Object.entries(progressById.value).filter(([id]) => runningIds.has(id)), + ) + + if (!runningSchedules.length) { + progressById.value = {} + return + } + + const results = await Promise.allSettled(runningSchedules.map(loadScheduleProgress)) + const currentRunningIds = new Set( + schedules.value + .filter(schedule => schedule.id && isScheduleRunning(schedule)) + .map(schedule => schedule.id), + ) + + results.forEach((result, index) => { + const schedule = runningSchedules[index] + if (result.status === 'fulfilled' && result.value && currentRunningIds.has(schedule.id)) { + nextProgress[schedule.id] = result.value + } + }) + + progressById.value = Object.fromEntries( + Object.entries(nextProgress).filter(([id]) => currentRunningIds.has(id)), + ) + } + + /** 获取任务当前应展示的百分比,并限制在合法范围内。 */ + function getScheduleProgressValue(schedule: ScheduleInfo) { + const value = Number(progressById.value[schedule.id]?.value ?? schedule.progress ?? 0) + + return Math.min(Math.max(value, 0), 100) + } + + /** 获取任务当前应展示的进度说明。 */ + function getScheduleProgressText(schedule: ScheduleInfo) { + return progressById.value[schedule.id]?.text || schedule.progress_text || '' + } + + useDataRefresh(refreshId, refreshRunningProgress, SCHEDULE_PROGRESS_REFRESH_INTERVAL, true) + + return { + getScheduleProgressText, + getScheduleProgressValue, + refreshRunningProgress, + } +} diff --git a/src/composables/useShortcutTools.ts b/src/composables/useShortcutTools.ts index 2897b9ca..78f9a092 100644 --- a/src/composables/useShortcutTools.ts +++ b/src/composables/useShortcutTools.ts @@ -19,6 +19,9 @@ const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue')) const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue')) +// 定时服务在捷径与仪表板中共用的图标,避免两个入口的视觉语义漂移。 +export const SCHEDULER_SHORTCUT_ICON = 'mdi-list-box' + export type ShortcutToolItem = PermissionProtectedItem & { bodyClass?: string cardClass?: string @@ -95,7 +98,7 @@ export function useShortcutTools() { { title: t('shortcut.scheduler.title'), subtitle: t('shortcut.scheduler.subtitle'), - icon: 'mdi-list-box', + icon: SCHEDULER_SHORTCUT_ICON, dialog: 'scheduler', bodyClass: 'scheduler-shortcut-dialog-body pa-0', cardClass: 'scheduler-shortcut-dialog-card', diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 42a19c2f..e85562b2 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -959,6 +959,7 @@ export default { memory: 'Memory', memoryUsage: 'Memory Usage', memoryUsed: 'Used', + memoryCached: 'Cached', memoryAvailable: 'Available', averageUsage: 'Average', network: 'Network Traffic', @@ -967,7 +968,7 @@ export default { library: 'My Media Library', playing: 'Continue Watching', latest: 'Recently Added', - recentImports: 'Recent Imports', + recentImports: 'Recent Transfers', viewAll: 'View All', settings: 'Dashboard Settings', chooseContent: 'Choose content to display', @@ -984,7 +985,7 @@ export default { transferProgress: '{completed} / {total} files', taskRunning: 'Running', taskWaiting: 'Waiting', - noRecentImports: 'No recent import records', + noRecentImports: 'No recent transfer records', monthlyAddition: '+{count} this month', quickActions: { title: 'Quick Actions', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index e27d9dac..cc78346e 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -953,6 +953,7 @@ export default { memory: '内存', memoryUsage: '内存使用', memoryUsed: '已使用', + memoryCached: '已缓存', memoryAvailable: '可用', averageUsage: '平均使用', network: '网络流量', @@ -963,7 +964,7 @@ export default { library: '我的媒体库', playing: '继续观看', latest: '最近添加', - recentImports: '最近导入', + recentImports: '近期整理', viewAll: '查看全部', settings: '设置仪表板', chooseContent: '选择您想在页面显示的内容', @@ -980,7 +981,7 @@ export default { transferProgress: '{completed} / {total} 个文件', taskRunning: '进行中', taskWaiting: '等待中', - noRecentImports: '暂无最近导入记录', + noRecentImports: '暂无近期整理记录', monthlyAddition: '+{count} 本月新增', quickActions: { title: '快捷操作', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 2567a1c7..9492436b 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -953,6 +953,7 @@ export default { memory: '內存', memoryUsage: '內存使用', memoryUsed: '已使用', + memoryCached: '已緩存', memoryAvailable: '可用', averageUsage: '平均使用', network: '網絡流量', @@ -963,7 +964,7 @@ export default { library: '我的媒體庫', playing: '繼續觀看', latest: '最近添加', - recentImports: '最近導入', + recentImports: '近期整理', viewAll: '查看全部', settings: '設置儀表板', chooseContent: '選擇您想在頁面顯示的內容', @@ -980,7 +981,7 @@ export default { transferProgress: '{completed} / {total} 個文件', taskRunning: '進行中', taskWaiting: '等待中', - noRecentImports: '暫無最近導入記錄', + noRecentImports: '暫無近期整理記錄', monthlyAddition: '+{count} 本月新增', quickActions: { title: '快捷操作', diff --git a/src/pages/dashboard.vue b/src/pages/dashboard.vue index 9b6384b8..e0051c7e 100644 --- a/src/pages/dashboard.vue +++ b/src/pages/dashboard.vue @@ -56,6 +56,9 @@ type DashboardConfigNormalizer = (value: unknown) => T | undefined type DashboardConfigRemoteValueBuilder = (value: T) => unknown type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile' +// CPU 与内存组件共用默认行高,确保两张资源趋势卡片始终等高。 +const DASHBOARD_RESOURCE_CHART_ROWS = 11 + interface DashboardGridLayoutItem { x?: number y?: number @@ -65,15 +68,15 @@ interface DashboardGridLayoutItem { // 参考桌面端设计稿定义默认排布;用户保存过的布局仍优先于这里的初始值。 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 }, + storage: { x: 0, y: 0, w: 4, h: 7 }, + mediaStatistic: { x: 4, y: 0, w: 8, h: 7 }, + speed: { x: 0, y: 7, w: 4, h: 12 }, + recentImports: { x: 4, y: 7, w: 4, h: 15 }, + scheduler: { x: 8, y: 7, w: 4, h: 15 }, + memory: { x: 0, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS }, + cpu: { x: 4, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS }, + quickActions: { x: 8, y: 22, w: 4, h: 5 }, + systemInfo: { x: 8, y: 27, w: 4, h: 6 }, } // 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。 @@ -152,7 +155,7 @@ const dashboardConfigs = ref([ key: '', attrs: {}, cols: { cols: 12, md: 4 }, - rows: 8, + rows: 7, elements: [], }, { @@ -161,7 +164,7 @@ const dashboardConfigs = ref([ key: '', attrs: {}, cols: { cols: 12, md: 8 }, - rows: 8, + rows: 7, elements: [], }, { @@ -179,7 +182,7 @@ const dashboardConfigs = ref([ key: '', attrs: {}, cols: { cols: 12, md: 4 }, - rows: 15, + rows: 12, elements: [], }, { @@ -197,7 +200,7 @@ const dashboardConfigs = ref([ key: '', attrs: {}, cols: { cols: 12, sm: 3, md: 4 }, - rows: 11, + rows: DASHBOARD_RESOURCE_CHART_ROWS, elements: [], }, { @@ -206,7 +209,7 @@ const dashboardConfigs = ref([ key: '', attrs: {}, cols: { cols: 12, sm: 3, md: 4 }, - rows: 11, + rows: DASHBOARD_RESOURCE_CHART_ROWS, elements: [], }, { diff --git a/src/views/dashboard/AnalyticsCpu.vue b/src/views/dashboard/AnalyticsCpu.vue index 79e08405..b9fed2f8 100644 --- a/src/views/dashboard/AnalyticsCpu.vue +++ b/src/views/dashboard/AnalyticsCpu.vue @@ -59,8 +59,15 @@ const averageUsages = computed(() => [ { label: '15m', value: getAverageUsage(30) }, ]) +// 根据最近采样动态收紧纵轴,低负载时仍能看清区域图变化。 +const cpuChartMax = computed(() => { + const peak = Math.max(current.value, ...series.value[0].data) + + return Math.min(100, Math.max(10, Math.ceil(peak / 10) * 10)) +}) + const chartOptions = controlledComputed( - () => vuetifyTheme.name.value, + () => `${vuetifyTheme.name.value}:${cpuChartMax.value}`, () => { const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})` @@ -85,7 +92,7 @@ const chartOptions = controlledComputed( }, padding: { top: -10, - left: -7, + left: 8, right: 5, bottom: 5, }, @@ -95,11 +102,14 @@ const chartOptions = controlledComputed( lineCap: 'butt', curve: 'smooth', }, - colors: [currentTheme.value.primary], + colors: [currentTheme.value.success], + fill: { + opacity: 0.24, + }, markers: { size: 6, offsetY: 4, - offsetX: -2, + offsetX: 4, strokeWidth: 3, colors: ['transparent'], strokeColors: 'transparent', @@ -107,12 +117,15 @@ const chartOptions = controlledComputed( { size: 5.5, seriesIndex: 0, - strokeColor: currentTheme.value.primary, + strokeColor: currentTheme.value.success, fillColor: currentTheme.value.surface, }, ], hover: { size: 7 }, }, + dataLabels: { + enabled: false, + }, xaxis: { labels: { show: false }, axisTicks: { show: false }, @@ -121,6 +134,7 @@ const chartOptions = controlledComputed( yaxis: { labels: { show: true, + minWidth: 32, formatter: (value: number) => `${Math.round(value)}%`, style: { colors: axisLabelColor, @@ -128,7 +142,7 @@ const chartOptions = controlledComputed( }, }, tickAmount: 2, - max: 100, + max: cpuChartMax.value, min: 0, }, } @@ -172,7 +186,7 @@ useKeepAliveRefresh(refresh)
- +
@@ -250,7 +283,11 @@ useKeepAliveRefresh(refresh) background: rgb(var(--v-theme-primary)); } -.memory-dot--available { +.memory-dot--cached { background: rgb(var(--v-theme-info)); } + +.memory-dot--available { + background: rgb(var(--v-theme-success)); +} diff --git a/src/views/dashboard/AnalyticsScheduler.vue b/src/views/dashboard/AnalyticsScheduler.vue index eca120c3..af3f6987 100644 --- a/src/views/dashboard/AnalyticsScheduler.vue +++ b/src/views/dashboard/AnalyticsScheduler.vue @@ -3,6 +3,8 @@ import api from '@/api' import type { ScheduleInfo, TransferQueue } from '@/api/types' import { useI18n } from 'vue-i18n' import { useBackground } from '@/composables/useBackground' +import { SCHEDULER_SHORTCUT_ICON } from '@/composables/useShortcutTools' +import { isScheduleRunning, useScheduleProgress } from '@/composables/useScheduleProgress' // 国际化 const { t } = useI18n() @@ -20,6 +22,10 @@ const props = defineProps({ // 定时服务列表 const schedulerList = ref([]) const transferQueue = ref([]) +const { getScheduleProgressText, getScheduleProgressValue } = useScheduleProgress( + schedulerList, + 'dashboard-scheduler-progress', +) interface BackgroundTaskItem { color: string @@ -33,30 +39,35 @@ interface BackgroundTaskItem { // 将正在运行的服务和整理队列排在前面,再补充最近即将执行的定时任务。 const backgroundTasks = computed(() => { - 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 runningSchedulers = schedulerList.value.filter(isScheduleRunning) + const waitingSchedulers = schedulerList.value.filter(item => !isScheduleRunning(item)) + const schedulerTasks = [...runningSchedulers, ...waitingSchedulers].map(item => { + const isRunning = isScheduleRunning(item) + + return { + id: `schedule-${item.id}`, + title: item.name || t('dashboard.scheduler'), + subtitle: (isRunning && getScheduleProgressText(item)) || item.provider || item.next_run || '', + status: item.status || t('dashboard.taskWaiting'), + icon: isRunning ? 'mdi-progress-clock' : 'mdi-clock-outline', + color: isRunning ? 'primary' : 'info', + progress: isRunning ? getScheduleProgressValue(item) : undefined, + } + }) 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 + const isRunning = tasks.some(task => task.state === 'running') 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'), + status: isRunning ? t('dashboard.taskRunning') : t('dashboard.taskWaiting'), icon: 'mdi-folder-sync-outline', color: 'warning', - progress, + progress: isRunning ? progress : undefined, } }) @@ -84,7 +95,7 @@ async function loadSchedulerList() { useDataRefresh( 'dashboard-scheduler', loadSchedulerList, - 10000, // 10秒间隔,及时反映整理队列和运行状态 + 3000, // 3秒间隔,及时发现任务启停;运行中进度由独立轮询每秒刷新 true // 立即执行 ) @@ -92,10 +103,8 @@ useDataRefresh(