1
0

fix: pagination bugs for data table, added close-to-milestone list in homepage

This commit is contained in:
alikia2x (寒寒) 2025-11-10 07:37:31 +08:00
parent 50d6e0f498
commit 302dbcdffe
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
12 changed files with 505 additions and 217 deletions

View File

@ -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=="],

View File

@ -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()
})

View File

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

View File

@ -32,7 +32,7 @@ export function SearchResults({ results, query }: SearchResultsProps) {
<div className="text-center pt-6">
<p className="text-secondary-foreground">
<a href={`/song/${query}/add`} className="text-primary-foreground">
<a href={`/song/${query}/add`} className="text-primary">
</a>
?

View File

@ -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<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -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<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -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<App>(import.meta.env.VITE_API_URL!);
type CloseMilestoneInfo = Awaited<ReturnType<ReturnType<(typeof app.songs)["close-milestone"]>["get"]>>["data"];
type CloseMilestoneError = Awaited<ReturnType<ReturnType<(typeof app.songs)["close-milestone"]>["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<MilestoneType>("shinwa");
const [closeMilestoneInfo, setCloseMilestoneInfo] = useState<CloseMilestoneInfo>();
const [closeMilestoneError, setCloseMilestoneError] = useState<CloseMilestoneError>();
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<CloseMilestoneInfo>[number] }) => {
const config = milestoneConfig[milestoneType];
const remainingViews = config.target - video.eta.currentViews;
const progressPercentage = (video.eta.currentViews / config.target) * 100;
return (
<Card className="px-3 max-md:py-3 md:px-4 my-4 gap-0">
<div className="w-full flex items-start space-x-4 mb-4">
{video.bilibili_metadata.coverUrl && (
<img
src={video.bilibili_metadata.coverUrl}
alt="视频封面"
className="h-25 w-40 rounded-sm object-cover flex-shrink-0"
referrerPolicy="no-referrer"
/>
)}
<div className="flex flex-col w-full justify-between">
<h3 className="text-sm sm:text-lg font-medium line-clamp-2 text-wrap mb-2">
<a href={`/song/av${video.bilibili_metadata.aid}/info`} className="hover:underline">
{video.bilibili_metadata.title}
</a>
</h3>
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>: {video.eta.currentViews.toLocaleString()}</span>
<span>: {config.target.toLocaleString()}</span>
</div>
<Progress value={progressPercentage} />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-5 text-xs text-muted-foreground mb-2">
<div>
<p>: {remainingViews.toLocaleString()}</p>
<p>: {formatHours(video.eta.eta)}</p>
</div>
<div>
<p>: {Math.round(video.eta.speed)}/</p>
<p>: {addHoursToNow(video.eta.eta)}</p>
</div>
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
{video.bilibili_metadata.publishedAt && (
<span className="stat-num">
{formatDateTime(new Date(video.bilibili_metadata.publishedAt))}
</span>
)}
<a
href={`https://www.bilibili.com/video/av${video.bilibili_metadata.aid}`}
target="_blank"
rel="noopener noreferrer"
className="text-pink-400 text-xs hover:underline"
>
</a>
<a
href={`/song/av${video.bilibili_metadata.aid}/info`}
className="text-xs text-secondary-foreground hover:underline"
>
</a>
</div>
</Card>
);
};
const MilestoneVideos = () => {
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-gray-200 dark:border-neutral-700 p-4"
>
<div className="flex items-start space-x-4">
<Skeleton className="h-21 w-36 rounded-sm" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</div>
))}
</div>
);
}
if (closeMilestoneError) {
return (
<div className="text-center py-8">
<p className="text-red-500">: {closeMilestoneError.value?.message || "未知错误"}</p>
<Button variant="outline" className="mt-4" onClick={() => fetchMilestoneData(milestoneType)}>
</Button>
</div>
);
}
if (!closeMilestoneInfo || closeMilestoneInfo.length === 0) {
return (
<div className="text-center py-8">
<p className="text-secondary-foreground">{milestoneConfig[milestoneType].name}</p>
</div>
);
}
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
{closeMilestoneInfo.length} {milestoneConfig[milestoneType].name}
</p>
<ScrollArea className="h-140 w-full">
{closeMilestoneInfo.map((video) => (
<MilestoneVideoCard key={video.bilibili_metadata.aid} video={video} />
))}
</ScrollArea>
</div>
);
};
return (
<Layout>
<h2 className="text-2xl mt-5 mb-2"></h2>
<div className="flex items-center gap-7">
<h2 className="text-2xl mt-5 mb-6"></h2>
<div className="flex max-sm:flex-col sm:items-center gap-7 mb-8">
<Button>
<a href="/util/time-calculator"></a>
</Button>
<div className="flex w-96 gap-3">
<div className="flex sm:w-96 gap-3">
<Input placeholder="输入BV号或av号" value={input} onChange={(e) => setInput(e.target.value)} />
<Button >
<Button>
<a href={`/song/${input}/add`}></a>
</Button>
</div>
</div>
<h2 className="text-2xl mb-4"></h2>
<div className="flex items-center gap-4 mb-6">
<Select value={milestoneType} onValueChange={(value: MilestoneType) => setMilestoneType(value)}>
<SelectTrigger className="w-20">
<SelectValue placeholder="成就" />
</SelectTrigger>
<SelectContent>
<SelectItem value="dendou">殿</SelectItem>
<SelectItem value="densetsu"></SelectItem>
<SelectItem value="shinwa"></SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{milestoneConfig[milestoneType].range[0].toLocaleString()} -{" "}
{milestoneConfig[milestoneType].range[1].toLocaleString()}
{milestoneConfig[milestoneType].name}
</span>
</div>
<MilestoneVideos />
</Layout>
);
}

View File

@ -75,7 +75,7 @@ export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between py-4">
<div className="flex items-center justify-between py-4 stat-num">
<div className="text-sm text-muted-foreground">
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
</div>

View File

@ -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<App>(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<string>("all");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(10);
const [timeRange, setTimeRange] = useState<string>("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 <Skeleton className="w-full h-50 rounded-lg mt-4" />;
@ -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 (
<div className="mt-4">
<div className="mt-4 stat-num">
<p>
: {snapshots[0].views.toLocaleString()}
<span className="text-secondary-foreground">
@ -207,70 +211,42 @@ const SnapshotsView = ({
<Tabs defaultValue="chart" className="mt-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-medium"></h2>
<div className="flex items-center gap-4">
<TabsList>
<TabsTrigger value="chart"></TabsTrigger>
<TabsTrigger value="table"></TabsTrigger>
</TabsList>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="时间范围" />
</SelectTrigger>
<SelectContent>
<SelectItem value="6h">6</SelectItem>
<SelectItem value="12h">12</SelectItem>
<SelectItem value="24h">24</SelectItem>
<SelectItem value="3d">3</SelectItem>
<SelectItem value="7d">7</SelectItem>
<SelectItem value="14d">14</SelectItem>
<SelectItem value="30d">30</SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
</div>
<TabsList>
<TabsTrigger value="chart"></TabsTrigger>
<TabsTrigger value="table"></TabsTrigger>
</TabsList>
</div>
<TabsContent value="chart">
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<Button variant="outline" size="sm" onClick={handleBack} disabled={!canGoBack}>
</Button>
<span className="text-sm text-secondary-foreground">
{timeRangeHours ? `${timeRangeHours}小时范围` : "全部数据"}
</span>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="时间范围" />
</SelectTrigger>
<SelectContent>
<SelectItem value="6h">6</SelectItem>
<SelectItem value="24h">24</SelectItem>
<SelectItem value="7d">7</SelectItem>
<SelectItem value="14d">14</SelectItem>
<SelectItem value="30d">30</SelectItem>
<SelectItem value="90d">90</SelectItem>
<SelectItem value="365d">1</SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={handleForward} disabled={!canGoForward}>
</Button>
</div>
<ViewsChart chartData={processedData} />
</div>
</TabsContent>
<TabsContent value="table">
<StatsTable snapshots={paginatedSnapshots} />
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-secondary-foreground">
{currentPage} {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
<StatsTable snapshots={snapshots} />
</TabsContent>
</Tabs>
</div>
@ -457,7 +433,7 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
</DialogContent>
</Dialog>
</div>
<div className="flex justify-between mt-3">
<div className="flex justify-between mt-3 stat-num">
<div>
<If condition={songInfo!.aid}>
<Then>

View File

@ -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<Snapshots, null>[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<number, MilestoneAchievement>();
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}`;
}
}

View File

@ -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 (
<ChartContainer config={isDarkMode ? chartConfigDark : chartConfigLight} className="min-h-[200px] w-full">
@ -67,17 +68,17 @@ export function ViewsChart({ chartData }: { chartData: ChartData[] }) {
<YAxis
dataKey="views"
tickLine={false}
tickMargin={5}
tickMargin={0}
domain={["auto", "auto"]}
className="stat-num"
tickFormatter={(value) => formatYAxisLabel(value, minMax)}
tickFormatter={formatYAxisLabel}
allowDecimals={false}
/>
<ChartTooltip
content={<ChartTooltipContent hideIndicator={true} labelFormatter={(e) => formatDate(e, true)} />}
/>
<Line dataKey="views" stroke="var(--color-views)" strokeWidth={2} dot={false} />
<Line dataKey="likes" stroke="var(--color-likes)" strokeWidth={2} dot={false} />
<Line dataKey="views" stroke="var(--color-views)" strokeWidth={2} dot={false} animationDuration={300} />
<Line dataKey="likes" stroke="var(--color-likes)" strokeWidth={2} dot={false} animationDuration={300} />
</LineChart>
</ChartContainer>
);

View File

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