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