From 48bd53105c0511b09476188122e82f410f946722 Mon Sep 17 00:00:00 2001
From: alikia2x
Date: Sun, 21 Dec 2025 23:05:06 +0800
Subject: [PATCH] fix: add zero-value snapshot at video creation - Adds a
"virtual" record with all zeros to ensure the chart starts from the origin. -
Fixes various linting issues.
---
.idea/biome.xml | 1 +
.idea/data_source_mapping.xml | 1 +
.idea/prettier.xml | 2 +-
package.json | 5 +-
packages/core/net/delegate.ts | 12 +-
.../app/components/SearchResults.tsx | 8 +-
.../app/routes/song/[id]/info/index.tsx | 324 +++---------------
.../app/routes/song/[id]/info/lib.ts | 3 +-
.../routes/song/[id]/info/snapshotsView.tsx | 251 ++++++++++++++
.../app/routes/song/[id]/info/views-chart.tsx | 19 +-
turbo.json | 15 +-
11 files changed, 337 insertions(+), 304 deletions(-)
create mode 100644 packages/temp_frontend/app/routes/song/[id]/info/snapshotsView.tsx
diff --git a/.idea/biome.xml b/.idea/biome.xml
index f400d0c..c5fed51 100644
--- a/.idea/biome.xml
+++ b/.idea/biome.xml
@@ -2,6 +2,7 @@
+
\ No newline at end of file
diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml
index 7ba958c..73510c6 100644
--- a/.idea/data_source_mapping.xml
+++ b/.idea/data_source_mapping.xml
@@ -6,5 +6,6 @@
+
\ No newline at end of file
diff --git a/.idea/prettier.xml b/.idea/prettier.xml
index 01f3c56..3a5991f 100644
--- a/.idea/prettier.xml
+++ b/.idea/prettier.xml
@@ -1,7 +1,7 @@
-
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 09532a8..fd47115 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,10 @@
"scripts": {
"biome:format": "bunx --bun biome format --write",
"biome:lint": "bunx --bun biome lint",
- "biome:check": "bunx --bun biome check --write"
+ "dev": "turbo run dev --filter=temp_frontend --filter=backend",
+ "db": "turbo run drizzle:pull",
+ "biome": "turbo run biome",
+ "test": "turbo run test"
},
"workspaces": {
"packages": [
diff --git a/packages/core/net/delegate.ts b/packages/core/net/delegate.ts
index 581147e..849c316 100644
--- a/packages/core/net/delegate.ts
+++ b/packages/core/net/delegate.ts
@@ -364,8 +364,6 @@ class IPPoolManager {
this.isRefreshing = true;
try {
- logger.debug("Refreshing IP pool", "net", "IPPoolManager.refreshPool");
-
const extractedIPs = await this.config.extractor();
const newIPs = extractedIPs.slice(0, this.config.maxPoolSize - this.pool.length);
@@ -378,12 +376,6 @@ class IPPoolManager {
};
this.pool.push(ipEntry);
}
-
- logger.debug(
- `IP pool refreshed. Pool size: ${this.pool.length}`,
- "net",
- "IPPoolManager.refreshPool"
- );
} catch (error) {
logger.error(error as Error, "net", "IPPoolManager.refreshPool");
} finally {
@@ -477,7 +469,9 @@ export class NetworkDelegate {
const boundProxies = new Set();
for (const [_taskName, taskImpl] of Object.entries(this.tasks)) {
if (taskImpl.provider === providerName) {
- taskImpl.proxies.forEach((p) => boundProxies.add(p));
+ taskImpl.proxies.forEach((p) => {
+ boundProxies.add(p);
+ });
}
}
diff --git a/packages/temp_frontend/app/components/SearchResults.tsx b/packages/temp_frontend/app/components/SearchResults.tsx
index fdcb3b4..76c117b 100644
--- a/packages/temp_frontend/app/components/SearchResults.tsx
+++ b/packages/temp_frontend/app/components/SearchResults.tsx
@@ -6,15 +6,16 @@ interface SearchResultsProps {
query: string;
}
-export const formatDateTime = (date: Date): string => {
+export const formatDateTime = (date: Date, showYear = true, showSec = false): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); // 月份从0开始,补0
const day = String(date.getDate()).padStart(2, "0");
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
-
- return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
+ const yearStr = showYear ? ` ${year}-` : "";
+ const secStr = showSec ? `:${second}` : "";
+ return `${yearStr}${month}-${day} ${hour}:${minute}${secStr}`;
};
const biliIDSchema = z.union([z.string().regex(/BV1[0-9A-Za-z]{9}/), z.string().regex(/av[0-9]+/)]);
@@ -65,6 +66,7 @@ export function SearchResults({ results, query }: SearchResultsProps) {
{results.data.map((result, index) => (
(import.meta.env.VITE_API_URL!);
+const app = treaty
(import.meta.env.VITE_API_URL || "https://api.projectcvsa.com/");
type SongInfo = Awaited["info"]["get"]>>["data"];
-type EtaInfo = Awaited["eta"]["get"]>>["data"];
+export type EtaInfo = Awaited["eta"]["get"]>>["data"];
export type Snapshots = Awaited<
ReturnType["snapshots"]["get"]>
>["data"];
@@ -53,6 +40,7 @@ type SnapshotsError = Awaited<
>["error"];
type EtaInfoError = Awaited["eta"]["get"]>>["error"];
+// noinspection JSUnusedGlobalSymbols
export async function clientLoader({ params }: Route.LoaderArgs) {
return { id: params.id };
}
@@ -70,217 +58,6 @@ export function addHoursToNow(hours: number): string {
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")}`;
}
-const StatsTable = ({ snapshots }: { snapshots: Snapshots | null }) => {
- if (!snapshots || snapshots.length === 0) {
- return null;
- }
-
- const tableData: Snapshot[] = snapshots.map((snapshot) => ({
- createdAt: snapshot.createdAt,
- views: snapshot.views,
- likes: snapshot.likes || 0,
- favorites: snapshot.favorites || 0,
- coins: snapshot.coins || 0,
- danmakus: snapshot.danmakus || 0,
- shares: snapshot.shares || 0,
- }));
-
- return ;
-};
-
-const getMileStoneName = (views: number) => {
- if (views < 100000) return "殿堂";
- if (views < 1000000) return "传说";
- return "神话";
-};
-
-const SnapshotsView = ({
- snapshots,
- etaData,
- publishedAt,
-}: {
- snapshots: Snapshots | null;
- etaData: EtaInfo | null;
- publishedAt?: string;
-}) => {
- const [timeRange, setTimeRange] = useState("7d");
- const [timeOffsetHours, setTimeOffsetHours] = useState(0);
-
- // Calculate time range in hours
- const timeRangeHours = useMemo(() => {
- switch (timeRange) {
- case "6h":
- return 6;
- case "24h":
- return 24;
- case "7d":
- return 7 * 24;
- case "14d":
- return 14 * 24;
- case "30d":
- return 30 * 24;
- case "90d":
- return 90 * 24;
- case "365d":
- return 365 * 24;
- default:
- return undefined; // "all"
- }
- }, [timeRange]);
-
- 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 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 ;
- }
-
- if (snapshots.length === 0) {
- return (
-
- );
- }
-
- const milestoneAchievements = detectMilestoneAchievements(snapshots, publishedAt);
-
- const handleBack = () => {
- if (timeRangeHours && timeRangeHours > 0) {
- setTimeOffsetHours((prev) => prev + timeRangeHours);
- }
- };
-
- const handleForward = () => {
- if (timeRangeHours && timeRangeHours > 0) {
- setTimeOffsetHours((prev) => Math.max(0, prev - timeRangeHours));
- }
- };
-
- return (
-
-
- 播放: {snapshots[0].views.toLocaleString()}
-
- {" "}
- 更新于 {formatDateTime(new Date(snapshots[0].createdAt))}
-
-
- {etaData && (
- <>
- {etaData!.views <= 10000000 && (
-
- 下一个成就:{getMileStoneName(etaData!.views)}
-
- {" "}
- 预计 {formatHours(etaData!.eta)} 后({addHoursToNow(etaData!.eta)}
- )达成
-
-
- )}
-
- {milestoneAchievements.length > 0 && (
-
-
成就达成时间:
- {milestoneAchievements.map((achievement) => (
-
- {achievement.milestoneName}(
- {achievement.milestone.toLocaleString()}) -{" "}
- {formatDateTime(new Date(achievement.achievedAt))}
- {achievement.timeTaken && ` - 用时 ${achievement.timeTaken}`}
-
- ))}
-
- )}
- >
- )}
-
-
-
-
数据
-
- 图表
- 表格
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-const MemoizedSnapshotsView = memo(SnapshotsView);
-
export default function SongInfo({ loaderData }: Route.ComponentProps) {
const [songInfo, setData] = useState(null);
const [snapshots, setSnapshots] = useState(null);
@@ -292,25 +69,25 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
const [isDeleting, setIsDeleting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
- const getEta = async () => {
+ const getEta = useCallback(async () => {
const { data, error } = await app.song({ id: loaderData.id }).eta.get();
if (error) {
console.log(error);
return;
}
setEtaData(data);
- };
+ }, [loaderData.id]);
- const getSnapshots = async () => {
+ const getSnapshots = useCallback(async () => {
const { data, error } = await app.song({ id: loaderData.id }).snapshots.get();
if (error) {
console.log(error);
return;
}
setSnapshots(data);
- };
+ }, [loaderData.id]);
- const getInfo = async () => {
+ const getInfo = useCallback(async () => {
const { data, error } = await app.song({ id: loaderData.id }).info.get();
if (error) {
console.log(error);
@@ -318,13 +95,13 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
return;
}
setData(data);
- };
+ }, [loaderData.id]);
useEffect(() => {
- getInfo();
- getSnapshots();
- getEta();
- }, []);
+ getInfo().then(() => {});
+ getSnapshots().then(() => {});
+ getEta().then(() => {});
+ }, [getEta, getInfo, getSnapshots]);
useEffect(() => {
if (songInfo?.name) {
@@ -368,6 +145,11 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
return ;
}
+ // Type guard: songInfo is guaranteed to be non-null at this point
+ if (!songInfo) {
+ throw new Error("Invariant violation: songInfo should not be null here");
+ }
+
const formatDuration = (duration: number) => {
return `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`;
};
@@ -388,14 +170,14 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
if (error || !data) {
toast.error(`无法更新:${error.value.message || "未知错误"}`);
}
- getInfo();
+ getInfo().then(() => {});
};
const handleDeleteSong = async () => {
if (!songInfo) return;
setIsDeleting(true);
try {
- const { data, error } = await app.song({ id: songInfo.id }).delete(undefined, {
+ const { error } = await app.song({ id: songInfo.id }).delete(undefined, {
headers: {
Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`,
},
@@ -411,7 +193,7 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
setTimeout(() => {
window.location.href = "/";
}, 1000);
- } catch (err) {
+ } catch {
toast.error("删除失败:网络错误");
} finally {
setIsDeleting(false);
@@ -421,17 +203,18 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
return (
-
- {songInfo!.cover && (
+
+
- )}
+
setIsDialogOpen(true)}>
- {songInfo!.name ? songInfo!.name : "未知歌曲名"}
+ {songInfo.name ? songInfo.name : "未知歌曲名"}
-
-
-
- av{songInfo!.aid} ·{" "}
- {av2bv(songInfo!.aid!)}
-
-
-
+
+
+ av{songInfo.aid} · {av2bv(songInfo.aid || 0)}
+
+
-
-
-
- 时长:
- {formatDuration(songInfo!.duration!)}
-
-
-
+
+
+ 时长:
+ {formatDuration(songInfo.duration || 0)}
+
+
·
-
-
-
- 发布于 {formatDateTime(new Date(songInfo!.publishedAt!))}
-
-
-
+
+
+ 发布于 {formatDateTime(new Date(songInfo.publishedAt || 0))}
+
+
P主:
- {songInfo!.producer ? songInfo!.producer : "未知P主"}
+ {songInfo.producer ? songInfo.producer : "未知P主"}
- {songInfo!.aid && (
+ {songInfo.aid && (
@@ -527,7 +303,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 a3f4a3e..f185c2a 100644
--- a/packages/temp_frontend/app/routes/song/[id]/info/lib.ts
+++ b/packages/temp_frontend/app/routes/song/[id]/info/lib.ts
@@ -14,7 +14,7 @@ const getDataIntervalMins = (interval: number, timeRangeHours?: number) => {
return 24 * 60;
} else if (interval >= 6 * HOUR) {
return 6 * 60;
- } else if (interval >= 1 * HOUR) {
+ } else if (interval >= HOUR) {
return 60;
} else if (interval >= 15 * MINUTE) {
return 15;
@@ -175,7 +175,6 @@ export const detectMilestoneAchievements = (
const milestones = [100000, 1000000, 10000000];
const milestoneNames = ["殿堂", "传说", "神话"];
- const achievements: MilestoneAchievement[] = [];
const earliestAchievements = new Map
();
diff --git a/packages/temp_frontend/app/routes/song/[id]/info/snapshotsView.tsx b/packages/temp_frontend/app/routes/song/[id]/info/snapshotsView.tsx
new file mode 100644
index 0000000..f56ba37
--- /dev/null
+++ b/packages/temp_frontend/app/routes/song/[id]/info/snapshotsView.tsx
@@ -0,0 +1,251 @@
+import { HOUR } from "@core/lib";
+import { memo, useMemo, useState } from "react";
+import { When } from "react-if";
+import { formatDateTime } from "@/components/SearchResults";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { columns, type Snapshot } from "@/routes/song/[id]/info/columns";
+import { DataTable } from "@/routes/song/[id]/info/data-table";
+import {
+ addHoursToNow,
+ type EtaInfo,
+ formatHours,
+ type Snapshots,
+} from "@/routes/song/[id]/info/index";
+import { detectMilestoneAchievements, processSnapshots } from "@/routes/song/[id]/info/lib";
+import { ViewsChart } from "@/routes/song/[id]/info/views-chart";
+
+const StatsTable = ({ snapshots }: { snapshots: Snapshots | null }) => {
+ if (!snapshots || snapshots.length === 0) {
+ return null;
+ }
+
+ const tableData: Snapshot[] = snapshots.map((snapshot) => ({
+ createdAt: snapshot.createdAt,
+ views: snapshot.views,
+ likes: snapshot.likes || 0,
+ favorites: snapshot.favorites || 0,
+ coins: snapshot.coins || 0,
+ danmakus: snapshot.danmakus || 0,
+ shares: snapshot.shares || 0,
+ }));
+
+ return ;
+};
+
+const getMileStoneName = (views: number) => {
+ if (views < 100000) return "殿堂";
+ if (views < 1000000) return "传说";
+ return "神话";
+};
+
+export const SnapshotsView = ({
+ snapshots,
+ etaData,
+ publishedAt,
+}: {
+ snapshots: Snapshots | null;
+ etaData: EtaInfo | null;
+ publishedAt: string;
+}) => {
+ const [timeRange, setTimeRange] = useState("7d");
+ const [timeOffsetHours, setTimeOffsetHours] = useState(0);
+
+ // Calculate time range in hours
+ const timeRangeHours = useMemo(() => {
+ switch (timeRange) {
+ case "6h":
+ return 6;
+ case "24h":
+ return 24;
+ case "7d":
+ return 7 * 24;
+ case "14d":
+ return 14 * 24;
+ case "30d":
+ return 30 * 24;
+ case "90d":
+ return 90 * 24;
+ case "365d":
+ return 365 * 24;
+ default:
+ return undefined; // "all"
+ }
+ }, [timeRange]);
+
+ const sortedSnapshots = useMemo(() => {
+ if (!snapshots) return null;
+ return [
+ {
+ id: 0,
+ createdAt: publishedAt,
+ views: 0,
+ coins: 0,
+ likes: 0,
+ favorites: 0,
+ shares: 0,
+ danmakus: 0,
+ aid: 0,
+ replies: 0,
+ },
+ ...snapshots,
+ ]
+ .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
+ .map((s) => ({
+ ...s,
+ timestamp: new Date(s.createdAt).getTime(),
+ }));
+ }, [snapshots, publishedAt]);
+
+ 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;
+ }, [timeRangeHours, timeOffsetHours, sortedSnapshots]);
+
+ const canGoForward = useMemo(() => {
+ return !(
+ !sortedSnapshots ||
+ !timeRangeHours ||
+ timeRangeHours <= 0 ||
+ timeOffsetHours <= 0
+ );
+ }, [timeRangeHours, timeOffsetHours, sortedSnapshots]);
+
+ const processedData = useMemo(
+ () => processSnapshots(sortedSnapshots, timeRangeHours, timeOffsetHours),
+ [timeRangeHours, timeOffsetHours, sortedSnapshots]
+ );
+
+ if (!snapshots) {
+ return ;
+ }
+
+ if (snapshots.length === 0) {
+ return (
+
+ );
+ }
+
+ const milestoneAchievements = detectMilestoneAchievements(snapshots, publishedAt);
+
+ const handleBack = () => {
+ if (timeRangeHours && timeRangeHours > 0) {
+ setTimeOffsetHours((prev) => prev + timeRangeHours);
+ }
+ };
+
+ const handleForward = () => {
+ if (timeRangeHours && timeRangeHours > 0) {
+ setTimeOffsetHours((prev) => Math.max(0, prev - timeRangeHours));
+ }
+ };
+
+ return (
+
+
+ 播放: {snapshots[0].views.toLocaleString()}
+
+ {" "}
+ 更新于 {formatDateTime(new Date(snapshots[0].createdAt))}
+
+
+
+
+
+ 下一个成就:{getMileStoneName(etaData?.views || 0)}
+
+ {" "}
+ 预计 {formatHours(etaData?.eta || 0)} 后(
+ {addHoursToNow(etaData?.eta || 0)}
+ )达成
+
+
+
+
+ 0}>
+
+
成就达成时间:
+ {milestoneAchievements.map((achievement) => (
+
+ {achievement.milestoneName}(
+ {achievement.milestone.toLocaleString()}) -{" "}
+ {formatDateTime(new Date(achievement.achievedAt))}
+ {achievement.timeTaken && ` - 用时 ${achievement.timeTaken}`}
+
+ ))}
+
+
+
+
+
+
+
数据
+
+ 图表
+ 表格
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const MemoizedSnapshotsView = memo(SnapshotsView);
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 1d6f29a..c92ead2 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
@@ -2,6 +2,7 @@
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { useDarkMode } from "usehooks-ts";
+import { formatDateTime } from "@/components/SearchResults";
import {
type ChartConfig,
ChartContainer,
@@ -34,29 +35,21 @@ interface ChartData {
views: number;
}
-function formatDate(dateStr: string, showYear = false): string {
- const date = new Date(dateStr);
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- const hours = String(date.getHours()).padStart(2, "0");
- const minutes = String(date.getMinutes()).padStart(2, "0");
- const yearStr = showYear ? ` ${year}-` : "";
- return `${yearStr}${month}-${day} ${hours}:${minutes}`;
-}
+const formatDate = (dateStr: string, showYear = false) =>
+ formatDateTime(new Date(dateStr), showYear);
const formatYAxisLabel = (value: number) => {
if (value >= 1000000) {
- return (value / 10000).toPrecision(4) + "万";
+ return `${(value / 10000).toPrecision(4)}万`;
} else if (value >= 10000) {
- return (value / 10000).toPrecision(3) + "万";
+ return `${(value / 10000).toPrecision(3)}万`;
}
return value.toLocaleString();
};
export function ViewsChart({ chartData }: { chartData: ChartData[] }) {
const { isDarkMode } = useDarkMode();
- if (!chartData || chartData.length === 0) return <>>;
+ if (!chartData || chartData.length === 0) return;
return (