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.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 (
- 小工具
-
+
小工具
+
-
+
+
即将达成里程碑
+
+
+
+ 播放量在 {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",