fix: pagination bugs for data table, added close-to-milestone list in homepage
This commit is contained in:
parent
50d6e0f498
commit
302dbcdffe
10
bun.lock
10
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=="],
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
?
|
||||
|
||||
29
packages/temp_frontend/app/components/ui/progress.tsx
Normal file
29
packages/temp_frontend/app/components/ui/progress.tsx
Normal 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 }
|
||||
56
packages/temp_frontend/app/components/ui/scroll-area.tsx
Normal file
56
packages/temp_frontend/app/components/ui/scroll-area.tsx
Normal 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 }
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} 分`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user