refactor(video-search): 修复巨量bug

This commit is contained in:
mei 2025-07-30 10:11:52 +08:00
parent b71ca19cad
commit 70f7d7899c
3 changed files with 248 additions and 165 deletions

View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

View File

@ -29,7 +29,7 @@ export default function UploaderSearch() {
return return
} }
// 检查是否为禁止的 UP 主 ID // tsy 过滤器
if (blockedUploaderIds.includes(uid)) { if (blockedUploaderIds.includes(uid)) {
router.push("/blocked") router.push("/blocked")
return return

View File

@ -1,131 +1,275 @@
"use client" "use client";
import { useState } from "react" import { useState } from "react";
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react";
import { blockedVideoIds } from "@/config/blocked-ids" import { blockedVideoIds } from "@/config/blocked-ids";
interface Video { interface Video {
video_stat: { video_stat: {
view: number view: number;
like: number like: number;
coin: number coin: number;
favorite: number favorite: number;
reply: number reply: number;
share: number share: number;
danmaku: number danmaku: number;
} };
video_info: { video_info: {
uploader_mid: string uploader_mid: string;
uploader_name: string uploader_name: string;
title: string title: string;
pic: string pic: string;
pages: number pages: number;
timestamp: number timestamp: number;
} };
video_id: { video_id: {
avid: string avid: string;
bvid: string bvid: string;
} };
vrank_info: { vrank_info: {
vrank_score: number vrank_score: number;
rank: string rank: string;
rank_code: number rank_code: number;
progress_percentage: number progress_percentage: number;
} };
video_increase: { video_increase: {
view: number view: number;
like: number like: number;
coin: number coin: number;
favorite: number favorite: number;
reply: number reply: number;
share: number share: number;
danmaku: number danmaku: number;
} };
score_rank: number score_rank: number;
} }
function getAchievement(views: number) { function getAchievement(views: number) {
if (views >= 10000000) return { name: "神话曲", next: null, progress: 100 } const safeViews = Math.max(0, Number(views) || 0);
if (views >= 1000000) if (safeViews >= 10000000)
return { name: "神话曲", next: null, progress: 100 };
if (safeViews >= 1000000)
return { return {
name: "传说曲", name: "传说曲",
next: "神话曲", next: "神话曲",
progress: (views / 10000000) * 100, progress: (safeViews / 10000000) * 100,
} };
if (views >= 100000) if (safeViews >= 100000)
return { return {
name: "殿堂曲", name: "殿堂曲",
next: "传说曲", next: "传说曲",
progress: (views / 1000000) * 100, progress: (safeViews / 1000000) * 100,
} };
return { name: "未达成", next: "殿堂曲", progress: (views / 100000) * 100 } return {
name: "未达成",
next: "殿堂曲",
progress: (safeViews / 100000) * 100,
};
} }
const VideoInfo = ({ video }: { video: Video }) => {
const achievement = getAchievement(video.video_stat.view);
return (
<li
key={video.video_id.bvid}
className="border p-6 rounded-lg bg-white shadow-md"
>
<div className="flex flex-col gap-6">
<div className="w-full">
<h3 className="font-bold text-xl mb-2">{video.video_info.title}</h3>
<p className="text-gray-600">
UP主: {video.video_info.uploader_name} (UID:{" "}
{video.video_info.uploader_mid})
</p>
<p className="text-gray-600">BV号: {video.video_id.bvid}</p>
<p className="text-gray-600">AV号: {video.video_id.avid}</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<p className="text-gray-600">
: {video.video_stat.view.toLocaleString()}
</p>
<p className="text-gray-600">
: {video.video_stat.like.toLocaleString()}
</p>
<p className="text-gray-600">
: {video.video_stat.coin.toLocaleString()}
</p>
<p className="text-gray-600">
: {video.video_stat.favorite.toLocaleString()}
</p>
<p className="text-gray-600">
: {video.video_stat.reply.toLocaleString()}
</p>
<p className="text-gray-600">
: {video.video_stat.share.toLocaleString()}
</p>
<p className="text-gray-600">
: {video.video_stat.danmaku.toLocaleString()}
</p>
</div>
<div className="mt-4 bg-blue-100 p-4 rounded-lg">
<h4 className="font-bold text-lg mb-2"></h4>
{video.vrank_info ? (
<>
<p className="text-gray-700">
: {video.vrank_info.vrank_score.toFixed(2)}
</p>
<p className="text-gray-700 font-bold text-xl">
: {video.score_rank}
</p>
</>
) : (
<p className="text-gray-700"></p>
)}
{video.video_increase && (
<>
<h5 className="font-semibold mt-2"></h5>
<div className="grid grid-cols-2 gap-2">
<p className="text-gray-700">
: {video.video_increase.view.toLocaleString()}
</p>
<p className="text-gray-700">
: {video.video_increase.like.toLocaleString()}
</p>
<p className="text-gray-700">
: {video.video_increase.coin.toLocaleString()}
</p>
<p className="text-gray-700">
: {video.video_increase.favorite.toLocaleString()}
</p>
<p className="text-gray-700">
: {video.video_increase.reply.toLocaleString()}
</p>
<p className="text-gray-700">
: {video.video_increase.share.toLocaleString()}
</p>
<p className="text-gray-700">
: {video.video_increase.danmaku.toLocaleString()}
</p>
</div>
</>
)}
</div>
<p className="text-gray-600 mt-2">
:{" "}
{new Date(video.video_info.timestamp * 1000).toLocaleString(
"zh-CN"
)}
</p>
<div className="mt-4">
<p className="font-semibold">: {achievement.name}</p>
{achievement.next && (
<div className="mt-2">
<p>
{achievement.next} {" "}
{(achievement.next === "殿堂曲"
? 100000
: achievement.next === "传说曲"
? 1000000
: 10000000) - video.video_stat.view}{" "}
</p>
<div className="w-full bg-gray-200 rounded-full h-2.5 mt-2">
<div
className="bg-blue-600 h-2.5 rounded-full"
style={{ width: `${achievement.progress}%` }}
></div>
</div>
</div>
)}
</div>
</div>
</div>
</li>
);
};
export default function VideoSearch() { export default function VideoSearch() {
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("");
const [videos, setVideos] = useState<Video[]>([]) const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const router = useRouter() const router = useRouter();
const handleSearch = async () => { const handleSearch = async () => {
if (!searchTerm) { if (!searchTerm.trim()) {
setError("请输入搜索内容") setError("请输入有效的搜索内容");
return return;
}
// 输入格式验证
if (!/^(BV([a-zA-Z0-9]{10})|AV\d+)$/.test(searchTerm)) {
setError("请输入有效的BV号或AV号");
return;
} }
// 检查是否为禁止的视频 ID
if (blockedVideoIds.includes(searchTerm)) { if (blockedVideoIds.includes(searchTerm)) {
router.push("/blocked") router.push("/blocked");
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
try { try {
const response = await fetch( const [response, weeklyResponse] = await Promise.all([
`https://api.mmeiblog.cn/NineVocalRank/vocaloid_rank/v1/video/${searchTerm}`, fetch(
) `https://api.mmeiblog.cn/NineVocalRank/vocaloid_rank/v1/video/${searchTerm}`
const weekly_response = await fetch( ),
`https://api.mmeiblog.cn/NineVocalRank/vocaloid_rank/v1/sorted/${searchTerm}`, fetch(
) `https://api.mmeiblog.cn/NineVocalRank/vocaloid_rank/v1/sorted/${searchTerm}`
if (!response.ok || !weekly_response.ok) { ),
throw new Error("服务器响应错误") ]);
if (!response.ok || !weeklyResponse.ok) {
const statusText =
response.status === 404 ? "视频未找到" : "服务器响应错误";
throw new Error(statusText);
} }
const data = await response.json()
const weekly_data = await weekly_response.json() const data = await response.json();
const weeklyData = await weeklyResponse.json();
const combinedData = { const combinedData = {
...data, ...data,
score_rank: weekly_data.score_rank, score_rank: weeklyData.score_rank,
} };
setVideos([combinedData]) setVideos([combinedData]);
if (videos.length === 0) { setLoading(false);
setError("未找到匹配的视频") if (!combinedData) {
setError("未找到匹配的视频");
} }
} catch (error) { } catch (err) {
console.error("搜索视频时出错:", error) if (err instanceof TypeError && err.message.includes("network error")) {
setError("搜索过程中出现错误") setError("网络连接异常,请检查网络状态");
} finally { } else if (err instanceof Error && err.message.includes("JSON")) {
setLoading(false) setError("数据解析失败,请稍后重试");
} else if (err instanceof Error && err.message.includes("视频不符合")) {
setError("该视频不在Vocaloid分区无法查询");
} else {
setError(err instanceof Error ? err.message : "搜索过程中出现未知错误");
}
console.error("搜索视频时出错:", err);
setLoading(false);
} }
} };
return ( return (
<Card className="bg-gradient-to-br from-yellow-100 to-red-100"> <Card className="bg-gradient-to-br from-yellow-100 to-red-100">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold text-primary"></CardTitle> <CardTitle className="text-2xl font-bold text-primary">
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex space-x-2 mb-4"> <div className="flex space-x-2 mb-4">
<Input <Input
type="text" type="text"
placeholder="输入 BV/AV 号 " placeholder="输入 BV/AV 号"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
@ -136,90 +280,21 @@ export default function VideoSearch() {
{error && <p className="text-red-500 mb-4">{error}</p>} {error && <p className="text-red-500 mb-4">{error}</p>}
{videos.length > 0 ? ( {videos.length > 0 ? (
<ul className="space-y-8"> <ul className="space-y-8">
{videos.map((video) => { {videos
const achievement = getAchievement(video.video_stat.view) .map((video) => {
return ( // 空值校验
<li key={video.video_id.bvid} className="border p-6 rounded-lg bg-white shadow-md"> if (!video?.video_id?.bvid) {
<div className="flex flex-col gap-6"> console.warn("该视频不在Vocaloid分区无法查询", video);
<div className="w-full"> return null;
<h3 className="font-bold text-xl mb-2">{video.video_info.title}</h3> }
<p className="text-gray-600"> return <VideoInfo key={video.video_id.bvid} video={video} />;
UP主: {video.video_info.uploader_name} (UID: {video.video_info.uploader_mid}) })
</p> .filter(Boolean)}
<p className="text-gray-600">BV号: {video.video_id.bvid}</p>
<p className="text-gray-600">AV号: {video.video_id.avid}</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<p className="text-gray-600">: {video.video_stat.view.toLocaleString()}</p>
<p className="text-gray-600">: {video.video_stat.like.toLocaleString()}</p>
<p className="text-gray-600">: {video.video_stat.coin.toLocaleString()}</p>
<p className="text-gray-600">: {video.video_stat.favorite.toLocaleString()}</p>
<p className="text-gray-600">: {video.video_stat.reply.toLocaleString()}</p>
<p className="text-gray-600">: {video.video_stat.share.toLocaleString()}</p>
<p className="text-gray-600">: {video.video_stat.danmaku.toLocaleString()}</p>
</div>
<div className="mt-4 bg-blue-100 p-4 rounded-lg">
<h4 className="font-bold text-lg mb-2"></h4>
{video.vrank_info ? (
<>
<p className="text-gray-700">: {video.vrank_info.vrank_score.toFixed(2)}</p>
<p className="text-gray-700 font-bold text-xl">: {video.score_rank}</p>
</>
) : (
<p className="text-gray-700"></p>
)}
{video.video_increase && (
<>
<h5 className="font-semibold mt-2"></h5>
<div className="grid grid-cols-2 gap-2">
<p className="text-gray-700">: {video.video_increase.view.toLocaleString()}</p>
<p className="text-gray-700">: {video.video_increase.like.toLocaleString()}</p>
<p className="text-gray-700">: {video.video_increase.coin.toLocaleString()}</p>
<p className="text-gray-700">
: {video.video_increase.favorite.toLocaleString()}
</p>
<p className="text-gray-700">: {video.video_increase.reply.toLocaleString()}</p>
<p className="text-gray-700">: {video.video_increase.share.toLocaleString()}</p>
<p className="text-gray-700">: {video.video_increase.danmaku.toLocaleString()}</p>
</div>
</>
)}
</div>
<p className="text-gray-600 mt-2">
: {new Date(video.video_info.timestamp * 1000).toLocaleString("zh-CN")}
</p>
<div className="mt-4">
<p className="font-semibold">: {achievement.name}</p>
{achievement.next && (
<div className="mt-2">
<p>
{achievement.next} {" "}
{(achievement.next === "殿堂曲"
? 100000
: achievement.next === "传说曲"
? 1000000
: 10000000) - video.video_stat.view}{" "}
</p>
<div className="w-full bg-gray-200 rounded-full h-2.5 mt-2">
<div
className="bg-blue-600 h-2.5 rounded-full"
style={{ width: `${achievement.progress}%` }}
></div>
</div>
</div>
)}
</div>
</div>
</div>
</li>
)
})}
</ul> </ul>
) : ( ) : (
!loading && !error && <p></p> !loading && !error && <p></p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) );
} }