Animate dashboard metrics and standardize card sizing

This commit is contained in:
jxxghp
2026-06-25 11:28:36 +08:00
parent 530fe9d35b
commit 175f610524
13 changed files with 392 additions and 100 deletions

View File

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

View File

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

View File

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

View 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]}`
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -101,4 +101,5 @@ useDataRefresh(
.card-list::-webkit-scrollbar {
display: none;
}
</style>

View File

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

View File

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

View File

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

View File

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