feat(front): add AboutChartTooltip and ImageCompressResult components for enhanced file upload statistics and visualization

This commit is contained in:
keven1024
2025-06-02 01:17:58 +08:00
parent 1224d5e72e
commit e88e6cb46a
3 changed files with 417 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { filesize } from "filesize";
const props = defineProps<{
data: { name: string; value: string; color: string }[];
title: string;
}>();
const dataKeyMap = {
file_size: "文件大小",
file_num: "文件数量",
processed: "处理数量",
failed: "失败数量",
};
</script>
<template>
<div class="rounded-md bg-white p-2 flex flex-col gap-2">
<div class="text-sm font-medium">{{ title }}</div>
<div v-for="(item, index) in data" :key="index">
<div class="flex flex-row items-center gap-2">
<div
class="h-5 w-2 rounded-full"
:style="{ backgroundColor: item.color ?? '#222' }"
></div>
<div class="text-xs font-medium">
{{ dataKeyMap?.[item.name as keyof typeof dataKeyMap] ?? item.name }}
</div>
<div class="text-sm">
{{
["file_size"]?.includes(item?.name)
? filesize(item.value)
: item.value
}}
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import { AsyncButton, Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { filesize } from "filesize";
import useAppShare from "~/composables/useShare";
const props = defineProps<{
data: { file: File; config: any; handle_type: string; file_id: string };
}>();
const { data } = useQuery({
queryKey: ["create-image-compress", props?.data?.file_id],
queryFn: async () => {
const { file_id } = props?.data || {};
const data = await $fetch<{
code: number;
data: {
id?: string;
};
}>(`/api/image/compress`, {
method: "POST",
body: {
file_id,
},
});
return data?.data;
},
staleTime: Infinity,
});
const taskId = computed(() => data?.value?.id);
const { data: taskData, refetch } = useQuery({
queryKey: ["image-compress-task", taskId],
queryFn: async () => {
const data = await $fetch<{
code: number;
data: {
result: {
old_file: {
id: string;
size: number;
};
new_file: {
id: string;
size: number;
};
}[];
status: "success" | "processing" | "failed";
};
}>(`/api/image/compress/${taskId.value}`);
return data?.data;
},
enabled: !!taskId.value,
});
const { downloadFile, createFileShare } = useAppShare();
const { counter, pause } = useInterval(2000, { controls: true });
watch(
() => counter.value,
() => {
if (taskData.value?.status === "success") {
pause();
return;
}
refetch();
},
);
</script>
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg">上传成功</h2>
<div class="flex flex-col gap-1 items-center">
<div class="flex flex-col h-30 items-center justify-center">
<FilePreviewView :value="props?.data?.file" />
</div>
</div>
<div
v-if="taskData?.status === 'success'"
class="flex flex-col gap-2"
v-for="item in taskData?.result"
>
<div
class="bg-white/80 p-2 rounded-md w-full flex flex-row items-center justify-between"
>
<div class="flex flex-row gap-2 items-center">
<div
class="flex flex-row items-center justify-center rounded-md bg-black/5 p-2"
>
<LucideImage />
</div>
{{ props?.data?.file?.name }}
<div class="flex flex-row gap-2 items-center text-sm">
<span class="opacity-75">{{
filesize(item.new_file.size ?? 0)
}}</span>
<span
class="bg-green-200 text-green-600 rounded-md px-1 py-0.5 flex flex-row gap-1 items-center text-xs"
>
<LucideChevronDown class="size-4" />
{{
((1 - item.new_file.size / item.old_file.size) * 100).toFixed(
2,
)
}}%
</span>
</div>
</div>
<AsyncButton
variant="outline"
class="bg-black/5"
size="icon"
@click="
async () => {
const data = await createFileShare({
file_id: item.new_file.id,
config: {
download_nums: 1,
expire_time: 60,
has_pickup_code: false,
has_password: false,
},
file_name: props?.data?.file?.name,
});
const { id } = data?.data || {};
if (!id) {
return;
}
await downloadFile(id);
}
"
>
<LucideDownload />
</AsyncButton>
</div>
</div>
<div v-else class="flex flex-col gap-2">
<Skeleton
class="w-full h-16 flex flex-row items-center justify-between"
v-for="i in 3"
/>
</div>
</div>
</template>

233
front/pages/about.vue Normal file
View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { CurveType } from "@unovis/ts";
import { AreaChart } from "@/components/ui/chart-area";
import { cx } from "class-variance-authority";
import { useQuery } from "@tanstack/vue-query";
import { Skeleton } from "@/components/ui/skeleton";
import AboutChartTooltip from "@/components/AboutChartTooltip.vue";
import { filesize } from "filesize";
import SparkMD5 from "spark-md5";
const { data, isLoading } = useQuery({
queryKey: ["stat"],
queryFn: async () => {
const data = await $fetch<{ data: any }>("/api/stat");
return data.data;
},
});
const chartTabs = computed(() => {
return [
{
label: "文件",
value: "storage",
total:
data.value?.chart?.storage?.reduce(
(acc: number, curr: { file_size: number; file_num: number }) =>
acc + curr.file_num,
0,
) ?? 0,
},
{
label: "任务",
value: "queue",
total:
data.value?.chart?.queue?.reduce(
(acc: number, curr: { processed: number; failed: number }) =>
acc + curr.processed + curr.failed,
0,
) ?? 0,
},
];
});
const currentFileSize = computed(() => {
return (
data.value?.chart?.storage?.reduce(
(acc: number, curr: { file_size: number; file_num: number }) =>
acc + curr.file_size,
0,
) ?? 0
);
});
const currentChartTab = ref<"storage" | "queue">("storage");
const currentChartData = computed(() => {
const { storage, queue } = data.value?.chart || {};
if (currentChartTab.value === "storage") {
return {
data: storage,
index: "date",
categories: ["file_size", "file_num"],
colors: ["#22d3ee", "#c084fc"],
};
}
return {
data: queue,
index: "date",
categories: ["processed", "failed"],
colors: ["#4ade80", "#f87171"],
};
});
const genUserAvatar = ({ email }: { email: string }) => {
if (!email) {
return "/logo.png";
}
return `https://www.gravatar.com/avatar/${SparkMD5.hash(email)}?d=retro`;
};
const handleUserClick = ({ url, email }: { url: string; email: string }) => {
if (url) {
return navigateTo(url, { external: true });
}
if (email) {
return navigateTo(`mailto:${email}`, { external: true });
}
return null;
};
const users = computed(() => {
const { email, name, url } = data.value?.admin || {};
return [
...(!!name
? [
{
title: "站长",
email,
name,
url: url ?? (email ? `mailto:${email}` : null),
},
]
: []),
{
title: "作者",
name: "keven1024",
email: "keven@fudaoyuan.icu",
url: "https://github.com/keven1024",
},
];
});
</script>
<template>
<div
class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200 my-5 flex flex-col gap-5"
>
<div class="text-xl font-normal">关于</div>
<div class="flex flex-col gap-2 items-center">
<NuxtImg src="/logo.png" class="size-20 rounded-xl" />
<div class="text-xl">015</div>
<div class="text-sm opacity-75">
015
是一个开源的临时文件分享平台项目支持临时大文件切片上传临时文本上传下载分享
</div>
</div>
<div class="font-semibold">系统信息</div>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-20 rounded-xl" v-for="i in 2" :key="i" />
</div>
</template>
<template v-else>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3">
<div class="opacity-75 text-xs">系统版本</div>
<div class="text-xl font-semibold">
{{ data?.version }}
</div>
</div>
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3">
<div class="opacity-75 text-xs">存储空间</div>
<div class="text-right flex flex-row items-baseline">
<span class="text-lg font-semibold">{{
filesize(currentFileSize ?? 0)
}}</span>
<span class="text-md opacity-75"
>/ {{ filesize(data?.max_limit?.file_size ?? 0) }}</span
>
</div>
<div class="rounded-full w-full h-1 bg-black/10">
<div
class="rounded-full h-full bg-blue-500"
:style="{
width: `${(currentFileSize / (data?.max_limit?.file_size ?? 0)) * 100}%`,
}"
></div>
</div>
</div>
</div>
</template>
<div class="font-semibold">分析</div>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-96 rounded-xl" />
</div>
</template>
<template v-else>
<div class="flex flex-col gap-2 bg-white/50 w-full rounded-xl py-5">
<div class="flex flex-row gap-2 px-5">
<div
:class="
cx(
'rounded-md min-w-30 flex flex-col px-3 py-1.5 cursor-pointer',
currentChartTab === tab.value && 'bg-black/10',
)
"
v-for="tab in chartTabs"
:key="tab.value"
@click="
() => {
currentChartTab = tab.value as 'storage' | 'queue';
}
"
>
<div class="opacity-75 text-xs">{{ tab.label }}</div>
<div class="text-lg font-semibold">{{ tab.total }}</div>
</div>
</div>
<AreaChart
v-if="currentChartData"
class="h-64 w-full"
:key="currentChartTab"
:index="currentChartData.index"
:data="currentChartData.data"
:categories="currentChartData.categories"
:show-grid-line="false"
:show-legend="false"
:colors="currentChartData.colors"
:custom-tooltip="AboutChartTooltip"
:curve-type="CurveType.CatmullRom"
/>
</div>
</template>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-20 rounded-xl" v-for="i in 2" :key="i" />
</div>
</template>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="flex flex-col gap-3" v-for="user in users" :key="user.name">
<div class="font-semibold">{{ user.title }}</div>
<div
class="rounded-xl bg-white/50 hover:bg-white/40 flex-1 flex flex-row items-center gap-2 p-3 cursor-pointer"
@click="
() => {
handleUserClick(user);
}
"
>
<div class="size-10 rounded-full bg-white/50">
<NuxtImg
:src="genUserAvatar(user)"
class="size-full rounded-full"
/>
</div>
<div class="text-md font-semibold">{{ user.name }}</div>
</div>
</div>
</div>
</template>
</div>
</template>