1
0

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.
This commit is contained in:
alikia2x (寒寒) 2025-12-21 23:05:06 +08:00
parent 1130dcb85f
commit 48bd53105c
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
11 changed files with 337 additions and 304 deletions

View File

@ -2,6 +2,7 @@
<project version="4">
<component name="BiomeSettings">
<option name="enableLspFormat" value="true" />
<option name="formatOnSave" value="true" />
<option name="sortImportOnSave" value="true" />
</component>
</project>

View File

@ -6,5 +6,6 @@
<file url="file://$PROJECT_DIR$/queries/schedule_count.sql" value="0d2dd3d3-bd27-4e5f-b0fa-ff14fb2a6bef" />
<file url="file://$PROJECT_DIR$/queries/schedule_window.sql" value="0d2dd3d3-bd27-4e5f-b0fa-ff14fb2a6bef" />
<file url="file://$PROJECT_DIR$/queries/snapshots_count.sql" value="0d2dd3d3-bd27-4e5f-b0fa-ff14fb2a6bef" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/0d2dd3d3-bd27-4e5f-b0fa-ff14fb2a6bef/console_4.sql" value="0d2dd3d3-bd27-4e5f-b0fa-ff14fb2a6bef" />
</component>
</project>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myConfigurationMode" value="DISABLED" />
<option name="codeStyleSettingsModifierEnabled" value="false" />
</component>
</project>

View File

@ -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": [

View File

@ -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 C extends NetworkConfig> {
const boundProxies = new Set<string>();
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);
});
}
}

View File

@ -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) {
</p>
{results.data.map((result, index) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: `result` will never change
key={index}
className="bg-white dark:bg-neutral-800 rounded-lg shadow-sm border
border-gray-200 dark:border-neutral-700 p-2 sm:p-4 hover:shadow-md transition-shadow"

View File

@ -1,10 +1,9 @@
import { av2bv } from "@backend/lib/bilibiliID";
import type { App } from "@backend/src";
import { HOUR } from "@core/lib";
import { treaty } from "@elysiajs/eden";
import { TriangleAlert } from "lucide-react";
import { memo, useEffect, useMemo, useState } from "react";
import { If, Then } from "react-if";
import { useCallback, useEffect, useState } from "react";
import { When } from "react-if";
import { toast } from "sonner";
import { ErrorPage } from "@/components/Error";
import { Layout } from "@/components/Layout";
@ -24,26 +23,14 @@ import {
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
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 { MemoizedSnapshotsView } from "@/routes/song/[id]/info/snapshotsView";
import type { Route } from "./+types/index";
import { columns, type Snapshot } from "./columns";
import { DataTable } from "./data-table";
import { detectMilestoneAchievements, type MilestoneAchievement, processSnapshots } from "./lib";
import { ViewsChart } from "./views-chart";
// @ts-expect-error idk
const app = treaty<App>(import.meta.env.VITE_API_URL!);
const app = treaty<App>(import.meta.env.VITE_API_URL || "https://api.projectcvsa.com/");
type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"];
type EtaInfo = Awaited<ReturnType<ReturnType<typeof app.song>["eta"]["get"]>>["data"];
export type EtaInfo = Awaited<ReturnType<ReturnType<typeof app.song>["eta"]["get"]>>["data"];
export type Snapshots = Awaited<
ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>
>["data"];
@ -53,6 +40,7 @@ type SnapshotsError = Awaited<
>["error"];
type EtaInfoError = Awaited<ReturnType<ReturnType<typeof app.video>["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 <DataTable columns={columns} data={tableData} />;
};
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<string>("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 <Skeleton className="w-full h-50 rounded-lg mt-4" />;
}
if (snapshots.length === 0) {
return (
<div className="mt-4">
<p></p>
</div>
);
}
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 (
<div className="mt-4 stat-num">
<p>
: {snapshots[0].views.toLocaleString()}
<span className="text-secondary-foreground">
{" "}
{formatDateTime(new Date(snapshots[0].createdAt))}
</span>
</p>
{etaData && (
<>
{etaData!.views <= 10000000 && (
<p>
{getMileStoneName(etaData!.views)}
<span className="text-secondary-foreground">
{" "}
{formatHours(etaData!.eta)} {addHoursToNow(etaData!.eta)}
</span>
</p>
)}
{milestoneAchievements.length > 0 && (
<div className="mt-2">
<p className="text-sm text-secondary-foreground"></p>
{milestoneAchievements.map((achievement) => (
<p
key={achievement.milestone}
className="text-sm text-secondary-foreground ml-2"
>
{achievement.milestoneName}
{achievement.milestone.toLocaleString()} -{" "}
{formatDateTime(new Date(achievement.achievedAt))}
{achievement.timeTaken && ` - 用时 ${achievement.timeTaken}`}
</p>
))}
</div>
)}
</>
)}
<Tabs defaultValue="chart" className="mt-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-medium"></h2>
<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>
<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={snapshots} />
</TabsContent>
</Tabs>
</div>
);
};
const MemoizedSnapshotsView = memo(SnapshotsView);
export default function SongInfo({ loaderData }: Route.ComponentProps) {
const [songInfo, setData] = useState<SongInfo | null>(null);
const [snapshots, setSnapshots] = useState<Snapshots | null>(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 <ErrorPage error={error} />;
}
// 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 (
<Layout>
<Title title={songInfo!.name ? songInfo!.name : "未知歌曲名"} />
{songInfo!.cover && (
<Title title={songInfo.name ? songInfo.name : "未知歌曲名"} />
<When condition={songInfo.cover}>
<img
src={songInfo!.cover}
src={songInfo?.cover}
alt="封面图片"
referrerPolicy="no-referrer"
className="w-full aspect-video object-cover rounded-lg mt-6"
/>
)}
</When>
<div className="mt-6 flex items-center gap-2">
<h1 className="text-4xl font-medium" onDoubleClick={() => setIsDialogOpen(true)}>
{songInfo!.name ? songInfo!.name : "未知歌曲名"}
{songInfo.name ? songInfo.name : "未知歌曲名"}
</h1>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
@ -459,42 +242,35 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
</div>
<div className="flex justify-between mt-3 stat-num">
<div>
<If condition={songInfo!.aid}>
<Then>
<p>
<span>av{songInfo!.aid}</span> ·{" "}
<span>{av2bv(songInfo!.aid!)}</span>
</p>
</Then>
</If>
<When condition={songInfo.aid}>
<p>
<span>av{songInfo.aid}</span> · <span>{av2bv(songInfo.aid || 0)}</span>
</p>
</When>
<p>
<If condition={songInfo!.duration}>
<Then>
<span>
{formatDuration(songInfo!.duration!)}
</span>
</Then>
</If>
<When condition={songInfo.duration}>
<span>
{formatDuration(songInfo.duration || 0)}
</span>
</When>
<span> · </span>
<If condition={songInfo!.publishedAt}>
<Then>
<span>
{formatDateTime(new Date(songInfo!.publishedAt!))}
</span>
</Then>
</If>
<When condition={songInfo.publishedAt}>
<span>
{formatDateTime(new Date(songInfo.publishedAt || 0))}
</span>
</When>
</p>
<span>
P主
{songInfo!.producer ? songInfo!.producer : "未知P主"}
{songInfo.producer ? songInfo.producer : "未知P主"}
</span>
</div>
<div className="flex flex-col gap-3">
{songInfo!.aid && (
{songInfo.aid && (
<Button className="bg-pink-400">
<a href={`https://www.bilibili.com/video/${av2bv(songInfo!.aid)}`}>
<a href={`https://www.bilibili.com/video/${av2bv(songInfo.aid)}`}>
</a>
</Button>
@ -527,7 +303,7 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
<MemoizedSnapshotsView
snapshots={snapshots}
etaData={etaData}
publishedAt={songInfo!.publishedAt || undefined}
publishedAt={songInfo.publishedAt || ""}
/>
</Layout>
);

View File

@ -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<number, MilestoneAchievement>();

View File

@ -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 <DataTable columns={columns} data={tableData} />;
};
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<string>("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 <Skeleton className="w-full h-50 rounded-lg mt-4" />;
}
if (snapshots.length === 0) {
return (
<div className="mt-4">
<p></p>
</div>
);
}
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 (
<div className="mt-4 stat-num">
<p>
: {snapshots[0].views.toLocaleString()}
<span className="text-secondary-foreground">
{" "}
{formatDateTime(new Date(snapshots[0].createdAt))}
</span>
</p>
<When condition={etaData != null}>
<When condition={etaData?.views && etaData.views <= 10000000}>
<p>
{getMileStoneName(etaData?.views || 0)}
<span className="text-secondary-foreground">
{" "}
{formatHours(etaData?.eta || 0)}
{addHoursToNow(etaData?.eta || 0)}
</span>
</p>
</When>
<When condition={milestoneAchievements.length > 0}>
<div className="mt-2">
<p className="text-sm text-secondary-foreground"></p>
{milestoneAchievements.map((achievement) => (
<p
key={achievement.milestone}
className="text-sm text-secondary-foreground ml-2"
>
{achievement.milestoneName}
{achievement.milestone.toLocaleString()} -{" "}
{formatDateTime(new Date(achievement.achievedAt))}
{achievement.timeTaken && ` - 用时 ${achievement.timeTaken}`}
</p>
))}
</div>
</When>
</When>
<Tabs defaultValue="chart" className="mt-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-medium"></h2>
<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>
<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={snapshots} />
</TabsContent>
</Tabs>
</div>
);
};
export const MemoizedSnapshotsView = memo(SnapshotsView);

View File

@ -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 (
<ChartContainer
config={isDarkMode ? chartConfigDark : chartConfigLight}

View File

@ -9,6 +9,19 @@
"//#biome:lint": {},
"//#lint": {
"dependsOn": ["biome:lint"]
}
},
"dev": {
"persistent": true,
"cache": false
},
"drizzle:pull": {
"cache": false
},
"test": {},
"deploy": {
"cache": false,
"dependsOn": ["temp_frontend#build"]
},
"build": {}
}
}