From 302dbcdffeec0be84d18d79cb485f85ec2c9332a Mon Sep 17 00:00:00 2001 From: alikia2x Date: Mon, 10 Nov 2025 07:37:31 +0800 Subject: [PATCH] fix: pagination bugs for data table, added close-to-milestone list in homepage --- bun.lock | 10 + packages/elysia/routes/song/milestone.ts | 15 +- packages/temp_frontend/app/app.css | 6 +- .../app/components/SearchResults.tsx | 2 +- .../app/components/ui/progress.tsx | 29 +++ .../app/components/ui/scroll-area.tsx | 56 +++++ packages/temp_frontend/app/routes/home.tsx | 213 ++++++++++++++++- .../app/routes/song/[id]/info/data-table.tsx | 2 +- .../app/routes/song/[id]/info/index.tsx | 144 +++++------ .../app/routes/song/[id]/info/lib.ts | 224 +++++++++--------- .../app/routes/song/[id]/info/views-chart.tsx | 19 +- packages/temp_frontend/package.json | 2 + 12 files changed, 505 insertions(+), 217 deletions(-) create mode 100644 packages/temp_frontend/app/components/ui/progress.tsx create mode 100644 packages/temp_frontend/app/components/ui/scroll-area.tsx diff --git a/bun.lock b/bun.lock index 64ee960..58efc9c 100644 --- a/bun.lock +++ b/bun.lock @@ -203,6 +203,8 @@ "@nivo/line": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", @@ -825,8 +827,12 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -3091,6 +3097,10 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@react-router/dev/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], diff --git a/packages/elysia/routes/song/milestone.ts b/packages/elysia/routes/song/milestone.ts index 0125a85..5314042 100644 --- a/packages/elysia/routes/song/milestone.ts +++ b/packages/elysia/routes/song/milestone.ts @@ -3,6 +3,8 @@ import { dbMain } from "@core/drizzle"; import { bilibiliMetadata, eta, latestVideoSnapshot } from "@core/drizzle/main/schema"; import { eq, and, gte, lt, desc } from "drizzle-orm"; import serverTiming from "@elysia/middlewares/timing"; +import z from "zod"; +import { BiliVideoSchema } from "@elysia/lib/schema"; type MileStoneType = "dendou" | "densetsu" | "shinwa"; @@ -34,7 +36,18 @@ export const closeMileStoneHandler = new Elysia({ prefix: "/songs" }).use(server }, { response: { - 200: t.Array(t.Any()), + 200: z.array( + z.object({ + eta: z.object({ + aid: z.number(), + eta: z.number(), + speed: z.number(), + currentViews: z.number(), + updatedAt: z.string() + }), + bilibili_metadata: BiliVideoSchema + }) + ), 404: t.Object({ message: t.String() }) diff --git a/packages/temp_frontend/app/app.css b/packages/temp_frontend/app/app.css index 2463aee..ebdfcb2 100644 --- a/packages/temp_frontend/app/app.css +++ b/packages/temp_frontend/app/app.css @@ -112,10 +112,10 @@ input[type="number"] { --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.324 0 0); - --primary-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.875 0 0); + --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.188 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); diff --git a/packages/temp_frontend/app/components/SearchResults.tsx b/packages/temp_frontend/app/components/SearchResults.tsx index b8b17d6..e657d93 100644 --- a/packages/temp_frontend/app/components/SearchResults.tsx +++ b/packages/temp_frontend/app/components/SearchResults.tsx @@ -32,7 +32,7 @@ export function SearchResults({ results, query }: SearchResultsProps) {

没有找到相关结果。 尝试 - + 收录 ? diff --git a/packages/temp_frontend/app/components/ui/progress.tsx b/packages/temp_frontend/app/components/ui/progress.tsx new file mode 100644 index 0000000..7dbf708 --- /dev/null +++ b/packages/temp_frontend/app/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib//utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/packages/temp_frontend/app/components/ui/scroll-area.tsx b/packages/temp_frontend/app/components/ui/scroll-area.tsx new file mode 100644 index 0000000..fd01429 --- /dev/null +++ b/packages/temp_frontend/app/components/ui/scroll-area.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib//utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/packages/temp_frontend/app/routes/home.tsx b/packages/temp_frontend/app/routes/home.tsx index 171b588..d398e10 100644 --- a/packages/temp_frontend/app/routes/home.tsx +++ b/packages/temp_frontend/app/routes/home.tsx @@ -1,30 +1,233 @@ import { Layout } from "@/components/Layout"; import type { Route } from "./+types/home"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { treaty } from "@elysiajs/eden"; +import type { App } from "@elysia/src"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { formatDateTime } from "@/components/SearchResults"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; + +// @ts-ignore idk +const app = treaty(import.meta.env.VITE_API_URL!); + +type CloseMilestoneInfo = Awaited["get"]>>["data"]; +type CloseMilestoneError = Awaited["get"]>>["error"]; export function meta({}: Route.MetaArgs) { return [{ title: "中V档案馆" }]; } +type MilestoneType = "dendou" | "densetsu" | "shinwa"; + +const milestoneConfig = { + dendou: { name: "殿堂", range: [90000, 99999], target: 100000 }, + densetsu: { name: "传说", range: [900000, 999999], target: 1000000 }, + shinwa: { name: "神话", range: [5000000, 9999999], target: 10000000 }, +}; + +function formatHours(hours: number): string { + if (hours >= 24 * 14) return `${Math.floor(hours / 24)} 天`; + if (hours >= 24) return `${Math.floor(hours / 24)} 天 ${Math.floor(hours % 24)} 小时`; + if (hours >= 1) return `${Math.floor(hours)} 时 ${Math.round((hours % 1) * 60)} 分`; + return `${Math.round(hours * 60)} 分钟`; +} + +function addHoursToNow(hours: number): string { + const d = new Date(); + d.setHours(d.getHours() + hours); + return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`; +} + export default function Home() { const [input, setInput] = useState(""); + const [milestoneType, setMilestoneType] = useState("shinwa"); + const [closeMilestoneInfo, setCloseMilestoneInfo] = useState(); + const [closeMilestoneError, setCloseMilestoneError] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const fetchMilestoneData = async (type: MilestoneType) => { + setIsLoading(true); + setCloseMilestoneError(undefined); + const { data, error } = await app.songs["close-milestone"]({ type }).get(); + if (error) { + setCloseMilestoneError(error); + } else { + setCloseMilestoneInfo(data); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchMilestoneData(milestoneType); + }, [milestoneType]); + + const MilestoneVideoCard = ({ video }: { video: NonNullable[number] }) => { + const config = milestoneConfig[milestoneType]; + const remainingViews = config.target - video.eta.currentViews; + const progressPercentage = (video.eta.currentViews / config.target) * 100; + + return ( + +

+ {video.bilibili_metadata.coverUrl && ( + 视频封面 + )} +
+

+ + {video.bilibili_metadata.title} + +

+ +
+
+ 当前播放: {video.eta.currentViews.toLocaleString()} + 目标: {config.target.toLocaleString()} +
+ + +
+
+
+
+
+

剩余播放: {remainingViews.toLocaleString()}

+

预计达成: {formatHours(video.eta.eta)}

+
+
+

播放速度: {Math.round(video.eta.speed)}/小时

+

达成时间: {addHoursToNow(video.eta.eta)}

+
+
+ +
+ {video.bilibili_metadata.publishedAt && ( + + 发布于 {formatDateTime(new Date(video.bilibili_metadata.publishedAt))} + + )} + + 观看视频 + + + 查看详情 + +
+ + ); + }; + + const MilestoneVideos = () => { + if (isLoading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+ +
+ + + + +
+
+
+ ))} +
+ ); + } + + if (closeMilestoneError) { + return ( +
+

加载失败: {closeMilestoneError.value?.message || "未知错误"}

+ +
+ ); + } + + if (!closeMilestoneInfo || closeMilestoneInfo.length === 0) { + return ( +
+

暂无接近{milestoneConfig[milestoneType].name}的视频

+
+ ); + } + + return ( +
+

+ 找到 {closeMilestoneInfo.length} 个接近{milestoneConfig[milestoneType].name}的视频 +

+ + {closeMilestoneInfo.map((video) => ( + + ))} + +
+ ); + }; + return ( -

小工具

-
+

小工具

+
-
+
setInput(e.target.value)} /> -
+ +

即将达成里程碑

+
+ + + 播放量在 {milestoneConfig[milestoneType].range[0].toLocaleString()} -{" "} + {milestoneConfig[milestoneType].range[1].toLocaleString()} 之间,即将达成 + {milestoneConfig[milestoneType].name} + +
+ + ); } diff --git a/packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx b/packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx index b715a61..ad601ba 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx @@ -75,7 +75,7 @@ export function DataTable({ columns, data }: DataTableProps
-
+
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()} 页
diff --git a/packages/temp_frontend/app/routes/song/[id]/info/index.tsx b/packages/temp_frontend/app/routes/song/[id]/info/index.tsx index ab5861f..35c0a97 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info/index.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/info/index.tsx @@ -31,6 +31,7 @@ import { } from "@/components/ui/alert-dialog"; import { av2bv } from "@elysia/lib/bilibiliID"; import { columns, type Snapshot } from "./columns"; +import { HOUR } from "@core/lib"; // @ts-ignore idk const app = treaty(import.meta.env.VITE_API_URL!); @@ -48,7 +49,7 @@ export async function clientLoader({ params }: Route.LoaderArgs) { function formatHours(hours: number): string { if (hours >= 24 * 14) return `${Math.floor(hours / 24)} 天`; - if (hours >= 24) return `${Math.floor(hours / 24)} 天 ${hours % 24} 小时`; + if (hours >= 24) return `${Math.floor(hours / 24)} 天 ${Math.round(hours) % 24} 小时`; if (hours >= 1) return `${Math.floor(hours)} 时 ${Math.round((hours % 1) * 60)} 分`; return `${Math.round(hours * 60)} 分钟`; } @@ -92,9 +93,7 @@ const SnapshotsView = ({ etaData: EtaInfo | null; publishedAt?: string; }) => { - const [timeRange, setTimeRange] = useState("all"); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize] = useState(10); + const [timeRange, setTimeRange] = useState("7d"); const [timeOffsetHours, setTimeOffsetHours] = useState(0); // Calculate time range in hours @@ -102,32 +101,50 @@ const SnapshotsView = ({ switch (timeRange) { case "6h": return 6; - case "12h": - return 12; case "24h": return 24; - case "3d": - return 72; case "7d": - return 168; + return 7 * 24; case "14d": - return 336; + return 14 * 24; case "30d": - return 720; + return 30 * 24; + case "90d": + return 90 * 24; + case "365d": + return 365 * 24; default: return undefined; // "all" } }, [timeRange]); - // Pagination for table data - const paginatedSnapshots = useMemo(() => { - if (!snapshots) return []; - const startIndex = (currentPage - 1) * pageSize; - const endIndex = startIndex + pageSize; - return snapshots.slice(startIndex, endIndex); - }, [snapshots, currentPage, pageSize]); + const sortedSnapshots = useMemo(() => { + if (!snapshots) return null; + return [...snapshots] + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((s) => ({ + ...s, + timestamp: new Date(s.createdAt).getTime(), + })); + }, [snapshots]); - const totalPages = snapshots ? Math.ceil(snapshots.length / pageSize) : 0; + const canGoBack = useMemo(() => { + if (!sortedSnapshots || !timeRangeHours || timeRangeHours <= 0) return false; + const oldestTimestamp = sortedSnapshots[0].timestamp; + const newestTimestamp = sortedSnapshots[sortedSnapshots.length - 1].timestamp; + const timeDiff = newestTimestamp - oldestTimestamp; + return timeOffsetHours * HOUR + timeRangeHours * HOUR < timeDiff; + }, [snapshots, timeRangeHours, timeOffsetHours]); + + const canGoForward = useMemo(() => { + if (!sortedSnapshots || !timeRangeHours || timeRangeHours <= 0 || timeOffsetHours <= 0) return false; + return true; + }, [snapshots, timeRangeHours, timeOffsetHours]); + + const processedData = useMemo( + () => processSnapshots(sortedSnapshots, timeRangeHours, timeOffsetHours), + [snapshots, timeRangeHours, timeOffsetHours], + ); if (!snapshots) { return ; @@ -141,21 +158,8 @@ const SnapshotsView = ({ ); } - const processedData = processSnapshots(snapshots, timeRangeHours, timeOffsetHours); const milestoneAchievements = detectMilestoneAchievements(snapshots, publishedAt); - // Handle time range navigation - const totalDataHours = - snapshots && snapshots.length > 0 - ? (new Date(snapshots[snapshots.length - 1].createdAt).getTime() - - new Date(snapshots[0].createdAt).getTime()) / - (1000 * 60 * 60) - : 0; - - // Simplified logic: always allow navigation if we have a time range - const canGoBack = timeRangeHours !== undefined && timeRangeHours > 0; - const canGoForward = timeRangeHours !== undefined && timeRangeHours > 0; - const handleBack = () => { if (timeRangeHours && timeRangeHours > 0) { setTimeOffsetHours((prev) => prev + timeRangeHours); @@ -169,7 +173,7 @@ const SnapshotsView = ({ }; return ( -
+

播放: {snapshots[0].views.toLocaleString()} @@ -207,70 +211,42 @@ const SnapshotsView = ({

数据

-
- - 图表 - 表格 - - -
+ + 图表 + 表格 +
- - {timeRangeHours ? `${timeRangeHours}小时范围` : "全部数据"} - +
- - {totalPages > 1 && ( -
- - - 第 {currentPage} 页,共 {totalPages} 页 - - -
- )} +
@@ -457,7 +433,7 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
-
+
diff --git a/packages/temp_frontend/app/routes/song/[id]/info/lib.ts b/packages/temp_frontend/app/routes/song/[id]/info/lib.ts index 6d041db..4118bc0 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info/lib.ts +++ b/packages/temp_frontend/app/routes/song/[id]/info/lib.ts @@ -10,10 +10,9 @@ export interface MilestoneAchievement { } const getDataIntervalMins = (interval: number, timeRangeHours?: number) => { - if (!timeRangeHours ||timeRangeHours >= 7 * 24) { + if (!timeRangeHours || timeRangeHours > 90 * 24) { return 24 * 60; - } - if (interval >= 6 * HOUR) { + } else if (interval >= 6 * HOUR) { return 6 * 60; } else if (interval >= 1 * HOUR) { return 60; @@ -25,122 +24,130 @@ const getDataIntervalMins = (interval: number, timeRangeHours?: number) => { return 1; }; -export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours?: number, timeOffsetHours: number = 0) => { - if (!snapshots || snapshots.length === 0) { +export const processSnapshots = ( + snapshotTimestamps: (Exclude[number] & { timestamp: number })[] | null, + timeRangeHours?: number, + timeOffsetHours: number = 0, +) => { + if (!snapshotTimestamps || snapshotTimestamps.length === 0) { return []; } - const sortedSnapshots = [...snapshots].sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); + const oldestTimestamp = snapshotTimestamps[0].timestamp; + const newestTimestamp = snapshotTimestamps[snapshotTimestamps.length - 1].timestamp; - const oldestDate = new Date(sortedSnapshots[0].createdAt); - const newestDate = new Date(sortedSnapshots[sortedSnapshots.length - 1].createdAt); + const targetEndTime = timeRangeHours ? newestTimestamp - timeOffsetHours * HOUR : newestTimestamp; + const targetStartTime = timeRangeHours ? targetEndTime - timeRangeHours * HOUR : oldestTimestamp; - // Calculate the time range with offset - const targetEndTime = timeRangeHours ? new Date(newestDate.getTime() - timeOffsetHours * HOUR) : newestDate; - const targetStartTime = timeRangeHours ? new Date(targetEndTime.getTime() - timeRangeHours * HOUR) : null; - - const startTime = targetStartTime ? (oldestDate > targetStartTime ? oldestDate : targetStartTime) : oldestDate; + const startTime = Math.max(oldestTimestamp, targetStartTime); const endTime = targetEndTime; - const hourlyTimePoints: Date[] = []; - const currentTime = endTime; - const timeDiff = currentTime.getTime() - startTime.getTime(); - const length = sortedSnapshots.filter((s) => { - const snapshotTime = new Date(s.createdAt).getTime(); - return snapshotTime >= startTime.getTime() && snapshotTime <= endTime.getTime(); - }).length; - const avgInterval = timeDiff / length; - const dataIntervalMins = getDataIntervalMins(avgInterval, timeRangeHours); + const beforeRangeSnapshots = snapshotTimestamps.filter((s) => s.timestamp < startTime); + const afterRangeSnapshots = snapshotTimestamps.filter((s) => s.timestamp > endTime); - for (let time = new Date(startTime); time <= currentTime; time.setMinutes(time.getMinutes() + dataIntervalMins)) { - hourlyTimePoints.push(new Date(time)); + const closestBefore = + beforeRangeSnapshots.length > 0 ? beforeRangeSnapshots[beforeRangeSnapshots.length - 1] : null; + const closestAfter = afterRangeSnapshots.length > 0 ? afterRangeSnapshots[0] : null; + + const relevantSnapshots = snapshotTimestamps.filter((s) => s.timestamp >= startTime && s.timestamp <= endTime); + + if (relevantSnapshots.length === 0) { + if (!closestBefore && !closestAfter) { + return []; + } + + if (closestBefore && !closestAfter) { + return [createSnapshotData(startTime, closestBefore)]; + } + if (!closestBefore && closestAfter) { + return [createSnapshotData(startTime, closestAfter)]; + } + + if (closestBefore && closestAfter) { + const timeDiff = closestAfter.timestamp - closestBefore.timestamp; + const ratio = (startTime - closestBefore.timestamp) / timeDiff; + return [createInterpolatedSnapshot(startTime, closestBefore, closestAfter, ratio)]; + } } - const processedData = hourlyTimePoints - .map((timePoint) => { - const previousSnapshots = sortedSnapshots.filter((s) => { - const snapshotTime = new Date(s.createdAt).getTime(); - return snapshotTime <= timePoint.getTime() && snapshotTime >= startTime.getTime(); - }); - - const nextSnapshots = sortedSnapshots.filter((s) => { - const snapshotTime = new Date(s.createdAt).getTime(); - return snapshotTime >= timePoint.getTime() && snapshotTime <= endTime.getTime(); - }); + const timeDiff = endTime - startTime; + const avgInterval = timeDiff / Math.max(relevantSnapshots.length, 1); + const dataIntervalMins = getDataIntervalMins(avgInterval, timeRangeHours); + const dataIntervalMs = dataIntervalMins * 60 * 1000; - const previousSnapshot = previousSnapshots[previousSnapshots.length - 1]; - const nextSnapshot = nextSnapshots[0]; + const hourlyTimePoints: number[] = []; + for (let time = startTime; time <= endTime; time += dataIntervalMs) { + hourlyTimePoints.push(time); + } - if (!previousSnapshot && !nextSnapshot) { - return null; + let snapshotIndex = 0; + const processedData = []; + + for (const timePoint of hourlyTimePoints) { + while (snapshotIndex < relevantSnapshots.length - 1 && relevantSnapshots[snapshotIndex].timestamp < timePoint) { + snapshotIndex++; + } + + const currentSnapshot = relevantSnapshots[snapshotIndex]; + const prevSnapshot = snapshotIndex > 0 ? relevantSnapshots[snapshotIndex - 1] : null; + + let result = null; + + if (currentSnapshot && currentSnapshot.timestamp === timePoint) { + result = createSnapshotData(timePoint, currentSnapshot); + } else if (prevSnapshot && currentSnapshot && prevSnapshot.timestamp <= timePoint) { + const ratio = (timePoint - prevSnapshot.timestamp) / (currentSnapshot.timestamp - prevSnapshot.timestamp); + result = createInterpolatedSnapshot(timePoint, prevSnapshot, currentSnapshot, ratio); + } else if (!prevSnapshot && currentSnapshot && currentSnapshot.timestamp >= timePoint) { + result = createSnapshotData(timePoint, currentSnapshot); + } else if ( + snapshotIndex === relevantSnapshots.length - 1 && + currentSnapshot && + currentSnapshot.timestamp <= timePoint + ) { + result = createSnapshotData(timePoint, currentSnapshot); + } else { + if (closestBefore && closestAfter) { + const timeDiff = closestAfter.timestamp - closestBefore.timestamp; + const ratio = (timePoint - closestBefore.timestamp) / timeDiff; + result = createInterpolatedSnapshot(timePoint, closestBefore, closestAfter, ratio); + } else if (closestBefore) { + result = createSnapshotData(timePoint, closestBefore); + } else if (closestAfter) { + result = createSnapshotData(timePoint, closestAfter); } + } - if (previousSnapshot && new Date(previousSnapshot.createdAt).getTime() === timePoint.getTime()) { - return { - createdAt: timePoint.toISOString(), - views: previousSnapshot.views, - likes: previousSnapshot.likes || 0, - favorites: previousSnapshot.favorites || 0, - coins: previousSnapshot.coins || 0, - danmakus: previousSnapshot.danmakus || 0, - }; - } - - if (previousSnapshot && !nextSnapshot) { - return { - createdAt: timePoint.toISOString(), - views: previousSnapshot.views, - likes: previousSnapshot.likes || 0, - favorites: previousSnapshot.favorites || 0, - coins: previousSnapshot.coins || 0, - danmakus: previousSnapshot.danmakus || 0, - }; - } - - if (!previousSnapshot && nextSnapshot) { - return { - createdAt: timePoint.toISOString(), - views: nextSnapshot.views, - likes: nextSnapshot.likes || 0, - favorites: nextSnapshot.favorites || 0, - coins: nextSnapshot.coins || 0, - danmakus: nextSnapshot.danmakus || 0, - }; - } - - const prevTime = new Date(previousSnapshot.createdAt).getTime(); - const nextTime = new Date(nextSnapshot.createdAt).getTime(); - const currentTime = timePoint.getTime(); - - const ratio = (currentTime - prevTime) / (nextTime - prevTime); - - return { - createdAt: timePoint.toISOString(), - views: Math.round(previousSnapshot.views + (nextSnapshot.views - previousSnapshot.views) * ratio), - likes: Math.round( - (previousSnapshot.likes || 0) + ((nextSnapshot.likes || 0) - (previousSnapshot.likes || 0)) * ratio, - ), - favorites: Math.round( - (previousSnapshot.favorites || 0) + - ((nextSnapshot.favorites || 0) - (previousSnapshot.favorites || 0)) * ratio, - ), - coins: Math.round( - (previousSnapshot.coins || 0) + ((nextSnapshot.coins || 0) - (previousSnapshot.coins || 0)) * ratio, - ), - danmakus: Math.round( - (previousSnapshot.danmakus || 0) + - ((nextSnapshot.danmakus || 0) - (previousSnapshot.danmakus || 0)) * ratio, - ), - }; - }) - .filter((d) => d !== null); + if (result) { + processedData.push(result); + } + } return processedData; }; -export const detectMilestoneAchievements = (snapshots: Snapshots | null, publishedAt?: string): MilestoneAchievement[] => { +const createSnapshotData = (timestamp: number, snapshot: any) => ({ + createdAt: new Date(timestamp).toISOString(), + views: snapshot.views, + likes: snapshot.likes || 0, + favorites: snapshot.favorites || 0, + coins: snapshot.coins || 0, + danmakus: snapshot.danmakus || 0, +}); + +const createInterpolatedSnapshot = (timestamp: number, prev: any, next: any, ratio: number) => ({ + createdAt: new Date(timestamp).toISOString(), + views: Math.round(prev.views + (next.views - prev.views) * ratio), + likes: Math.round((prev.likes || 0) + ((next.likes || 0) - (prev.likes || 0)) * ratio), + favorites: Math.round((prev.favorites || 0) + ((next.favorites || 0) - (prev.favorites || 0)) * ratio), + coins: Math.round((prev.coins || 0) + ((next.coins || 0) - (prev.coins || 0)) * ratio), + danmakus: Math.round((prev.danmakus || 0) + ((next.danmakus || 0) - (prev.danmakus || 0)) * ratio), +}); + +export const detectMilestoneAchievements = ( + snapshots: Snapshots | null, + publishedAt?: string, +): MilestoneAchievement[] => { if (!snapshots || snapshots.length < 2) { return []; } @@ -153,7 +160,6 @@ export const detectMilestoneAchievements = (snapshots: Snapshots | null, publish const milestoneNames = ["殿堂", "传说", "神话"]; const achievements: MilestoneAchievement[] = []; - // Find the earliest snapshot for each milestone const earliestAchievements = new Map(); for (let i = 1; i < sortedSnapshots.length; i++) { @@ -164,15 +170,12 @@ export const detectMilestoneAchievements = (snapshots: Snapshots | null, publish const currentTime = new Date(currentSnapshot.createdAt).getTime(); const timeDiff = currentTime - prevTime; - // Check if snapshots are within 10 minutes if (timeDiff <= 10 * 60 * 1000) { for (let j = 0; j < milestones.length; j++) { const milestone = milestones[j]; const milestoneName = milestoneNames[j]; - // Check if milestone was crossed between these two snapshots if (prevSnapshot.views < milestone && currentSnapshot.views >= milestone) { - // Find the exact time when milestone was reached (linear interpolation) const ratio = (milestone - prevSnapshot.views) / (currentSnapshot.views - prevSnapshot.views); const milestoneTime = new Date(prevTime + ratio * timeDiff); @@ -183,7 +186,6 @@ export const detectMilestoneAchievements = (snapshots: Snapshots | null, publish views: milestone, }; - // Only keep the earliest achievement for each milestone if ( !earliestAchievements.has(milestone) || new Date(achievement.achievedAt) < new Date(earliestAchievements.get(milestone)!.achievedAt) @@ -192,7 +194,6 @@ export const detectMilestoneAchievements = (snapshots: Snapshots | null, publish } } - // Check if a snapshot exactly equals a milestone if (prevSnapshot.views === milestone || currentSnapshot.views === milestone) { const exactSnapshot = prevSnapshot.views === milestone ? prevSnapshot : currentSnapshot; const achievement: MilestoneAchievement = { @@ -213,22 +214,19 @@ export const detectMilestoneAchievements = (snapshots: Snapshots | null, publish } } - // Convert map to array and sort by milestone value const achievementsWithTime = Array.from(earliestAchievements.values()).sort((a, b) => a.milestone - b.milestone); - // Calculate time taken for each achievement if (publishedAt) { const publishTime = new Date(publishedAt).getTime(); - + for (const achievement of achievementsWithTime) { const achievementTime = new Date(achievement.achievedAt).getTime(); const timeDiffMs = achievementTime - publishTime; - - // Convert to days, hours, minutes + const days = Math.floor(timeDiffMs / (1000 * 60 * 60 * 24)); const hours = Math.floor((timeDiffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((timeDiffMs % (1000 * 60 * 60)) / (1000 * 60)); - + achievement.timeTaken = `${days} 天 ${hours} 时 ${minutes} 分`; } } diff --git a/packages/temp_frontend/app/routes/song/[id]/info/views-chart.tsx b/packages/temp_frontend/app/routes/song/[id]/info/views-chart.tsx index 4826fc4..fa32f54 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info/views-chart.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/info/views-chart.tsx @@ -40,16 +40,17 @@ function formatDate(dateStr: string, showYear = false): string { return `${yearStr}${month}-${day} ${hours}:${minutes}`; } -const formatYAxisLabel = (value: number, minMax: number) => { - if (minMax >= 40000) { - return (value / 10000).toFixed() + " 万"; +const formatYAxisLabel = (value: number) => { + if (value >= 1000000) { + return (value / 10000).toPrecision(4) + "万"; + } else if (value >= 10000) { + return (value / 10000).toPrecision(3) + "万"; } return value.toLocaleString(); -} +}; export function ViewsChart({ chartData }: { chartData: ChartData[] }) { const { isDarkMode } = useDarkMode(); - const minMax = chartData[chartData.length - 1].views - chartData[0].views; if (!chartData || chartData.length === 0) return <>; return ( @@ -67,17 +68,17 @@ export function ViewsChart({ chartData }: { chartData: ChartData[] }) { formatYAxisLabel(value, minMax)} + tickFormatter={formatYAxisLabel} allowDecimals={false} /> formatDate(e, true)} />} /> - - + + ); diff --git a/packages/temp_frontend/package.json b/packages/temp_frontend/package.json index ede1801..7fd148a 100644 --- a/packages/temp_frontend/package.json +++ b/packages/temp_frontend/package.json @@ -16,6 +16,8 @@ "@nivo/line": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13",