feat(dashboard): add memory and scheduler progress interfaces, enhance dashboard layout and responsiveness

This commit is contained in:
jxxghp
2026-06-28 22:59:36 +08:00
parent fd9c314d55
commit a3d74929b3
14 changed files with 374 additions and 90 deletions
+50
View File
@@ -1052,6 +1052,48 @@ export interface DownloaderInfo {
free_space: number 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<string, unknown>
}
// 定时服务信息 // 定时服务信息
export interface ScheduleInfo { export interface ScheduleInfo {
// ID // ID
@@ -1064,6 +1106,14 @@ export interface ScheduleInfo {
status: string status: string
// 下次运行时间 // 下次运行时间
next_run: string next_run: string
// 当前完成百分比
progress?: number
// 进度文本
progress_text?: string
// 是否正在更新进度
progress_enable?: boolean
// 进度详情
progress_detail?: ScheduleProgress
} }
// 消息通知 // 消息通知
+85
View File
@@ -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<ScheduleInfo[]>, refreshId: string) {
const { useDataRefresh } = useBackground()
const progressById = ref<Record<string, ScheduleProgress>>({})
/** 请求指定运行中任务的最新进度。 */
async function loadScheduleProgress(schedule: ScheduleInfo) {
const response = (await api.get(
`dashboard/schedule/${encodeURIComponent(schedule.id)}/progress`,
)) as ApiResponse<ScheduleProgress>
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,
}
}
+4 -1
View File
@@ -19,6 +19,9 @@ const AccountSettingService = defineAsyncComponent(() => import('@/views/system/
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue')) const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue')) const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
// 定时服务在捷径与仪表板中共用的图标,避免两个入口的视觉语义漂移。
export const SCHEDULER_SHORTCUT_ICON = 'mdi-list-box'
export type ShortcutToolItem = PermissionProtectedItem & { export type ShortcutToolItem = PermissionProtectedItem & {
bodyClass?: string bodyClass?: string
cardClass?: string cardClass?: string
@@ -95,7 +98,7 @@ export function useShortcutTools() {
{ {
title: t('shortcut.scheduler.title'), title: t('shortcut.scheduler.title'),
subtitle: t('shortcut.scheduler.subtitle'), subtitle: t('shortcut.scheduler.subtitle'),
icon: 'mdi-list-box', icon: SCHEDULER_SHORTCUT_ICON,
dialog: 'scheduler', dialog: 'scheduler',
bodyClass: 'scheduler-shortcut-dialog-body pa-0', bodyClass: 'scheduler-shortcut-dialog-body pa-0',
cardClass: 'scheduler-shortcut-dialog-card', cardClass: 'scheduler-shortcut-dialog-card',
+3 -2
View File
@@ -959,6 +959,7 @@ export default {
memory: 'Memory', memory: 'Memory',
memoryUsage: 'Memory Usage', memoryUsage: 'Memory Usage',
memoryUsed: 'Used', memoryUsed: 'Used',
memoryCached: 'Cached',
memoryAvailable: 'Available', memoryAvailable: 'Available',
averageUsage: 'Average', averageUsage: 'Average',
network: 'Network Traffic', network: 'Network Traffic',
@@ -967,7 +968,7 @@ export default {
library: 'My Media Library', library: 'My Media Library',
playing: 'Continue Watching', playing: 'Continue Watching',
latest: 'Recently Added', latest: 'Recently Added',
recentImports: 'Recent Imports', recentImports: 'Recent Transfers',
viewAll: 'View All', viewAll: 'View All',
settings: 'Dashboard Settings', settings: 'Dashboard Settings',
chooseContent: 'Choose content to display', chooseContent: 'Choose content to display',
@@ -984,7 +985,7 @@ export default {
transferProgress: '{completed} / {total} files', transferProgress: '{completed} / {total} files',
taskRunning: 'Running', taskRunning: 'Running',
taskWaiting: 'Waiting', taskWaiting: 'Waiting',
noRecentImports: 'No recent import records', noRecentImports: 'No recent transfer records',
monthlyAddition: '+{count} this month', monthlyAddition: '+{count} this month',
quickActions: { quickActions: {
title: 'Quick Actions', title: 'Quick Actions',
+3 -2
View File
@@ -953,6 +953,7 @@ export default {
memory: '内存', memory: '内存',
memoryUsage: '内存使用', memoryUsage: '内存使用',
memoryUsed: '已使用', memoryUsed: '已使用',
memoryCached: '已缓存',
memoryAvailable: '可用', memoryAvailable: '可用',
averageUsage: '平均使用', averageUsage: '平均使用',
network: '网络流量', network: '网络流量',
@@ -963,7 +964,7 @@ export default {
library: '我的媒体库', library: '我的媒体库',
playing: '继续观看', playing: '继续观看',
latest: '最近添加', latest: '最近添加',
recentImports: '最近导入', recentImports: '近期整理',
viewAll: '查看全部', viewAll: '查看全部',
settings: '设置仪表板', settings: '设置仪表板',
chooseContent: '选择您想在页面显示的内容', chooseContent: '选择您想在页面显示的内容',
@@ -980,7 +981,7 @@ export default {
transferProgress: '{completed} / {total} 个文件', transferProgress: '{completed} / {total} 个文件',
taskRunning: '进行中', taskRunning: '进行中',
taskWaiting: '等待中', taskWaiting: '等待中',
noRecentImports: '暂无最近导入记录', noRecentImports: '暂无近期整理记录',
monthlyAddition: '+{count} 本月新增', monthlyAddition: '+{count} 本月新增',
quickActions: { quickActions: {
title: '快捷操作', title: '快捷操作',
+3 -2
View File
@@ -953,6 +953,7 @@ export default {
memory: '內存', memory: '內存',
memoryUsage: '內存使用', memoryUsage: '內存使用',
memoryUsed: '已使用', memoryUsed: '已使用',
memoryCached: '已緩存',
memoryAvailable: '可用', memoryAvailable: '可用',
averageUsage: '平均使用', averageUsage: '平均使用',
network: '網絡流量', network: '網絡流量',
@@ -963,7 +964,7 @@ export default {
library: '我的媒體庫', library: '我的媒體庫',
playing: '繼續觀看', playing: '繼續觀看',
latest: '最近添加', latest: '最近添加',
recentImports: '最近導入', recentImports: '近期整理',
viewAll: '查看全部', viewAll: '查看全部',
settings: '設置儀表板', settings: '設置儀表板',
chooseContent: '選擇您想在頁面顯示的內容', chooseContent: '選擇您想在頁面顯示的內容',
@@ -980,7 +981,7 @@ export default {
transferProgress: '{completed} / {total} 個文件', transferProgress: '{completed} / {total} 個文件',
taskRunning: '進行中', taskRunning: '進行中',
taskWaiting: '等待中', taskWaiting: '等待中',
noRecentImports: '暫無最近導入記錄', noRecentImports: '暫無近期整理記錄',
monthlyAddition: '+{count} 本月新增', monthlyAddition: '+{count} 本月新增',
quickActions: { quickActions: {
title: '快捷操作', title: '快捷操作',
+17 -14
View File
@@ -56,6 +56,9 @@ type DashboardConfigNormalizer<T> = (value: unknown) => T | undefined
type DashboardConfigRemoteValueBuilder<T> = (value: T) => unknown type DashboardConfigRemoteValueBuilder<T> = (value: T) => unknown
type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile' type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile'
// CPU
const DASHBOARD_RESOURCE_CHART_ROWS = 11
interface DashboardGridLayoutItem { interface DashboardGridLayoutItem {
x?: number x?: number
y?: number y?: number
@@ -65,15 +68,15 @@ interface DashboardGridLayoutItem {
// 稿 // 稿
const DASHBOARD_DESKTOP_DEFAULT_LAYOUT: DashboardGridLayoutConfig = { const DASHBOARD_DESKTOP_DEFAULT_LAYOUT: DashboardGridLayoutConfig = {
storage: { x: 0, y: 0, w: 4, h: 8 }, storage: { x: 0, y: 0, w: 4, h: 7 },
mediaStatistic: { x: 4, y: 0, w: 8, h: 8 }, mediaStatistic: { x: 4, y: 0, w: 8, h: 7 },
speed: { x: 0, y: 8, w: 4, h: 15 }, speed: { x: 0, y: 7, w: 4, h: 12 },
recentImports: { x: 4, y: 8, w: 4, h: 15 }, recentImports: { x: 4, y: 7, w: 4, h: 15 },
scheduler: { x: 8, y: 8, w: 4, h: 15 }, scheduler: { x: 8, y: 7, w: 4, h: 15 },
memory: { x: 0, y: 23, w: 4, h: 11 }, memory: { x: 0, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS },
cpu: { x: 4, y: 23, w: 4, h: 11 }, cpu: { x: 4, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS },
quickActions: { x: 8, y: 23, w: 4, h: 5 }, quickActions: { x: 8, y: 22, w: 4, h: 5 },
systemInfo: { x: 8, y: 28, w: 4, h: 6 }, systemInfo: { x: 8, y: 27, w: 4, h: 6 },
} }
// //
@@ -152,7 +155,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '', key: '',
attrs: {}, attrs: {},
cols: { cols: 12, md: 4 }, cols: { cols: 12, md: 4 },
rows: 8, rows: 7,
elements: [], elements: [],
}, },
{ {
@@ -161,7 +164,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '', key: '',
attrs: {}, attrs: {},
cols: { cols: 12, md: 8 }, cols: { cols: 12, md: 8 },
rows: 8, rows: 7,
elements: [], elements: [],
}, },
{ {
@@ -179,7 +182,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '', key: '',
attrs: {}, attrs: {},
cols: { cols: 12, md: 4 }, cols: { cols: 12, md: 4 },
rows: 15, rows: 12,
elements: [], elements: [],
}, },
{ {
@@ -197,7 +200,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '', key: '',
attrs: {}, attrs: {},
cols: { cols: 12, sm: 3, md: 4 }, cols: { cols: 12, sm: 3, md: 4 },
rows: 11, rows: DASHBOARD_RESOURCE_CHART_ROWS,
elements: [], elements: [],
}, },
{ {
@@ -206,7 +209,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '', key: '',
attrs: {}, attrs: {},
cols: { cols: 12, sm: 3, md: 4 }, cols: { cols: 12, sm: 3, md: 4 },
rows: 11, rows: DASHBOARD_RESOURCE_CHART_ROWS,
elements: [], elements: [],
}, },
{ {
+22 -8
View File
@@ -59,8 +59,15 @@ const averageUsages = computed(() => [
{ label: '15m', value: getAverageUsage(30) }, { 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( const chartOptions = controlledComputed(
() => vuetifyTheme.name.value, () => `${vuetifyTheme.name.value}:${cpuChartMax.value}`,
() => { () => {
const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})` const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})`
@@ -85,7 +92,7 @@ const chartOptions = controlledComputed(
}, },
padding: { padding: {
top: -10, top: -10,
left: -7, left: 8,
right: 5, right: 5,
bottom: 5, bottom: 5,
}, },
@@ -95,11 +102,14 @@ const chartOptions = controlledComputed(
lineCap: 'butt', lineCap: 'butt',
curve: 'smooth', curve: 'smooth',
}, },
colors: [currentTheme.value.primary], colors: [currentTheme.value.success],
fill: {
opacity: 0.24,
},
markers: { markers: {
size: 6, size: 6,
offsetY: 4, offsetY: 4,
offsetX: -2, offsetX: 4,
strokeWidth: 3, strokeWidth: 3,
colors: ['transparent'], colors: ['transparent'],
strokeColors: 'transparent', strokeColors: 'transparent',
@@ -107,12 +117,15 @@ const chartOptions = controlledComputed(
{ {
size: 5.5, size: 5.5,
seriesIndex: 0, seriesIndex: 0,
strokeColor: currentTheme.value.primary, strokeColor: currentTheme.value.success,
fillColor: currentTheme.value.surface, fillColor: currentTheme.value.surface,
}, },
], ],
hover: { size: 7 }, hover: { size: 7 },
}, },
dataLabels: {
enabled: false,
},
xaxis: { xaxis: {
labels: { show: false }, labels: { show: false },
axisTicks: { show: false }, axisTicks: { show: false },
@@ -121,6 +134,7 @@ const chartOptions = controlledComputed(
yaxis: { yaxis: {
labels: { labels: {
show: true, show: true,
minWidth: 32,
formatter: (value: number) => `${Math.round(value)}%`, formatter: (value: number) => `${Math.round(value)}%`,
style: { style: {
colors: axisLabelColor, colors: axisLabelColor,
@@ -128,7 +142,7 @@ const chartOptions = controlledComputed(
}, },
}, },
tickAmount: 2, tickAmount: 2,
max: 100, max: cpuChartMax.value,
min: 0, min: 0,
}, },
} }
@@ -172,7 +186,7 @@ useKeepAliveRefresh(refresh)
</VCardItem> </VCardItem>
<VCardText class="dashboard-chart-content"> <VCardText class="dashboard-chart-content">
<div class="dashboard-chart-plot"> <div class="dashboard-chart-plot">
<VApexChart type="line" :options="chartOptions" :series="series" height="100%" /> <VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
</div> </div>
<div class="dashboard-chart-footer"> <div class="dashboard-chart-footer">
<span>{{ t('dashboard.averageUsage') }}</span> <span>{{ t('dashboard.averageUsage') }}</span>
@@ -200,7 +214,7 @@ useKeepAliveRefresh(refresh)
.dashboard-chart-plot { .dashboard-chart-plot {
flex: 1 1 auto; flex: 1 1 auto;
min-block-size: 135px; min-block-size: 120px;
} }
.dashboard-chart-current, .dashboard-chart-current,
@@ -122,7 +122,7 @@ onActivated(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
block-size: 100%; block-size: 100%;
min-block-size: 190px; min-block-size: 160px;
} }
.dashboard-summary-content { .dashboard-summary-content {
@@ -142,8 +142,8 @@ onActivated(() => {
display: flex; display: flex;
min-inline-size: 0; min-inline-size: 0;
align-items: center; align-items: center;
gap: 0.85rem; gap: 0.7rem;
padding-inline: 1.35rem; padding-inline: 1.1rem;
} }
.dashboard-stat-item:first-child { .dashboard-stat-item:first-child {
+54 -17
View File
@@ -2,6 +2,7 @@
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils' import { hexToRgb } from '@layouts/utils'
import api from '@/api' import api from '@/api'
import type { DashboardMemoryInfo } from '@/api/types'
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion' import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground' import { useBackground } from '@/composables/useBackground'
@@ -34,25 +35,45 @@ const variableTheme = controlledComputed(
// //
const series = ref([ const series = ref([
{ {
name: t('dashboard.memoryUsed'),
data: [0],
},
{
name: t('dashboard.memoryCached'),
data: [0], data: [0],
}, },
]) ])
// // 使
const usedMemory = ref(0) const usedMemory = ref(0)
//
const cachedMemory = ref(0)
//
const availableMemory = ref(0)
//
const totalMemory = ref(0)
// 使 // 使
const memoryUsage = ref(0) const memoryUsage = ref(0)
const animatedUsedMemory = useAnimatedDashboardNumber(usedMemory, { const animatedUsedMemory = useAnimatedDashboardNumber(usedMemory, {
duration: 650, duration: 650,
}) })
const animatedCachedMemory = useAnimatedDashboardNumber(cachedMemory, {
duration: 650,
})
const animatedAvailableMemory = useAnimatedDashboardNumber(availableMemory, {
duration: 650,
})
const animatedUsedMemoryText = computed(() => formatDashboardFileSize(animatedUsedMemory.value, 2, usedMemory.value)) const animatedUsedMemoryText = computed(() => formatDashboardFileSize(animatedUsedMemory.value, 2, usedMemory.value))
const totalMemory = computed(() => (memoryUsage.value > 0 ? usedMemory.value / (memoryUsage.value / 100) : 0)) const animatedCachedMemoryText = computed(() =>
const availableMemory = computed(() => Math.max(0, totalMemory.value - usedMemory.value)) formatDashboardFileSize(animatedCachedMemory.value, 2, cachedMemory.value),
)
const animatedAvailableMemoryText = computed(() =>
formatDashboardFileSize(animatedAvailableMemory.value, 2, availableMemory.value),
)
const totalMemoryText = computed(() => formatDashboardFileSize(totalMemory.value, 2, totalMemory.value)) const totalMemoryText = computed(() => formatDashboardFileSize(totalMemory.value, 2, totalMemory.value))
const availableMemoryText = computed(() => formatDashboardFileSize(availableMemory.value, 2, availableMemory.value))
const chartOptions = controlledComputed( const chartOptions = controlledComputed(
() => vuetifyTheme.name.value, () => `${vuetifyTheme.name.value}:${totalMemory.value}`,
() => { () => {
const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})` const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})`
@@ -64,6 +85,7 @@ const chartOptions = controlledComputed(
foreColor: axisLabelColor, foreColor: axisLabelColor,
}, },
tooltip: { enabled: false }, tooltip: { enabled: false },
legend: { show: false },
grid: { grid: {
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${ borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
variableTheme.value['border-opacity'] variableTheme.value['border-opacity']
@@ -77,7 +99,7 @@ const chartOptions = controlledComputed(
}, },
padding: { padding: {
top: -10, top: -10,
left: -7, left: 8,
right: 5, right: 5,
bottom: 5, bottom: 5,
}, },
@@ -87,11 +109,14 @@ const chartOptions = controlledComputed(
lineCap: 'butt', lineCap: 'butt',
curve: 'smooth', curve: 'smooth',
}, },
colors: [currentTheme.value.primary], colors: [currentTheme.value.primary, currentTheme.value.info],
fill: {
opacity: [0.22, 0.08],
},
markers: { markers: {
size: 6, size: 6,
offsetY: 4, offsetY: 4,
offsetX: -2, offsetX: 4,
strokeWidth: 3, strokeWidth: 3,
colors: ['transparent'], colors: ['transparent'],
strokeColors: 'transparent', strokeColors: 'transparent',
@@ -116,14 +141,15 @@ const chartOptions = controlledComputed(
yaxis: { yaxis: {
labels: { labels: {
show: true, show: true,
formatter: (value: number) => `${Math.round(value)}%`, minWidth: 40,
formatter: (value: number) => formatDashboardFileSize(value, 0, totalMemory.value || value),
style: { style: {
colors: axisLabelColor, colors: axisLabelColor,
fontSize: '10px', fontSize: '10px',
}, },
}, },
tickAmount: 2, tickAmount: 2,
max: 100, max: totalMemory.value || undefined,
min: 0, min: 0,
}, },
} }
@@ -135,14 +161,20 @@ async function loadMemoryData() {
if (!props.allowRefresh) return if (!props.allowRefresh) return
try { try {
// //
const [memory, usage]: [number, number] = await api.get('dashboard/memory') const memory: DashboardMemoryInfo = await api.get('dashboard/memory')
usedMemory.value = Number(memory) || 0 usedMemory.value = Number(memory.used) || 0
memoryUsage.value = Number(usage) || 0 cachedMemory.value = Number(memory.cached) || 0
availableMemory.value = Number(memory.available) || 0
totalMemory.value = Number(memory.total) || 0
memoryUsage.value = Number(memory.usage) || 0
// 使nextTickDOM // 使nextTickDOM
await nextTick() await nextTick()
series.value[0].data.push(memoryUsage.value) series.value[0].data.push(usedMemory.value)
series.value[1].data.push(cachedMemory.value)
// 30 // 30
if (series.value[0].data.length > 30) series.value[0].data.shift() series.value.forEach(item => {
if (item.data.length > 30) item.data.shift()
})
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }
@@ -176,7 +208,8 @@ useKeepAliveRefresh(refresh)
</div> </div>
<div class="dashboard-chart-footer"> <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--used" />{{ t('dashboard.memoryUsed') }} {{ animatedUsedMemoryText }}</span>
<span><i class="memory-dot memory-dot--available" />{{ t('dashboard.memoryAvailable') }} {{ availableMemoryText }}</span> <span><i class="memory-dot memory-dot--cached" />{{ t('dashboard.memoryCached') }} {{ animatedCachedMemoryText }}</span>
<span><i class="memory-dot memory-dot--available" />{{ t('dashboard.memoryAvailable') }} {{ animatedAvailableMemoryText }}</span>
</div> </div>
</VCardText> </VCardText>
</VCard> </VCard>
@@ -250,7 +283,11 @@ useKeepAliveRefresh(refresh)
background: rgb(var(--v-theme-primary)); background: rgb(var(--v-theme-primary));
} }
.memory-dot--available { .memory-dot--cached {
background: rgb(var(--v-theme-info)); background: rgb(var(--v-theme-info));
} }
.memory-dot--available {
background: rgb(var(--v-theme-success));
}
</style> </style>
+26 -17
View File
@@ -3,6 +3,8 @@ import api from '@/api'
import type { ScheduleInfo, TransferQueue } from '@/api/types' import type { ScheduleInfo, TransferQueue } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground' import { useBackground } from '@/composables/useBackground'
import { SCHEDULER_SHORTCUT_ICON } from '@/composables/useShortcutTools'
import { isScheduleRunning, useScheduleProgress } from '@/composables/useScheduleProgress'
// //
const { t } = useI18n() const { t } = useI18n()
@@ -20,6 +22,10 @@ const props = defineProps({
// //
const schedulerList = ref<ScheduleInfo[]>([]) const schedulerList = ref<ScheduleInfo[]>([])
const transferQueue = ref<TransferQueue[]>([]) const transferQueue = ref<TransferQueue[]>([])
const { getScheduleProgressText, getScheduleProgressValue } = useScheduleProgress(
schedulerList,
'dashboard-scheduler-progress',
)
interface BackgroundTaskItem { interface BackgroundTaskItem {
color: string color: string
@@ -33,30 +39,35 @@ interface BackgroundTaskItem {
// //
const backgroundTasks = computed<BackgroundTaskItem[]>(() => { const backgroundTasks = computed<BackgroundTaskItem[]>(() => {
const runningSchedulers = schedulerList.value.filter(item => item.status === '正在运行') const runningSchedulers = schedulerList.value.filter(isScheduleRunning)
const waitingSchedulers = schedulerList.value.filter(item => item.status !== '正在运行') const waitingSchedulers = schedulerList.value.filter(item => !isScheduleRunning(item))
const schedulerTasks = [...runningSchedulers, ...waitingSchedulers].map(item => ({ const schedulerTasks = [...runningSchedulers, ...waitingSchedulers].map(item => {
id: `schedule-${item.id}`, const isRunning = isScheduleRunning(item)
title: item.name || t('dashboard.scheduler'),
subtitle: item.provider || item.next_run || '', return {
status: item.status || t('dashboard.taskWaiting'), id: `schedule-${item.id}`,
icon: item.status === '正在运行' ? 'mdi-progress-clock' : 'mdi-clock-outline', title: item.name || t('dashboard.scheduler'),
color: item.status === '正在运行' ? 'primary' : 'info', subtitle: (isRunning && getScheduleProgressText(item)) || item.provider || item.next_run || '',
progress: item.status === '正在运行' ? undefined : 0, 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 transferTasks = transferQueue.value.map((item, index) => {
const tasks = item.tasks ?? [] const tasks = item.tasks ?? []
const completed = tasks.filter(task => task.state === 'completed').length const completed = tasks.filter(task => task.state === 'completed').length
const progress = tasks.length ? Math.round((completed / tasks.length) * 100) : 0 const progress = tasks.length ? Math.round((completed / tasks.length) * 100) : 0
const isRunning = tasks.some(task => task.state === 'running')
return { return {
id: `transfer-${item.media?.tmdb_id ?? index}-${item.season ?? ''}`, id: `transfer-${item.media?.tmdb_id ?? index}-${item.season ?? ''}`,
title: item.media?.title_year || item.media?.title || t('dashboard.transferQueue'), title: item.media?.title_year || item.media?.title || t('dashboard.transferQueue'),
subtitle: t('dashboard.transferProgress', { completed, total: tasks.length }), 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', icon: 'mdi-folder-sync-outline',
color: 'warning', color: 'warning',
progress, progress: isRunning ? progress : undefined,
} }
}) })
@@ -84,7 +95,7 @@ async function loadSchedulerList() {
useDataRefresh( useDataRefresh(
'dashboard-scheduler', 'dashboard-scheduler',
loadSchedulerList, loadSchedulerList,
10000, // 10 3000, // 3
true // true //
) )
</script> </script>
@@ -92,10 +103,8 @@ useDataRefresh(
<template> <template>
<VCard class="dashboard-work-card dashboard-grid-fill"> <VCard class="dashboard-work-card dashboard-grid-fill">
<VCardItem> <VCardItem>
<template #prepend><VIcon :icon="SCHEDULER_SHORTCUT_ICON" size="20" class="me-2" /></template>
<VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle> <VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>
<template #append>
<VBtn size="small" variant="outlined" to="/history">{{ t('dashboard.viewAll') }}</VBtn>
</template>
</VCardItem> </VCardItem>
<VCardText class="dashboard-work-content"> <VCardText class="dashboard-work-content">
+2 -2
View File
@@ -158,8 +158,8 @@ const { loading } = useDataRefresh(
.dashboard-work-card { .dashboard-work-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
block-size: 100%; block-size: auto;
min-block-size: 350px; min-block-size: 0;
} }
.card-list { .card-list {
+15 -10
View File
@@ -94,10 +94,10 @@ onActivated(() => {
.v-card .storage-image { .v-card .storage-image {
position: absolute; position: absolute;
inline-size: clamp(4.6rem, 22%, 5.8rem); inline-size: clamp(3.6rem, 18%, 4.5rem);
filter: hue-rotate(225deg) saturate(0.72); filter: hue-rotate(225deg) saturate(0.72);
inset-block-start: 2.4rem; inset-block-start: 2.7rem;
inset-inline-end: 1.5rem; inset-inline-end: 1.35rem;
} }
.dashboard-summary-card { .dashboard-summary-card {
@@ -105,34 +105,39 @@ onActivated(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
block-size: 100%; block-size: 100%;
min-block-size: 190px; min-block-size: 160px;
overflow: hidden; overflow: hidden;
} }
.dashboard-summary-content { .dashboard-summary-content {
flex: 1 1 auto; flex: 1 1 auto;
min-block-size: 0; min-block-size: 0;
padding-block: 0.1rem 0.85rem; padding-block: 0 0.7rem;
padding-inline-end: 7rem; }
.animated-storage-value,
.animated-storage-meta,
.animated-storage-caption {
padding-inline-end: 4.75rem;
} }
.animated-storage-value { .animated-storage-value {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: clamp(1.65rem, 2vw, 1.9rem); font-size: clamp(1.5rem, 1.8vw, 1.75rem);
font-weight: 700; font-weight: 700;
line-height: 1.2; line-height: 1.2;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.animated-storage-meta { .animated-storage-meta {
margin-block-start: 0.5rem; margin-block-start: 0.3rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.2; line-height: 1.2;
} }
.animated-storage-progress-wrap { .animated-storage-progress-wrap {
margin-block-start: 0.55rem; margin-block-start: 0.4rem;
} }
.animated-storage-progress { .animated-storage-progress {
@@ -140,7 +145,7 @@ onActivated(() => {
} }
.animated-storage-caption { .animated-storage-caption {
margin-block-start: 0.45rem; margin-block-start: 0.35rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.68rem; font-size: 0.68rem;
white-space: nowrap; white-space: nowrap;
+87 -12
View File
@@ -4,6 +4,7 @@ import api from '@/api'
import type { ScheduleInfo } from '@/api/types' import type { ScheduleInfo } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground' import { useBackground } from '@/composables/useBackground'
import { isScheduleRunning, useScheduleProgress } from '@/composables/useScheduleProgress'
// //
type SchedulerMobileVisual = { type SchedulerMobileVisual = {
@@ -28,6 +29,10 @@ const $toast = useToast()
// //
const schedulerList = ref<ScheduleInfo[]>([]) const schedulerList = ref<ScheduleInfo[]>([])
const { getScheduleProgressText, getScheduleProgressValue } = useScheduleProgress(
schedulerList,
'scheduler-service-progress',
)
// job id // job id
const schedulerMobileVisualRules: SchedulerMobileVisualRule[] = [ const schedulerMobileVisualRules: SchedulerMobileVisualRule[] = [
@@ -162,19 +167,26 @@ function runCommand(id: string) {
// //
const mobileSchedulerCards = computed(() => const mobileSchedulerCards = computed(() =>
schedulerList.value.map(scheduler => ({ schedulerList.value.map(scheduler => {
scheduler, const isRunning = isScheduleRunning(scheduler)
statusText: getMobileSchedulerStatusText(scheduler),
statusVariant: getSchedulerStatusVariant(scheduler.status), return {
visual: getMobileSchedulerVisual(scheduler), isRunning,
})), progressText: isRunning ? getScheduleProgressText(scheduler) : '',
progressValue: isRunning ? getScheduleProgressValue(scheduler) : 0,
scheduler,
statusText: getMobileSchedulerStatusText(scheduler),
statusVariant: getSchedulerStatusVariant(scheduler.status),
visual: getMobileSchedulerVisual(scheduler),
}
}),
) )
// 使 // 使
const { loading: schedulerLoading } = useDataRefresh( const { loading: schedulerLoading } = useDataRefresh(
'scheduler-list', 'scheduler-list',
loadSchedulerList, loadSchedulerList,
5000, // 5 3000, // 3
true // true //
) )
</script> </script>
@@ -196,8 +208,20 @@ const { loading: schedulerLoading } = useDataRefresh(
<td> <td>
{{ scheduler.provider }} {{ scheduler.provider }}
</td> </td>
<td> <td class="scheduler-task-cell">
{{ scheduler.name }} <div>{{ scheduler.name }}</div>
<div v-if="isScheduleRunning(scheduler)" class="scheduler-progress">
<VProgressLinear
:model-value="getScheduleProgressValue(scheduler)"
color="primary"
height="4"
rounded
/>
<div class="scheduler-progress-meta">
<span>{{ getScheduleProgressText(scheduler) || scheduler.status }}</span>
<strong>{{ Math.round(getScheduleProgressValue(scheduler)) }}%</strong>
</div>
</div>
</td> </td>
<td> <td>
<VChip :color="getSchedulerColor(scheduler.status)"> <VChip :color="getSchedulerColor(scheduler.status)">
@@ -210,7 +234,7 @@ const { loading: schedulerLoading } = useDataRefresh(
<td> <td>
<VBtn <VBtn
size="small" size="small"
:disabled="scheduler.status === t('setting.scheduler.running')" :disabled="isScheduleRunning(scheduler)"
@click="runCommand(scheduler.id)" @click="runCommand(scheduler.id)"
> >
<template #prepend> <template #prepend>
@@ -237,7 +261,15 @@ const { loading: schedulerLoading } = useDataRefresh(
<div class="mobile-scheduler-view d-md-none"> <div class="mobile-scheduler-view d-md-none">
<div v-if="mobileSchedulerCards.length" class="mobile-scheduler-list"> <div v-if="mobileSchedulerCards.length" class="mobile-scheduler-list">
<article <article
v-for="{ scheduler, visual, statusText, statusVariant } in mobileSchedulerCards" v-for="{
scheduler,
visual,
statusText,
statusVariant,
isRunning,
progressText,
progressValue,
} in mobileSchedulerCards"
:key="scheduler.id" :key="scheduler.id"
class="mobile-scheduler-card" class="mobile-scheduler-card"
:style="{ :style="{
@@ -262,12 +294,20 @@ const { loading: schedulerLoading } = useDataRefresh(
icon icon
class="mobile-scheduler-run-btn" class="mobile-scheduler-run-btn"
:aria-label="t('setting.scheduler.execute')" :aria-label="t('setting.scheduler.execute')"
:disabled="scheduler.status === t('setting.scheduler.running')" :disabled="isRunning"
@click="runCommand(scheduler.id)" @click="runCommand(scheduler.id)"
> >
<VIcon icon="mdi-play" size="24" /> <VIcon icon="mdi-play" size="24" />
</VBtn> </VBtn>
</div> </div>
<div v-if="isRunning" class="mobile-scheduler-progress">
<VProgressLinear :model-value="progressValue" color="primary" height="4" rounded />
<div class="scheduler-progress-meta">
<span>{{ progressText || scheduler.status }}</span>
<strong>{{ Math.round(progressValue) }}%</strong>
</div>
</div>
</article> </article>
</div> </div>
@@ -301,6 +341,35 @@ const { loading: schedulerLoading } = useDataRefresh(
font-size: 15px; font-size: 15px;
} }
.scheduler-task-cell {
min-inline-size: 220px;
}
.scheduler-progress {
margin-block-start: 8px;
max-inline-size: 320px;
}
.scheduler-progress-meta {
display: flex;
justify-content: space-between;
margin-block-start: 4px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 11px;
gap: 12px;
}
.scheduler-progress-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scheduler-progress-meta strong {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.mobile-scheduler-view { .mobile-scheduler-view {
min-block-size: 100%; min-block-size: 100%;
padding: 12px 18px calc(22px + env(safe-area-inset-bottom)); padding: 12px 18px calc(22px + env(safe-area-inset-bottom));
@@ -370,6 +439,12 @@ const { loading: schedulerLoading } = useDataRefresh(
gap: 14px; gap: 14px;
} }
.mobile-scheduler-progress {
min-inline-size: 0;
margin-block-start: 2px;
grid-column: 2 / -1;
}
.mobile-scheduler-status { .mobile-scheduler-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;