feat: 前台评测列表页+导航入口+评测API

This commit is contained in:
sgp
2026-05-22 22:38:31 +08:00
parent 6dcfafceb6
commit ba867ba1f5
3 changed files with 343 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const pageSize = parseInt(searchParams.get("pageSize") || "12");
const category = searchParams.get("category") || undefined;
const where: Record<string, unknown> = {
status: "PUBLISHED",
};
if (category) {
where.skill = { categorySlug: category };
}
const [reviews, total] = await Promise.all([
prisma.skillReview.findMany({
where,
include: {
skill: {
select: {
id: true,
name: true,
slug: true,
description: true,
sourceUrl: true,
category: { select: { name: true, slug: true } },
},
},
},
orderBy: { overall: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.skillReview.count({ where }),
]);
const serialized = reviews.map((r) => ({
id: r.id,
overall: r.overall,
capability: r.capability,
devExp: r.devExp,
costLicense: r.costLicense,
community: r.community,
performance: r.performance,
reviews: r.reviews,
skill: r.skill,
reviewedAt: r.reviewedAt.toISOString(),
}));
return NextResponse.json(
{ reviews: serialized, total, page, pageSize },
{
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
}
);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+276
View File
@@ -0,0 +1,276 @@
"use client";
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface ReviewItem {
id: number;
overall: number;
capability: number;
devExp: number;
costLicense: number;
community: number;
performance: number;
reviews?: Record<string, { score: number; summary: string; detail: string }>;
skill: {
id: number;
name: string;
slug: string;
description: string;
sourceUrl: string;
category: { name: string; slug: string } | null;
};
reviewedAt: string;
}
const dimLabels: Record<string, string> = {
capability: "能力",
devExp: "体验",
costLicense: "成本",
community: "社区",
performance: "性能",
};
function getScoreColor(score: number): string {
if (score >= 4) return "text-emerald-700";
if (score >= 3) return "text-amber-700";
return "text-red-600";
}
function getScoreBg(score: number): string {
if (score >= 4) return "bg-emerald-50";
if (score >= 3) return "bg-amber-50";
return "bg-red-50";
}
function getOverallLabel(score: number): string {
if (score >= 4.5) return "卓越";
if (score >= 4) return "优秀";
if (score >= 3.5) return "良好";
if (score >= 3) return "中等";
return "一般";
}
function MiniRadar({ scores }: { scores: number[] }) {
const size = 80;
const cx = size / 2;
const cy = size / 2;
const r = 30;
const angles = scores.map((_, i) => (Math.PI * 2 * i) / scores.length - Math.PI / 2);
const points = scores.map((s, i) => {
const val = (s / 5) * r;
return `${cx + val * Math.cos(angles[i])},${cy + val * Math.sin(angles[i])}`;
}).join(" ");
const gridRings = [1, 2, 3, 4, 5].map((level) => {
const gridPoints = angles.map((a) => {
const val = (level / 5) * r;
return `${cx + val * Math.cos(a)},${cy + val * Math.sin(a)}`;
}).join(" ");
return <polygon key={level} points={gridPoints} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />;
});
const axisLines = angles.map((a, i) => (
<line key={i} x1={cx} y1={cy} x2={cx + r * Math.cos(a)} y2={cy + r * Math.sin(a)} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
));
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{gridRings}
{axisLines}
<polygon points={points} fill="rgba(139,92,246,0.15)" stroke="rgba(139,92,246,0.7)" strokeWidth="1.5" />
{scores.map((s, i) => (
<circle key={i} cx={cx + (s / 5) * r * Math.cos(angles[i])} cy={cy + (s / 5) * r * Math.sin(angles[i])} r="2.5" fill={s >= 4 ? "#10b981" : s >= 3 ? "#f59e0b" : "#ef4444"} />
))}
</svg>
);
}
function ReviewCard({ review }: { review: ReviewItem }) {
const scores = [review.capability, review.devExp, review.costLicense, review.community, review.performance];
const reviewEntries = review.reviews
? (Object.entries(review.reviews).filter(
([, v]) => v && typeof v === "object" && "score" in v
) as [string, { score: number; summary: string; detail: string }][])
: [];
return (
<Link href={`/skills/${review.skill.slug}`} className="block group">
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] transition-all duration-200 hover:shadow-md hover:border-primary/30 h-full">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate">
{review.skill.name}
</h3>
{review.skill.category && (
<span className="inline-block mt-1 text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded-md">
{review.skill.category.name}
</span>
)}
</div>
<div className={`flex-shrink-0 flex flex-col items-center justify-center rounded-xl px-3 py-2 ${getScoreBg(review.overall)}`}>
<span className={`text-xl font-bold ${getScoreColor(review.overall)}`}>
{review.overall}
</span>
<span className="text-[10px] text-muted-foreground">{getOverallLabel(review.overall)}</span>
</div>
</div>
<div className="flex items-center gap-4 mb-4">
<MiniRadar scores={scores} />
<div className="flex-1 grid grid-cols-5 gap-1">
{scores.map((s, i) => (
<div key={i} className="flex flex-col items-center">
<span className="text-[10px] text-muted-foreground mb-0.5">{dimLabels[Object.keys(dimLabels)[i]]}</span>
<div className="w-full bg-muted/50 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full ${s >= 4 ? "bg-emerald-500" : s >= 3 ? "bg-amber-500" : "bg-red-500"}`}
style={{ width: `${(s / 5) * 100}%` }}
/>
</div>
<span className="text-[10px] font-medium text-foreground/70 mt-0.5">{s}</span>
</div>
))}
</div>
</div>
{reviewEntries.length > 0 && (
<div className="space-y-1.5">
{reviewEntries.slice(0, 3).map(([key, dim]) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground w-10 flex-shrink-0">{dimLabels[key]}</span>
<span className="text-foreground/80 truncate">{dim.summary}</span>
</div>
))}
</div>
)}
{review.skill.description && (
<p className="mt-3 text-xs text-muted-foreground/70 line-clamp-2">
{review.skill.description}
</p>
)}
</CardContent>
</Card>
</Link>
);
}
function ReviewsContent() {
const searchParams = useSearchParams();
const [reviews, setReviews] = useState<ReviewItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const pageSize = 12;
const fetchReviews = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/reviews/list?page=${page}&pageSize=${pageSize}`);
if (res.ok) {
const data = await res.json();
setReviews(data.reviews || []);
setTotal(data.total || 0);
}
} catch {}
setLoading(false);
}, [page]);
useEffect(() => {
fetchReviews();
}, [fetchReviews]);
const totalPages = Math.ceil(total / pageSize);
return (
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-violet-100">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
AI评测
</h1>
<p className="mt-2 text-sm text-muted-foreground">
基于五维模型的AI技能深度评测,涵盖场景能力、开发体验、成本许可、社区健康、性能稳定
</p>
<div className="mt-3 flex items-center gap-4 text-xs text-muted-foreground">
<span>共 <strong className="text-foreground">{total}</strong> 项评测</span>
<span className="h-3 w-px bg-border/60" />
<span>按综合评分排序</span>
</div>
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/60 bg-white animate-pulse">
<CardContent className="p-5">
<div className="h-5 bg-muted/50 rounded w-2/3 mb-3" />
<div className="h-3 bg-muted/50 rounded w-1/3 mb-4" />
<div className="h-20 bg-muted/30 rounded" />
</CardContent>
</Card>
))}
</div>
) : reviews.length === 0 ? (
<div className="text-center py-20">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto text-muted-foreground/40 mb-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p className="text-muted-foreground">暂无评测数据</p>
<p className="text-xs text-muted-foreground/60 mt-1">评测正在生成中,请稍后再来</p>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
上一页
</button>
<span className="text-sm text-muted-foreground px-2">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
下一页
</button>
</div>
)}
</>
)}
</div>
);
}
export default function ReviewsPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">加载中...</p>
</div>
}>
<ReviewsContent />
</Suspense>
);
}
+1
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
const navLinks = [ const navLinks = [
{ label: "AI工具", href: "/", icon: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" }, { label: "AI工具", href: "/", icon: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" },
{ label: "AI技能", href: "/skills", icon: "M13 10V3L4 14h7v7l9-11h-7z" }, { label: "AI技能", href: "/skills", icon: "M13 10V3L4 14h7v7l9-11h-7z" },
{ label: "AI评测", href: "/reviews", icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" },
{ label: "AI日报", href: "/daily", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" }, { label: "AI日报", href: "/daily", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" },
]; ];