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

View File

@@ -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
}
// 消息通知

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

View File

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

View File

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

View File

@@ -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: '快捷操作',

View File

@@ -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: '快捷操作',

View File

@@ -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: [],
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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