mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-28 02:51:56 +08:00
Animate dashboard metrics and standardize card sizing
This commit is contained in:
@@ -13,6 +13,12 @@ const props = defineProps({
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -49,8 +55,7 @@ const getImgUrl = computed(() => {
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="backdrop-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
@@ -59,10 +64,18 @@ const getImgUrl = computed(() => {
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -94,7 +107,36 @@ const getImgUrl = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.backdrop-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-placeholder,
|
||||
.backdrop-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.backdrop-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,12 @@ const imageLoaded = ref(false)
|
||||
// 图片是否加载错误
|
||||
const imageError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -159,8 +165,7 @@ onMounted(async () => {
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="library-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
@@ -169,10 +174,18 @@ onMounted(async () => {
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -193,7 +206,36 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.library-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-placeholder,
|
||||
.library-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.library-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,12 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '2 / 3',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -50,8 +56,7 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="poster-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card outline-none ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
@@ -61,7 +66,8 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@@ -78,7 +84,7 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
@@ -101,7 +107,34 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.poster-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.poster-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-responsive__sizer) {
|
||||
padding-bottom: 150%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
41
src/composables/useDashboardMotion.ts
Normal file
41
src/composables/useDashboardMotion.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { TransitionPresets, usePreferredReducedMotion, useTransition, type UseTransitionOptions } from '@vueuse/core'
|
||||
import { computed, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const fileSizeUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
|
||||
|
||||
export function useDashboardMotionDisabled() {
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
|
||||
return computed(() => preferredMotion.value === 'reduce')
|
||||
}
|
||||
|
||||
export function useAnimatedDashboardNumber(source: MaybeRefOrGetter<number>, options: UseTransitionOptions = {}) {
|
||||
const disabled = useDashboardMotionDisabled()
|
||||
|
||||
return useTransition(source, {
|
||||
duration: 420,
|
||||
transition: TransitionPresets.easeOutQuart,
|
||||
disabled,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDashboardCount(value: number) {
|
||||
return Math.round(Math.max(Number(value) || 0, 0)).toLocaleString()
|
||||
}
|
||||
|
||||
export function formatDashboardFileSize(bytes: number, decimals = 2, targetBytes = bytes) {
|
||||
let size = Math.abs(Number(targetBytes) || Number(bytes) || 0)
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < fileSizeUnits.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
const divisor = 1024 ** unitIndex
|
||||
const value = (Math.abs(Number(bytes) || 0) / divisor).toFixed(decimals)
|
||||
const prefix = bytes < 0 ? '-' : ''
|
||||
|
||||
return `${prefix}${value} ${fileSizeUnits[unitIndex]}`
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
@@ -39,6 +40,10 @@ const series = ref([
|
||||
|
||||
// 当前值
|
||||
const current = ref(0)
|
||||
const animatedCurrent = useAnimatedDashboardNumber(current, {
|
||||
duration: 520,
|
||||
})
|
||||
const animatedCurrentText = computed(() => Math.round(animatedCurrent.value).toLocaleString())
|
||||
|
||||
const chartOptions = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
@@ -109,7 +114,7 @@ async function loadCpuData() {
|
||||
if (!props.allowRefresh) return
|
||||
try {
|
||||
// 请求数据
|
||||
current.value = (await api.get('dashboard/cpu')) ?? 0
|
||||
current.value = Number(await api.get('dashboard/cpu')) || 0
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
// 添加到序列
|
||||
@@ -141,7 +146,9 @@ useKeepAliveRefresh(refresh)
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="line" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}:{{ current }}%</p>
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
{{ t('dashboard.current') }}:{{ animatedCurrentText }}%
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -159,4 +166,8 @@ useKeepAliveRefresh(refresh)
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-chart-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,44 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { MediaStatistic } from '@/api/types'
|
||||
import { formatDashboardCount, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const statistics = ref<{ [key: string]: string }[]>([])
|
||||
const movieCount = ref(0)
|
||||
const tvCount = ref(0)
|
||||
const episodeCount = ref<number | null>(null)
|
||||
const userCount = ref(0)
|
||||
|
||||
const animatedMovieCount = useAnimatedDashboardNumber(movieCount, {
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const animatedTvCount = useAnimatedDashboardNumber(tvCount, {
|
||||
delay: 60,
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const animatedEpisodeCount = useAnimatedDashboardNumber(computed(() => episodeCount.value ?? 0), {
|
||||
delay: 120,
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const animatedUserCount = useAnimatedDashboardNumber(userCount, {
|
||||
delay: 180,
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const statistics = computed(() => [
|
||||
{
|
||||
title: t('mediaType.movie'),
|
||||
stats: formatDashboardCount(animatedMovieCount.value),
|
||||
icon: 'mdi-movie-roll',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
title: t('mediaType.tv'),
|
||||
stats: formatDashboardCount(animatedTvCount.value),
|
||||
icon: 'mdi-television-box',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.episodes'),
|
||||
stats: episodeCount.value == null ? t('common.notFetched') : formatDashboardCount(animatedEpisodeCount.value),
|
||||
icon: 'mdi-television-classic',
|
||||
color: 'warning',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.users'),
|
||||
stats: formatDashboardCount(animatedUserCount.value),
|
||||
icon: 'mdi-account',
|
||||
color: 'info',
|
||||
},
|
||||
])
|
||||
|
||||
// 调用API加载媒体统计数据
|
||||
async function loadMediaStatistic() {
|
||||
try {
|
||||
const res: MediaStatistic = await api.get('dashboard/statistic')
|
||||
|
||||
statistics.value = [
|
||||
{
|
||||
title: t('mediaType.movie'),
|
||||
stats: res.movie_count.toLocaleString(),
|
||||
icon: 'mdi-movie-roll',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
title: t('mediaType.tv'),
|
||||
stats: res.tv_count.toLocaleString(),
|
||||
icon: 'mdi-television-box',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.episodes'),
|
||||
stats: res.episode_count == null ? t('common.notFetched') : res.episode_count.toLocaleString(),
|
||||
icon: 'mdi-television-classic',
|
||||
color: 'warning',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.users'),
|
||||
stats: res.user_count.toLocaleString(),
|
||||
icon: 'mdi-account',
|
||||
color: 'info',
|
||||
},
|
||||
]
|
||||
movieCount.value = Number(res.movie_count) || 0
|
||||
tvCount.value = Number(res.tv_count) || 0
|
||||
episodeCount.value = res.episode_count == null ? null : Number(res.episode_count) || 0
|
||||
userCount.value = Number(res.user_count) || 0
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -73,7 +101,7 @@ onActivated(() => {
|
||||
<span class="text-caption">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="text-h6">{{ item.stats }}</span>
|
||||
<span class="dashboard-number text-h6">{{ item.stats }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
@@ -81,3 +109,9 @@ onActivated(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { formatBytes } from '@/@core/utils/formatters'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
@@ -42,6 +42,10 @@ const series = ref([
|
||||
const usedMemory = ref(0)
|
||||
// 内存使用百分比
|
||||
const memoryUsage = ref(0)
|
||||
const animatedUsedMemory = useAnimatedDashboardNumber(usedMemory, {
|
||||
duration: 650,
|
||||
})
|
||||
const animatedUsedMemoryText = computed(() => formatDashboardFileSize(animatedUsedMemory.value, 2, usedMemory.value))
|
||||
|
||||
const chartOptions = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
@@ -115,7 +119,9 @@ async function loadMemoryData() {
|
||||
if (!props.allowRefresh) return
|
||||
try {
|
||||
// 请求数据
|
||||
;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
|
||||
const [memory, usage]: [number, number] = await api.get('dashboard/memory')
|
||||
usedMemory.value = Number(memory) || 0
|
||||
memoryUsage.value = Number(usage) || 0
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
series.value[0].data.push(memoryUsage.value)
|
||||
@@ -146,7 +152,9 @@ useKeepAliveRefresh(refresh)
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}:{{ formatBytes(usedMemory) }}</p>
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
{{ t('dashboard.current') }}:{{ animatedUsedMemoryText }}
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -163,4 +171,8 @@ useKeepAliveRefresh(refresh)
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-chart-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
@@ -45,15 +46,16 @@ const series = ref([
|
||||
// 当前值
|
||||
const currentUpload = ref(0)
|
||||
const currentDownload = ref(0)
|
||||
|
||||
// 格式化流量显示
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B/s'
|
||||
const k = 1024
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
const animatedCurrentUpload = useAnimatedDashboardNumber(currentUpload, {
|
||||
duration: 520,
|
||||
})
|
||||
const animatedCurrentDownload = useAnimatedDashboardNumber(currentDownload, {
|
||||
duration: 520,
|
||||
})
|
||||
const animatedCurrentUploadText = computed(() => `${formatDashboardFileSize(animatedCurrentUpload.value, 2, currentUpload.value)}/s`)
|
||||
const animatedCurrentDownloadText = computed(
|
||||
() => `${formatDashboardFileSize(animatedCurrentDownload.value, 2, currentDownload.value)}/s`,
|
||||
)
|
||||
|
||||
const chartOptions = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
@@ -139,8 +141,8 @@ async function getNetworkUsage() {
|
||||
try {
|
||||
// 请求数据 - 接口返回 [上行流量, 下行流量]
|
||||
const data: [number, number] = (await api.get('dashboard/network')) ?? [0, 0]
|
||||
currentUpload.value = data[0] || 0
|
||||
currentDownload.value = data[1] || 0
|
||||
currentUpload.value = Number(data[0]) || 0
|
||||
currentDownload.value = Number(data[1]) || 0
|
||||
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
@@ -180,13 +182,13 @@ useKeepAliveRefresh(refresh)
|
||||
<VApexChart type="line" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<p class="text-center font-weight-medium mb-0">
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
<span class="text-warning">{{ t('dashboard.upload') }}</span
|
||||
>:{{ formatBytes(currentUpload) }}
|
||||
>:{{ animatedCurrentUploadText }}
|
||||
</p>
|
||||
<p class="text-center font-weight-medium mb-0">
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
<span class="text-info">{{ t('dashboard.download') }}</span
|
||||
>:{{ formatBytes(currentDownload) }}
|
||||
>:{{ animatedCurrentDownloadText }}
|
||||
</p>
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -206,4 +208,8 @@ useKeepAliveRefresh(refresh)
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-chart-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -101,4 +101,5 @@ useDataRefresh(
|
||||
.card-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
@@ -36,12 +36,48 @@ const downloadInfo = ref<DownloaderInfo>({
|
||||
free_space: 0,
|
||||
})
|
||||
|
||||
const animatedUploadSpeed = useAnimatedDashboardNumber(computed(() => downloadInfo.value.upload_speed), {
|
||||
duration: 520,
|
||||
})
|
||||
|
||||
const animatedDownloadSpeed = useAnimatedDashboardNumber(computed(() => downloadInfo.value.download_speed), {
|
||||
duration: 520,
|
||||
})
|
||||
|
||||
const animatedUploadSize = useAnimatedDashboardNumber(computed(() => downloadInfo.value.upload_size), {
|
||||
delay: 80,
|
||||
duration: 760,
|
||||
})
|
||||
|
||||
const animatedDownloadSize = useAnimatedDashboardNumber(computed(() => downloadInfo.value.download_size), {
|
||||
delay: 130,
|
||||
duration: 760,
|
||||
})
|
||||
|
||||
const animatedFreeSpace = useAnimatedDashboardNumber(computed(() => downloadInfo.value.free_space), {
|
||||
delay: 180,
|
||||
duration: 760,
|
||||
})
|
||||
|
||||
const uploadSpeedText = computed(() => `${formatDashboardFileSize(animatedUploadSpeed.value, 2, downloadInfo.value.upload_speed)}/s`)
|
||||
const downloadSpeedText = computed(() => `${formatDashboardFileSize(animatedDownloadSpeed.value, 2, downloadInfo.value.download_speed)}/s`)
|
||||
|
||||
// 显示项
|
||||
const infoItems = ref([
|
||||
const infoItems = computed(() => [
|
||||
{
|
||||
avatar: '',
|
||||
title: '',
|
||||
amount: '',
|
||||
avatar: 'mdi-cloud-upload',
|
||||
title: t('dashboard.speed.totalUpload'),
|
||||
amount: formatDashboardFileSize(animatedUploadSize.value, 2, downloadInfo.value.upload_size),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-download-box',
|
||||
title: t('dashboard.speed.totalDownload'),
|
||||
amount: formatDashboardFileSize(animatedDownloadSize.value, 2, downloadInfo.value.download_size),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-content-save',
|
||||
title: t('dashboard.speed.freeSpace'),
|
||||
amount: formatDashboardFileSize(animatedFreeSpace.value, 2, downloadInfo.value.free_space),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -54,24 +90,13 @@ async function loadDownloaderInfo() {
|
||||
try {
|
||||
const res: DownloaderInfo = await api.get('dashboard/downloader')
|
||||
|
||||
downloadInfo.value = res
|
||||
infoItems.value = [
|
||||
{
|
||||
avatar: 'mdi-cloud-upload',
|
||||
title: t('dashboard.speed.totalUpload'),
|
||||
amount: formatFileSize(res.upload_size),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-download-box',
|
||||
title: t('dashboard.speed.totalDownload'),
|
||||
amount: formatFileSize(res.download_size),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-content-save',
|
||||
title: t('dashboard.speed.freeSpace'),
|
||||
amount: formatFileSize(res.free_space),
|
||||
},
|
||||
]
|
||||
downloadInfo.value = {
|
||||
download_speed: Number(res.download_speed) || 0,
|
||||
upload_speed: Number(res.upload_speed) || 0,
|
||||
download_size: Number(res.download_size) || 0,
|
||||
upload_size: Number(res.upload_size) || 0,
|
||||
free_space: Number(res.free_space) || 0,
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -94,8 +119,8 @@ const { loading } = useDataRefresh(
|
||||
|
||||
<VCardText class="dashboard-work-content pt-4">
|
||||
<div>
|
||||
<p class="text-h5 me-2">↑{{ formatFileSize(downloadInfo.upload_speed) }}/s</p>
|
||||
<p class="text-h4 me-2">↓{{ formatFileSize(downloadInfo.download_speed) }}/s</p>
|
||||
<p class="dashboard-speed-number text-h5 me-2">↑{{ uploadSpeedText }}</p>
|
||||
<p class="dashboard-speed-number text-h4 me-2">↓{{ downloadSpeedText }}</p>
|
||||
</div>
|
||||
<VList class="card-list mt-9">
|
||||
<VListItem v-for="item in infoItems" :key="item.title">
|
||||
@@ -109,7 +134,7 @@ const { loading } = useDataRefresh(
|
||||
|
||||
<template #append>
|
||||
<div>
|
||||
<h6 class="text-sm font-weight-medium mb-2">
|
||||
<h6 class="dashboard-speed-number text-sm font-weight-medium mb-2">
|
||||
{{ item.amount }}
|
||||
</h6>
|
||||
</div>
|
||||
@@ -135,4 +160,9 @@ const { loading } = useDataRefresh(
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-speed-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from 'vuetify'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Storage } from '@/api/types'
|
||||
import trophy from '@images/misc/storage.png'
|
||||
import triangleDark from '@images/misc/triangle-dark.png'
|
||||
import triangleLight from '@images/misc/triangle-light.png'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -22,16 +23,31 @@ const used = ref(0)
|
||||
|
||||
// 计算已使用存储空间百分比,精确到小数点后1位
|
||||
const usedPercent = computed(() => {
|
||||
return Math.round((used.value / (storage.value || 1)) * 1000) / 10
|
||||
const percent = Math.round((used.value / (storage.value || 1)) * 1000) / 10
|
||||
|
||||
return Math.min(Math.max(percent, 0), 100)
|
||||
})
|
||||
|
||||
const animatedStorage = useAnimatedDashboardNumber(storage, {
|
||||
duration: 900,
|
||||
})
|
||||
|
||||
const animatedUsedPercent = useAnimatedDashboardNumber(usedPercent, {
|
||||
delay: 80,
|
||||
duration: 780,
|
||||
})
|
||||
|
||||
const animatedStorageText = computed(() => formatDashboardFileSize(animatedStorage.value, 2, storage.value))
|
||||
const animatedUsedPercentValue = computed(() => Math.round(animatedUsedPercent.value * 10) / 10)
|
||||
const animatedUsedPercentText = computed(() => animatedUsedPercentValue.value.toFixed(1))
|
||||
|
||||
// 调用API,查询存储空间
|
||||
async function getStorage() {
|
||||
try {
|
||||
const res: Storage = await api.get('dashboard/storage')
|
||||
|
||||
storage.value = res.total_storage
|
||||
used.value = res.used_storage
|
||||
storage.value = Number(res.total_storage) || 0
|
||||
used.value = Number(res.used_storage) || 0
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -54,12 +70,18 @@ onActivated(() => {
|
||||
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<h5 class="text-2xl font-weight-medium text-primary">
|
||||
{{ formatFileSize(storage) }}
|
||||
<h5 class="animated-storage-value text-2xl font-weight-medium text-primary">
|
||||
{{ animatedStorageText }}
|
||||
</h5>
|
||||
<p class="mt-2">{{ t('storage.usedPercent', { percent: usedPercent }) }} 🚀</p>
|
||||
<p class="mt-2">{{ t('storage.usedPercent', { percent: animatedUsedPercentText }) }} 🚀</p>
|
||||
<p class="mt-1">
|
||||
<VProgressLinear :model-value="usedPercent" color="primary" />
|
||||
<VProgressLinear
|
||||
:model-value="animatedUsedPercentValue"
|
||||
class="animated-storage-progress"
|
||||
color="primary"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
</p>
|
||||
</VCardText>
|
||||
<!-- Trophy -->
|
||||
@@ -84,4 +106,12 @@ onActivated(() => {
|
||||
inset-inline-end: 2rem;
|
||||
}
|
||||
|
||||
.animated-storage-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.animated-storage-progress {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import { formatDashboardCount, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -27,6 +28,7 @@ const options = controlledComputed(
|
||||
chart: {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: false },
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
@@ -97,6 +99,11 @@ const series = ref([{ data: [0, 0, 0, 0, 0, 0, 0] }])
|
||||
|
||||
// 总数
|
||||
const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0))
|
||||
const animatedTotalCount = useAnimatedDashboardNumber(totalCount, {
|
||||
delay: 100,
|
||||
duration: 850,
|
||||
})
|
||||
const animatedTotalCountText = computed(() => formatDashboardCount(animatedTotalCount.value))
|
||||
|
||||
// 调用API接口获取数据近7天数据
|
||||
async function getWeeklyData() {
|
||||
@@ -136,10 +143,10 @@ onActivated(() => {
|
||||
<VApexChart type="bar" :options="options" :series="series" height="100%" />
|
||||
</div>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<h5 class="text-h5 me-4">
|
||||
{{ totalCount }}
|
||||
<h5 class="dashboard-weekly-count text-h5 me-4">
|
||||
{{ animatedTotalCountText }}
|
||||
</h5>
|
||||
<p>{{ t('dashboard.weeklyOverviewDescription', { count: totalCount }) }} 😎</p>
|
||||
<p>{{ t('dashboard.weeklyOverviewDescription', { count: animatedTotalCountText }) }} 😎</p>
|
||||
</div>
|
||||
<div>
|
||||
<VBtn block to="/history"> {{ t('common.viewDetails') }} </VBtn>
|
||||
@@ -160,4 +167,8 @@ onActivated(() => {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-weekly-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||
|
||||
Reference in New Issue
Block a user