From ba867ba1f5c9c1d71cf55cb2c5ca895d2188f9ea Mon Sep 17 00:00:00 2001 From: sgp Date: Fri, 22 May 2026 22:38:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E5=8F=B0=E8=AF=84=E6=B5=8B?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=A1=B5+=E5=AF=BC=E8=88=AA=E5=85=A5?= =?UTF-8?q?=E5=8F=A3+=E8=AF=84=E6=B5=8BAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/reviews/list/route.ts | 66 +++++++ src/app/reviews/page.tsx | 276 ++++++++++++++++++++++++++++++ src/components/layout/Header.tsx | 1 + 3 files changed, 343 insertions(+) create mode 100644 src/app/api/reviews/list/route.ts create mode 100644 src/app/reviews/page.tsx diff --git a/src/app/api/reviews/list/route.ts b/src/app/api/reviews/list/route.ts new file mode 100644 index 0000000..3c5bdf1 --- /dev/null +++ b/src/app/api/reviews/list/route.ts @@ -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 = { + 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 }); + } +} diff --git a/src/app/reviews/page.tsx b/src/app/reviews/page.tsx new file mode 100644 index 0000000..d8ede4d --- /dev/null +++ b/src/app/reviews/page.tsx @@ -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; + skill: { + id: number; + name: string; + slug: string; + description: string; + sourceUrl: string; + category: { name: string; slug: string } | null; + }; + reviewedAt: string; +} + +const dimLabels: Record = { + 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 ; + }); + + const axisLines = angles.map((a, i) => ( + + )); + + return ( + + {gridRings} + {axisLines} + + {scores.map((s, i) => ( + = 4 ? "#10b981" : s >= 3 ? "#f59e0b" : "#ef4444"} /> + ))} + + ); +} + +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 ( + + + +
+
+

+ {review.skill.name} +

+ {review.skill.category && ( + + {review.skill.category.name} + + )} +
+
+ + {review.overall} + + {getOverallLabel(review.overall)} +
+
+ +
+ +
+ {scores.map((s, i) => ( +
+ {dimLabels[Object.keys(dimLabels)[i]]} +
+
= 4 ? "bg-emerald-500" : s >= 3 ? "bg-amber-500" : "bg-red-500"}`} + style={{ width: `${(s / 5) * 100}%` }} + /> +
+ {s} +
+ ))} +
+
+ + {reviewEntries.length > 0 && ( +
+ {reviewEntries.slice(0, 3).map(([key, dim]) => ( +
+ {dimLabels[key]} + {dim.summary} +
+ ))} +
+ )} + + {review.skill.description && ( +

+ {review.skill.description} +

+ )} + + + + ); +} + +function ReviewsContent() { + const searchParams = useSearchParams(); + const [reviews, setReviews] = useState([]); + 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 ( +
+
+

+
+ + + +
+ AI评测 +

+

+ 基于五维模型的AI技能深度评测,涵盖场景能力、开发体验、成本许可、社区健康、性能稳定 +

+
+ 共 {total} 项评测 + + 按综合评分排序 +
+
+ + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + +
+
+
+ + + ))} +
+ ) : reviews.length === 0 ? ( +
+ + + +

暂无评测数据

+

评测正在生成中,请稍后再来

+
+ ) : ( + <> +
+ {reviews.map((review) => ( + + ))} +
+ + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + )} +
+ ); +} + +export default function ReviewsPage() { + return ( + +

加载中...

+
+ }> + + + ); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 14aa088..54e2f54 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; 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: "/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" }, ];