- Adds a "virtual" record with all zeros to ensure the chart starts from the origin. - Fixes various linting issues.
252 lines
6.9 KiB
TypeScript
252 lines
6.9 KiB
TypeScript
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);
|