mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-15 09:23:21 +08:00
feat(dashboard): add memory and scheduler progress interfaces, enhance dashboard layout and responsiveness
This commit is contained in:
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
// 定时服务信息
|
||||
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
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
|
||||
85
src/composables/useScheduleProgress.ts
Normal file
85
src/composables/useScheduleProgress.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '快捷操作',
|
||||
|
||||
@@ -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: '快捷操作',
|
||||
|
||||
@@ -56,6 +56,9 @@ type DashboardConfigNormalizer<T> = (value: unknown) => T | undefined
|
||||
type DashboardConfigRemoteValueBuilder<T> = (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<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 4 },
|
||||
rows: 8,
|
||||
rows: 7,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -161,7 +164,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 8 },
|
||||
rows: 8,
|
||||
rows: 7,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -179,7 +182,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 4 },
|
||||
rows: 15,
|
||||
rows: 12,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -197,7 +200,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, sm: 3, md: 4 },
|
||||
rows: 11,
|
||||
rows: DASHBOARD_RESOURCE_CHART_ROWS,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
@@ -206,7 +209,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, sm: 3, md: 4 },
|
||||
rows: 11,
|
||||
rows: DASHBOARD_RESOURCE_CHART_ROWS,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
</VCardItem>
|
||||
<VCardText class="dashboard-chart-content">
|
||||
<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 class="dashboard-chart-footer">
|
||||
<span>{{ t('dashboard.averageUsage') }}</span>
|
||||
@@ -200,7 +214,7 @@ useKeepAliveRefresh(refresh)
|
||||
|
||||
.dashboard-chart-plot {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 135px;
|
||||
min-block-size: 120px;
|
||||
}
|
||||
|
||||
.dashboard-chart-current,
|
||||
|
||||
@@ -122,7 +122,7 @@ onActivated(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 190px;
|
||||
min-block-size: 160px;
|
||||
}
|
||||
|
||||
.dashboard-summary-content {
|
||||
@@ -142,8 +142,8 @@ onActivated(() => {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding-inline: 1.35rem;
|
||||
gap: 0.7rem;
|
||||
padding-inline: 1.1rem;
|
||||
}
|
||||
|
||||
.dashboard-stat-item:first-child {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import type { DashboardMemoryInfo } from '@/api/types'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
@@ -34,25 +35,45 @@ const variableTheme = controlledComputed(
|
||||
// 时间序列
|
||||
const series = ref([
|
||||
{
|
||||
name: t('dashboard.memoryUsed'),
|
||||
data: [0],
|
||||
},
|
||||
{
|
||||
name: t('dashboard.memoryCached'),
|
||||
data: [0],
|
||||
},
|
||||
])
|
||||
|
||||
// 占用的内存
|
||||
// 已使用内存
|
||||
const usedMemory = ref(0)
|
||||
// 缓存内存
|
||||
const cachedMemory = ref(0)
|
||||
// 可用内存
|
||||
const availableMemory = ref(0)
|
||||
// 总内存
|
||||
const totalMemory = ref(0)
|
||||
// 内存使用百分比
|
||||
const memoryUsage = ref(0)
|
||||
const animatedUsedMemory = useAnimatedDashboardNumber(usedMemory, {
|
||||
duration: 650,
|
||||
})
|
||||
const animatedCachedMemory = useAnimatedDashboardNumber(cachedMemory, {
|
||||
duration: 650,
|
||||
})
|
||||
const animatedAvailableMemory = useAnimatedDashboardNumber(availableMemory, {
|
||||
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 animatedCachedMemoryText = computed(() =>
|
||||
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 availableMemoryText = computed(() => formatDashboardFileSize(availableMemory.value, 2, availableMemory.value))
|
||||
|
||||
const chartOptions = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => `${vuetifyTheme.name.value}:${totalMemory.value}`,
|
||||
() => {
|
||||
const axisLabelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${variableTheme.value['medium-emphasis-opacity']})`
|
||||
|
||||
@@ -64,6 +85,7 @@ const chartOptions = controlledComputed(
|
||||
foreColor: axisLabelColor,
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
legend: { show: false },
|
||||
grid: {
|
||||
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
|
||||
variableTheme.value['border-opacity']
|
||||
@@ -77,7 +99,7 @@ const chartOptions = controlledComputed(
|
||||
},
|
||||
padding: {
|
||||
top: -10,
|
||||
left: -7,
|
||||
left: 8,
|
||||
right: 5,
|
||||
bottom: 5,
|
||||
},
|
||||
@@ -87,11 +109,14 @@ const chartOptions = controlledComputed(
|
||||
lineCap: 'butt',
|
||||
curve: 'smooth',
|
||||
},
|
||||
colors: [currentTheme.value.primary],
|
||||
colors: [currentTheme.value.primary, currentTheme.value.info],
|
||||
fill: {
|
||||
opacity: [0.22, 0.08],
|
||||
},
|
||||
markers: {
|
||||
size: 6,
|
||||
offsetY: 4,
|
||||
offsetX: -2,
|
||||
offsetX: 4,
|
||||
strokeWidth: 3,
|
||||
colors: ['transparent'],
|
||||
strokeColors: 'transparent',
|
||||
@@ -116,14 +141,15 @@ const chartOptions = controlledComputed(
|
||||
yaxis: {
|
||||
labels: {
|
||||
show: true,
|
||||
formatter: (value: number) => `${Math.round(value)}%`,
|
||||
minWidth: 40,
|
||||
formatter: (value: number) => formatDashboardFileSize(value, 0, totalMemory.value || value),
|
||||
style: {
|
||||
colors: axisLabelColor,
|
||||
fontSize: '10px',
|
||||
},
|
||||
},
|
||||
tickAmount: 2,
|
||||
max: 100,
|
||||
max: totalMemory.value || undefined,
|
||||
min: 0,
|
||||
},
|
||||
}
|
||||
@@ -135,14 +161,20 @@ async function loadMemoryData() {
|
||||
if (!props.allowRefresh) return
|
||||
try {
|
||||
// 请求数据
|
||||
const [memory, usage]: [number, number] = await api.get('dashboard/memory')
|
||||
usedMemory.value = Number(memory) || 0
|
||||
memoryUsage.value = Number(usage) || 0
|
||||
const memory: DashboardMemoryInfo = await api.get('dashboard/memory')
|
||||
usedMemory.value = Number(memory.used) || 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
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
series.value[0].data.push(memoryUsage.value)
|
||||
series.value[0].data.push(usedMemory.value)
|
||||
series.value[1].data.push(cachedMemory.value)
|
||||
// 序列超过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) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -176,7 +208,8 @@ useKeepAliveRefresh(refresh)
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -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));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<ScheduleInfo[]>([])
|
||||
const transferQueue = ref<TransferQueue[]>([])
|
||||
const { getScheduleProgressText, getScheduleProgressValue } = useScheduleProgress(
|
||||
schedulerList,
|
||||
'dashboard-scheduler-progress',
|
||||
)
|
||||
|
||||
interface BackgroundTaskItem {
|
||||
color: string
|
||||
@@ -33,30 +39,35 @@ interface BackgroundTaskItem {
|
||||
|
||||
// 将正在运行的服务和整理队列排在前面,再补充最近即将执行的定时任务。
|
||||
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 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 // 立即执行
|
||||
)
|
||||
</script>
|
||||
@@ -92,10 +103,8 @@ useDataRefresh(
|
||||
<template>
|
||||
<VCard class="dashboard-work-card dashboard-grid-fill">
|
||||
<VCardItem>
|
||||
<template #prepend><VIcon :icon="SCHEDULER_SHORTCUT_ICON" size="20" class="me-2" /></template>
|
||||
<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">
|
||||
|
||||
@@ -158,8 +158,8 @@ const { loading } = useDataRefresh(
|
||||
.dashboard-work-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 350px;
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
|
||||
@@ -94,10 +94,10 @@ onActivated(() => {
|
||||
|
||||
.v-card .storage-image {
|
||||
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);
|
||||
inset-block-start: 2.4rem;
|
||||
inset-inline-end: 1.5rem;
|
||||
inset-block-start: 2.7rem;
|
||||
inset-inline-end: 1.35rem;
|
||||
}
|
||||
|
||||
.dashboard-summary-card {
|
||||
@@ -105,34 +105,39 @@ onActivated(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 190px;
|
||||
min-block-size: 160px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-summary-content {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
padding-block: 0.1rem 0.85rem;
|
||||
padding-inline-end: 7rem;
|
||||
padding-block: 0 0.7rem;
|
||||
}
|
||||
|
||||
.animated-storage-value,
|
||||
.animated-storage-meta,
|
||||
.animated-storage-caption {
|
||||
padding-inline-end: 4.75rem;
|
||||
}
|
||||
|
||||
.animated-storage-value {
|
||||
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;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.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));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.animated-storage-progress-wrap {
|
||||
margin-block-start: 0.55rem;
|
||||
margin-block-start: 0.4rem;
|
||||
}
|
||||
|
||||
.animated-storage-progress {
|
||||
@@ -140,7 +145,7 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
.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));
|
||||
font-size: 0.68rem;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -4,6 +4,7 @@ import api from '@/api'
|
||||
import type { ScheduleInfo } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { isScheduleRunning, useScheduleProgress } from '@/composables/useScheduleProgress'
|
||||
|
||||
// 移动端任务卡片视觉配置。
|
||||
type SchedulerMobileVisual = {
|
||||
@@ -28,6 +29,10 @@ const $toast = useToast()
|
||||
|
||||
// 定时服务列表
|
||||
const schedulerList = ref<ScheduleInfo[]>([])
|
||||
const { getScheduleProgressText, getScheduleProgressValue } = useScheduleProgress(
|
||||
schedulerList,
|
||||
'scheduler-service-progress',
|
||||
)
|
||||
|
||||
// 移动端任务图标按后端 job id 优先匹配,避免列表顺序变化导致图标看起来随机。
|
||||
const schedulerMobileVisualRules: SchedulerMobileVisualRule[] = [
|
||||
@@ -162,19 +167,26 @@ function runCommand(id: string) {
|
||||
|
||||
// 移动端任务卡片展示模型。
|
||||
const mobileSchedulerCards = computed(() =>
|
||||
schedulerList.value.map(scheduler => ({
|
||||
scheduler,
|
||||
statusText: getMobileSchedulerStatusText(scheduler),
|
||||
statusVariant: getSchedulerStatusVariant(scheduler.status),
|
||||
visual: getMobileSchedulerVisual(scheduler),
|
||||
})),
|
||||
schedulerList.value.map(scheduler => {
|
||||
const isRunning = isScheduleRunning(scheduler)
|
||||
|
||||
return {
|
||||
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(
|
||||
'scheduler-list',
|
||||
loadSchedulerList,
|
||||
5000, // 5秒间隔
|
||||
3000, // 3秒间隔,及时发现任务启停;运行中进度由独立轮询每秒刷新
|
||||
true // 立即执行
|
||||
)
|
||||
</script>
|
||||
@@ -196,8 +208,20 @@ const { loading: schedulerLoading } = useDataRefresh(
|
||||
<td>
|
||||
{{ scheduler.provider }}
|
||||
</td>
|
||||
<td>
|
||||
{{ scheduler.name }}
|
||||
<td class="scheduler-task-cell">
|
||||
<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>
|
||||
<VChip :color="getSchedulerColor(scheduler.status)">
|
||||
@@ -210,7 +234,7 @@ const { loading: schedulerLoading } = useDataRefresh(
|
||||
<td>
|
||||
<VBtn
|
||||
size="small"
|
||||
:disabled="scheduler.status === t('setting.scheduler.running')"
|
||||
:disabled="isScheduleRunning(scheduler)"
|
||||
@click="runCommand(scheduler.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
@@ -237,7 +261,15 @@ const { loading: schedulerLoading } = useDataRefresh(
|
||||
<div class="mobile-scheduler-view d-md-none">
|
||||
<div v-if="mobileSchedulerCards.length" class="mobile-scheduler-list">
|
||||
<article
|
||||
v-for="{ scheduler, visual, statusText, statusVariant } in mobileSchedulerCards"
|
||||
v-for="{
|
||||
scheduler,
|
||||
visual,
|
||||
statusText,
|
||||
statusVariant,
|
||||
isRunning,
|
||||
progressText,
|
||||
progressValue,
|
||||
} in mobileSchedulerCards"
|
||||
:key="scheduler.id"
|
||||
class="mobile-scheduler-card"
|
||||
:style="{
|
||||
@@ -262,12 +294,20 @@ const { loading: schedulerLoading } = useDataRefresh(
|
||||
icon
|
||||
class="mobile-scheduler-run-btn"
|
||||
:aria-label="t('setting.scheduler.execute')"
|
||||
:disabled="scheduler.status === t('setting.scheduler.running')"
|
||||
:disabled="isRunning"
|
||||
@click="runCommand(scheduler.id)"
|
||||
>
|
||||
<VIcon icon="mdi-play" size="24" />
|
||||
</VBtn>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -301,6 +341,35 @@ const { loading: schedulerLoading } = useDataRefresh(
|
||||
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 {
|
||||
min-block-size: 100%;
|
||||
padding: 12px 18px calc(22px + env(safe-area-inset-bottom));
|
||||
@@ -370,6 +439,12 @@ const { loading: schedulerLoading } = useDataRefresh(
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mobile-scheduler-progress {
|
||||
min-inline-size: 0;
|
||||
margin-block-start: 2px;
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
.mobile-scheduler-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user