全面审查优化: 43项修复(安全/性能/可靠性)+ 文档更新

🔴 P0 安全: .env入gitignore, CSP/HSTS, Logo安全(5MB限制+SVG消毒+SHA256), middleware权限收紧(VIP不能访问admin), 发布去重, GitHub认证, 删除危险API, skill-discoverer绕过审核修复, prisma默认连接修复
🔴 P0 可靠性: deepseek/github超时重试, 脚本层DeepSeek保护, 脚本启动验证, OAuth竞态修复
🔴 P0 性能: skill-discoverer N+1优化(↓94%), 后台API分页限制, getUserStats聚合优化
🟡 P1: 分类动态加载, 星级字段统一, fetch-logos并发Bug修复, 批量发布冲突不删除, check-tools重试, 前端空指针修复(13处), logo-fetcher日志, rate-limit清理, 公开API缓存
This commit is contained in:
ZhuiGuangAI Dev
2026-05-26 00:30:36 +08:00
parent 9bf3e7574a
commit e263e28c81
47 changed files with 1212 additions and 788 deletions
+27 -24
View File
@@ -3,28 +3,13 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
const TOOL_CATEGORIES = [
{ id: 165, name: "文本生成" },
{ id: 174, name: "图像生成" },
{ id: 183, name: "视频生成" },
{ id: 191, name: "音频与语音" },
{ id: 199, name: "代码开发" },
{ id: 208, name: "AI Agent" },
{ id: 216, name: "办公效率" },
{ id: 224, name: "设计与创意" },
{ id: 231, name: "营销与SEO" },
{ id: 238, name: "数据分析" },
{ id: 245, name: "教育与学习" },
{ id: 252, name: "搜索与信息" },
{ id: 258, name: "安全与合规" },
{ id: 264, name: "医疗健康" },
{ id: 270, name: "金融与法律" },
{ id: 276, name: "电商与零售" },
{ id: 282, name: "3D与游戏" },
{ id: 288, name: "科学研究" },
{ id: 294, name: "模型与框架" },
{ id: 302, name: "聊天机器人" },
];
interface CategoryItem {
id: number;
name: string;
slug: string;
icon: string | null;
children: CategoryItem[];
}
const TAG_POOL = [
"文本生成", "图像生成", "视频生成", "音频处理", "代码生成",
@@ -33,7 +18,19 @@ const TAG_POOL = [
"企业级", "多模态", "大语言模型", "语音合成", "智能客服",
];
function flattenCategories(nodes: CategoryItem[]): { id: number; name: string }[] {
const result: { id: number; name: string }[] = [];
for (const node of nodes) {
result.push({ id: node.id, name: node.name });
if (node.children && node.children.length > 0) {
result.push(...flattenCategories(node.children));
}
}
return result;
}
export default function DiscoverToolsPage() {
const [toolCategories, setToolCategories] = useState<{ id: number; name: string }[]>([]);
const [excludedToolNames, setExcludedToolNames] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<{ id: number; name: string } | null>(null);
@@ -89,7 +86,13 @@ export default function DiscoverToolsPage() {
}, []);
useEffect(() => {
fetchExcludedNames().finally(() => setLoading(false));
Promise.all([
fetchExcludedNames(),
fetch("/api/categories")
.then((r) => r.json())
.then((data: CategoryItem[]) => setToolCategories(flattenCategories(data)))
.catch(() => {}),
]).finally(() => setLoading(false));
}, [fetchExcludedNames]);
const getPrompt = () => {
@@ -192,7 +195,7 @@ export default function DiscoverToolsPage() {
<div className="mb-8">
<h2 className="text-lg font-semibold mb-3">选择分类</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
{TOOL_CATEGORIES.map((cat) => {
{toolCategories.map((cat) => {
const isSelected = selectedCategory?.id === cat.id;
return (
<button
+1 -1
View File
@@ -97,7 +97,7 @@ export default function ReviewsPage() {
total={stats.total}
published={stats.published}
draft={stats.draft}
average={stats.overallAvg.toFixed(1)}
average={stats.overallAvg?.toFixed(1) ?? "0.0"}
currentFilter={statusFilter}
onFilterChange={(f) => {
setStatusFilter(f);
+1 -2
View File
@@ -244,8 +244,7 @@ export default function HomePageClient({
<HeroBanner
title="追光"
highlight="AI"
highlightColor="blue"
starStyle="cosmos"
colorScheme="cosmos"
stats={[
{ label: "AI工具", value: totalTools, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
+31 -14
View File
@@ -15,17 +15,12 @@ interface CheckResult {
logoUrl: string;
}
async function checkSingleTool(tool: {
id: number;
name: string;
websiteUrl: string | null;
logoUrl: string | null;
}, type: "published" | "pending"): Promise<CheckResult> {
const url = tool.websiteUrl;
let accessible = false;
let html: string | null = null;
if (url) {
async function checkWebsite(url: string, maxRetries = 2): Promise<{ accessible: boolean; html: string | null }> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
@@ -41,7 +36,8 @@ async function checkSingleTool(tool: {
});
clearTimeout(timeout);
accessible = res.status >= 200 && res.status < 400;
const accessible = res.status >= 200 && res.status < 400;
let html: string | null = null;
if (accessible) {
const ct = res.headers.get("content-type") || "";
@@ -49,10 +45,29 @@ async function checkSingleTool(tool: {
html = await res.text();
}
}
} catch {
accessible = false;
return { accessible, html };
} catch (e) {
lastError = e;
}
}
return { accessible: false, html: null };
}
async function checkSingleTool(tool: {
id: number;
name: string;
websiteUrl: string | null;
logoUrl: string | null;
}, type: "published" | "pending"): Promise<CheckResult> {
const url = tool.websiteUrl;
let accessible = false;
let html: string | null = null;
if (url) {
const result = await checkWebsite(url);
accessible = result.accessible;
html = result.html;
}
let logoFetched = false;
let logoMethod = "";
@@ -124,9 +139,11 @@ export async function POST(request: Request) {
type === "published"
? await prisma.tool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
take: 500,
})
: await prisma.pendingTool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
take: 500,
});
const CONCURRENCY = 10;
+2 -2
View File
@@ -17,8 +17,8 @@ export async function POST(request: Request) {
const { skills, categoryId } = parsed.data;
const [existingPending, existingPublished] = await Promise.all([
prisma.pendingSkill.findMany({ select: { name: true, sourceUrl: true } }),
prisma.skill.findMany({ select: { name: true, sourceUrl: true } }),
prisma.pendingSkill.findMany({ select: { name: true, sourceUrl: true }, take: 1000 }),
prisma.skill.findMany({ select: { name: true, sourceUrl: true }, take: 1000 }),
]);
const allExisting = [...existingPending, ...existingPublished];
+2 -2
View File
@@ -17,8 +17,8 @@ export async function POST(request: Request) {
const { tools, categoryId } = parsed.data;
const [existingPending, existingPublished] = await Promise.all([
prisma.pendingTool.findMany({ select: { name: true, websiteUrl: true } }),
prisma.tool.findMany({ select: { name: true, websiteUrl: true } }),
prisma.pendingTool.findMany({ select: { name: true, websiteUrl: true }, take: 1000 }),
prisma.tool.findMany({ select: { name: true, websiteUrl: true }, take: 1000 }),
]);
const allExisting = [...existingPending, ...existingPublished];
@@ -15,6 +15,26 @@ export async function POST(request: Request, { params }: { params: { id: string
return NextResponse.json({ error: "该记录已被处理" }, { status: 409 });
}
const [nameConflict, urlConflict, slugConflict] = await Promise.all([
prisma.skill.findFirst({ where: { name: pending.name }, select: { id: true } }),
pending.sourceUrl
? prisma.skill.findFirst({ where: { sourceUrl: pending.sourceUrl }, select: { id: true } })
: Promise.resolve(null),
pending.slug
? prisma.skill.findUnique({ where: { slug: pending.slug }, select: { id: true } })
: Promise.resolve(null),
]);
if (nameConflict) {
return NextResponse.json({ error: `技能名"${pending.name}"已存在,无法发布` }, { status: 409 });
}
if (urlConflict) {
return NextResponse.json({ error: `来源地址"${pending.sourceUrl}"已被已发布技能使用` }, { status: 409 });
}
if (slugConflict) {
return NextResponse.json({ error: `slug"${pending.slug}"已存在,请编辑后再发布` }, { status: 409 });
}
const slug = pending.slug || pending.name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 100);
const features = Array.isArray(pending.features) ? pending.features as any[] : [];
@@ -33,6 +53,7 @@ export async function POST(request: Request, { params }: { params: { id: string
sourceType: pending.sourceType || "github",
features,
tags,
rating: pending.rating || 0,
status: "published",
},
});
-13
View File
@@ -43,16 +43,3 @@ export async function GET(request: Request) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE() {
const authError = await requireAdmin();
if (authError) return authError;
try {
const result = await prisma.pendingSkill.deleteMany({});
return NextResponse.json({ success: true, deleted: result.count });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
@@ -57,10 +57,6 @@ export async function POST(request: Request) {
toPublish.push(pending);
}
if (skipIds.length > 0) {
await prisma.pendingTool.deleteMany({ where: { id: { in: skipIds } } });
}
if (toPublish.length === 0) {
return NextResponse.json({
success: true,
+8 -10
View File
@@ -12,23 +12,19 @@ async function processWithConcurrency<T, R>(
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = [];
const executing: Promise<void>[] = [];
const executing: Set<Promise<void>> = new Set();
for (const item of items) {
const p = fn(item).then((result) => {
results.push(result);
});
executing.push(p);
executing.add(p);
if (executing.length >= limit) {
const cleanup = () => executing.delete(p);
p.then(cleanup, cleanup);
if (executing.size >= limit) {
await Promise.race(executing);
for (let i = executing.length - 1; i >= 0; i--) {
const settled = await Promise.race([
executing[i].then(() => true, () => true),
Promise.resolve(false),
]);
if (settled) executing.splice(i, 1);
}
}
}
@@ -62,6 +58,7 @@ export async function POST(request: Request) {
const publishedTools = await prisma.tool.findMany({
where: { ...logoFilter, ...(toolIds ? { id: { in: toolIds } } : {}) },
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
take: 500,
});
tools.push(...publishedTools.map((t) => ({ ...t, model: "tool" })));
}
@@ -70,6 +67,7 @@ export async function POST(request: Request) {
const pendingTools = await prisma.pendingTool.findMany({
where: { websiteUrl: { not: null }, ...logoFilter, ...(toolIds ? { id: { in: toolIds } } : {}) },
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
take: 500,
});
tools.push(...pendingTools.map((t) => ({ ...t, model: "pendingTool" })));
}
+58 -43
View File
@@ -10,72 +10,73 @@ interface UpdateResult {
newRating: number;
}
async function fetchGiteeRating(owner: string, repo: string): Promise<number> {
interface StarResult {
rating: number;
stars: number;
}
async function fetchGiteeRating(owner: string, repo: string): Promise<StarResult> {
try {
const res = await fetch(`https://gitee.com/api/v5/repos/${owner}/${repo}`, {
headers: { "User-Agent": "ZhuiGuangAI-Bot" },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) return 0;
if (!res.ok) return { rating: 0, stars: 0 };
const data = await res.json();
const stars = data.stargazers_count || data.forks_count || 0;
if (stars >= 5000) return 5.0;
if (stars >= 2000) return 4.5;
if (stars >= 500) return 4.0;
if (stars >= 100) return 3.5;
if (stars >= 30) return 3.0;
if (stars >= 10) return 2.5;
return 2.0;
return { stars, rating: starsToRating(stars) };
} catch {
return 0;
return { rating: 0, stars: 0 };
}
}
async function fetchGitCodeRating(owner: string, repo: string): Promise<number> {
async function fetchGitCodeRating(owner: string, repo: string): Promise<StarResult> {
try {
const res = await fetch(`https://api.gitcode.com/api/v5/repos/${owner}/${repo}`, {
headers: { "User-Agent": "ZhuiGuangAI-Bot" },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) return 0;
if (!res.ok) return { rating: 0, stars: 0 };
const data = await res.json();
const stars = data.stargazers_count || data.forks_count || 0;
if (stars >= 5000) return 5.0;
if (stars >= 2000) return 4.5;
if (stars >= 500) return 4.0;
if (stars >= 100) return 3.5;
if (stars >= 30) return 3.0;
if (stars >= 10) return 2.5;
return 2.0;
return { stars, rating: starsToRating(stars) };
} catch {
return 0;
return { rating: 0, stars: 0 };
}
}
async function fetchGitHubRating(owner: string, repo: string): Promise<number> {
async function fetchGitHubRating(owner: string, repo: string): Promise<StarResult> {
try {
const headers: Record<string, string> = {
"User-Agent": "ZhuiGuangAI-Bot",
Accept: "application/vnd.github.v3+json",
};
if (process.env.GITHUB_TOKEN) {
headers["Authorization"] = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: {
"User-Agent": "ZhuiGuangAI-Bot",
Accept: "application/vnd.github.v3+json",
},
headers,
signal: AbortSignal.timeout(10000),
});
if (!res.ok) return 0;
if (!res.ok) return { rating: 0, stars: 0 };
const data = await res.json();
const stars = (data as { stargazers_count?: number }).stargazers_count ?? 0;
if (stars >= 10000) return 5.0;
if (stars >= 5000) return 4.5;
if (stars >= 1000) return 4.0;
if (stars >= 200) return 3.5;
if (stars >= 50) return 3.0;
if (stars >= 10) return 2.5;
return 2.0;
return { stars, rating: starsToRating(stars) };
} catch {
return 0;
return { rating: 0, stars: 0 };
}
}
function starsToRating(stars: number): number {
if (stars >= 10000) return 5.0;
if (stars >= 5000) return 4.5;
if (stars >= 1000) return 4.0;
if (stars >= 200) return 3.5;
if (stars >= 50) return 3.0;
if (stars >= 10) return 2.5;
return 2.0;
}
export async function POST(request: Request) {
const authError = await requireAdmin();
if (authError) return authError;
@@ -95,11 +96,13 @@ export async function POST(request: Request) {
type === "published"
? await prisma.skill.findMany({
where: { sourceUrl: { not: "" } },
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true },
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true, githubStars: true },
take: 500,
})
: await prisma.pendingSkill.findMany({
where: { sourceUrl: { not: "" } },
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true },
take: 500,
});
const results: UpdateResult[] = [];
@@ -108,28 +111,40 @@ export async function POST(request: Request) {
if (!skill.sourceUrl) continue;
try {
let newRating = 0;
let result: StarResult = { rating: 0, stars: 0 };
const url = skill.sourceUrl;
if (url.includes("gitee.com")) {
const match = url.match(/gitee\.com\/([^/]+)\/([^/]+)/);
if (match) newRating = await fetchGiteeRating(match[1], match[2].replace(/\.git$/, ""));
if (match) result = await fetchGiteeRating(match[1], match[2].replace(/\.git$/, ""));
} else if (url.includes("gitcode.com")) {
const match = url.match(/gitcode\.com\/([^/]+)\/([^/]+)/);
if (match) newRating = await fetchGitCodeRating(match[1], match[2].replace(/\.git$/, ""));
if (match) result = await fetchGitCodeRating(match[1], match[2].replace(/\.git$/, ""));
} else if (url.includes("github.com")) {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match) newRating = await fetchGitHubRating(match[1], match[2].replace(/\.git$/, ""));
if (match) result = await fetchGitHubRating(match[1], match[2].replace(/\.git$/, ""));
}
if (newRating > 0) {
if (result.rating > 0) {
const oldRating = skill.rating;
if (type === "published") {
await prisma.skill.update({ where: { id: skill.id }, data: { rating: newRating } });
await prisma.skill.update({
where: { id: skill.id },
data: {
rating: result.rating,
githubStars: result.stars > 0 ? result.stars : undefined,
},
});
} else {
await prisma.pendingSkill.update({ where: { id: skill.id }, data: { rating: newRating } });
await prisma.pendingSkill.update({ where: { id: skill.id }, data: { rating: result.rating } });
}
results.push({ id: skill.id, name: skill.name, sourceType: skill.sourceType || "unknown", oldRating, newRating });
results.push({
id: skill.id,
name: skill.name,
sourceType: skill.sourceType || "unknown",
oldRating,
newRating: result.rating,
});
}
} catch (err) {
console.error(`Failed to update rating for skill ${skill.id}:`, err);
+3 -1
View File
@@ -85,7 +85,9 @@ export async function GET(req: Request) {
})),
}));
return NextResponse.json({ comments: formatted, totalCount, page, pageSize });
const res = NextResponse.json({ comments: formatted, totalCount, page, pageSize });
res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120");
return res;
}
export async function POST(req: Request) {
+3 -1
View File
@@ -12,5 +12,7 @@ export async function GET() {
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return NextResponse.json({ categories });
const res = NextResponse.json({ categories });
res.headers.set("Cache-Control", "public, s-maxage=300, stale-while-revalidate=120");
return res;
}
+3 -1
View File
@@ -38,7 +38,9 @@ export async function GET(req: Request, { params }: { params: { id: string } })
},
}));
return NextResponse.json({ posts: formatted, totalCount, page, pageSize });
const res = NextResponse.json({ posts: formatted, totalCount, page, pageSize });
res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120");
return res;
}
export async function POST(req: Request, { params }: { params: { id: string } }) {
+3 -1
View File
@@ -60,7 +60,9 @@ export async function GET(req: Request) {
},
}));
return NextResponse.json({ topics: formatted, totalCount, page, pageSize });
const res = NextResponse.json({ topics: formatted, totalCount, page, pageSize });
res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120");
return res;
}
export async function POST(req: Request) {
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const type = searchParams.get("type") || "points";
const limit = Math.min(parseInt(searchParams.get("limit") || "10"), 50);
if (type === "active") {
const users = await prisma.user.findMany({
where: {
OR: [
{ forumTopics: { some: {} } },
{ forumPosts: { some: {} } },
],
},
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
},
orderBy: { points: "desc" },
take: limit,
});
const res = NextResponse.json({ users });
res.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=120");
return res;
}
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
},
orderBy: { points: "desc" },
take: limit,
});
const res = NextResponse.json({ users });
res.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=120");
return res;
} catch {
const res = NextResponse.json({ users: [] }, { status: 500 });
res.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=120");
return res;
}
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getLevelInfo } from "@/lib/level-config";
interface UserRanking {
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
level: string;
points: number;
bio?: string | null;
}
export function CommunitySidebar() {
const [monthlyActive, setMonthlyActive] = useState<UserRanking[]>([]);
const [topByPoints, setTopByPoints] = useState<UserRanking[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/user/rankings?type=active&limit=10")
.then((r) => r.json())
.then((data) => {
setMonthlyActive(data.users || []);
})
.catch(() => {})
.finally(() => {});
fetch("/api/user/rankings?type=points&limit=10")
.then((r) => r.json())
.then((data) => {
setTopByPoints(data.users || []);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, []);
const renderSkeleton = () => (
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 animate-pulse">
<div className="h-5 w-5 rounded bg-muted/40" />
<div className="h-4 w-4 rounded-full bg-muted/40" />
<div className="h-3 flex-1 rounded bg-muted/40" />
</div>
))
);
const renderUserRow = (user: UserRanking, i: number) => {
const initial = user.name?.charAt(0).toUpperCase() || user.email.charAt(0).toUpperCase() || "?";
const levelInfo = getLevelInfo(user.points);
return (
<Link
key={user.id}
href={`/user/${user.id}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-sky-100 text-sky-700" : "bg-muted/30 text-muted-foreground"}`}>{i + 1}</span>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-4 w-4 rounded-full object-cover shrink-0" />
) : (
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-[8px] font-bold text-sky-600 shrink-0">
{initial}
</div>
)}
<span className="flex-1 truncate text-left text-[12px]">{user.name || user.email?.split("@")[0]}</span>
{levelInfo && (
<span className="text-[9px] text-foreground/30 shrink-0">{levelInfo.label}</span>
)}
</Link>
);
};
return (
<div className="sidebar-bg rounded-xl p-3 sticky top-[68px] max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin space-y-4">
{/* Monthly Active Users */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-sky-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">本月活跃用户</span>
</div>
<div className="space-y-0.5">
{loading ? renderSkeleton() : monthlyActive.map(renderUserRow)}
</div>
</div>
<div className="h-px bg-border/40" />
{/* Total User Points Ranking */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">积分总排行</span>
</div>
<div className="space-y-0.5">
{loading ? renderSkeleton() : topByPoints.map(renderUserRow)}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -99,8 +99,8 @@ export default async function CategoryPage({ params, searchParams }: Props) {
</h3>
</div>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.user.name || topic.user.email.split("@")[0]}</span>
<span>{topic.createdAt.toLocaleDateString("zh-CN")}</span>
<span>{topic.user.name || topic.user.email?.split("@")[0]}</span>
<span>{topic.createdAt?.toLocaleDateString("zh-CN")}</span>
<span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span>
</div>
+130 -111
View File
@@ -1,11 +1,26 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import Link from "next/link";
import { HeroBanner } from "@/components/common/HeroBanner";
import { CommunitySidebar } from "./CommunitySidebar";
export const dynamic = "force-dynamic";
async function getHotTopics() {
return prisma.forumTopic.findMany({
export default async function CommunityPage() {
const [categories, topicsCount, userCount] = await Promise.all([
prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
}),
prisma.forumTopic.count(),
prisma.user.count(),
]);
const hotTopics = await prisma.forumTopic.findMany({
where: {},
include: {
category: { select: { name: true, slug: true, icon: true } },
@@ -15,92 +30,87 @@ async function getHotTopics() {
orderBy: [{ isPinned: "desc" }, { createdAt: "desc" }],
take: 5,
});
}
async function getActiveUsers() {
return prisma.user.findMany({
where: {
OR: [
{ forumTopics: { some: {} } },
{ forumPosts: { some: {} } },
],
},
select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true },
take: 5,
});
}
export default async function CommunityPage() {
const categories = await prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
const [hotTopics, activeUsers] = await Promise.all([getHotTopics(), getActiveUsers()]);
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-[1440px] mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold">追光会</h1>
<p className="mt-1 text-sm text-muted-foreground">AI爱好者交流社区</p>
</div>
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[208px_1fr_280px] gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div>
<h2 className="text-lg font-semibold mb-4">板块</h2>
<div className="space-y-4">
{categories.map((cat) => (
<div key={cat.id}>
<div className="flex items-center gap-3 mb-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/5 text-lg">
{cat.icon || "📌"}
</div>
<h3 className="text-sm font-semibold">{cat.name}</h3>
{cat.description && (
<span className="text-xs text-muted-foreground/40">{cat.description}</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 ml-11">
{cat.children.map((child) => (
<div
key={child.id}
className="flex items-center gap-3 p-3 rounded-lg border border-border/30 bg-white/60 transition-all hover:border-primary/30 hover:bg-white cursor-pointer group"
onClick={() => (window.location.href = `/community/${child.slug}`)}
>
<span className="text-lg">{child.icon || "📌"}</span>
<div className="min-w-0 flex-1">
<h4 className="text-xs font-medium group-hover:text-primary transition-colors truncate">{child.name}</h4>
{child.description && (
<p className="text-[10px] text-muted-foreground/50 truncate">{child.description}</p>
)}
</div>
<span className="text-[10px] text-muted-foreground/30">{child.topicCount}话题</span>
</div>
))}
</div>
</div>
))}
</div>
{/* Left Sidebar: Rankings */}
<aside className="hidden lg:block">
<CommunitySidebar />
</aside>
{/* Middle Content */}
<main className="min-w-0">
<HeroBanner
title="追光"
highlight="会"
colorScheme="forest"
stats={[
{ label: "板块", value: categories.reduce((sum, c) => sum + c.children.length + 1, 0), unit: "个" },
{ label: "话题", value: topicsCount, unit: "帖" },
{ label: "会员", value: userCount, unit: "人" },
]}
slogan="AI爱好者交流社区,分享你的发现和思考"
subSlogan="AI工具 · 开源技能 · 技术深度 · 教程分享 · 社区互动"
/>
{/* Forum Categories */}
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="px-5 py-4 border-b border-border/30">
<h2 className="text-sm font-semibold text-foreground">板块</h2>
</div>
<div className="p-5 space-y-5">
{categories.map((cat) => (
<div key={cat.id}>
<div className="flex items-center gap-3 mb-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/5 text-base">
{cat.icon || "📌"}
</div>
<h3 className="text-sm font-semibold">{cat.name}</h3>
{cat.description && (
<span className="text-[11px] text-muted-foreground/50">{cat.description}</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 ml-10">
{cat.children.map((child) => (
<Link href={`/community/${child.slug}`} key={child.id}
className="flex items-center gap-3 p-3 rounded-lg border border-border/30 bg-white/60 transition-all hover:border-primary/30 hover:bg-white group">
<span className="text-lg">{child.icon || "📌"}</span>
<div className="min-w-0 flex-1">
<h4 className="text-xs font-medium group-hover:text-primary transition-colors truncate">{child.name}</h4>
{child.description && (
<p className="text-[10px] text-muted-foreground/50 truncate">{child.description}</p>
)}
</div>
<span className="text-[10px] text-muted-foreground/30">{child.topicCount}话题</span>
</Link>
))}
</div>
</div>
))}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4">最新话题</h2>
<div className="space-y-3">
{hotTopics.map((topic) => {
{/* Hot Topics */}
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-border/30">
<h2 className="text-sm font-semibold text-foreground">最新话题</h2>
</div>
<div className="divide-y divide-border/30">
{hotTopics.length === 0 ? (
<div className="p-12 text-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto mb-3 text-muted-foreground/30">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
<p className="text-sm text-muted-foreground">暂无话题</p>
</div>
) : (
hotTopics.map((topic) => {
const initial = topic.user.name?.charAt(0).toUpperCase() || "?";
return (
<div
key={topic.id}
className="flex items-start gap-3 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 cursor-pointer"
onClick={() => (window.location.href = `/community/topic/${topic.id}`)}
>
<Link href={`/community/topic/${topic.id}`} key={topic.id}
className="flex items-start gap-3 p-4 transition-all hover:bg-muted/20">
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
) : (
@@ -118,46 +128,55 @@ export default async function CommunityPage() {
</h3>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.user.name || topic.user.email.split("@")[0]}</span>
<span>{topic.user.name || topic.user.email?.split("@")[0]}</span>
<span>{topic.category.name}</span>
<span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span>
</div>
</div>
</div>
</Link>
);
})}
</div>
})
)}
</div>
</div>
</main>
<div className="space-y-6">
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-semibold mb-4">活跃用户</h3>
<div className="space-y-3">
{activeUsers.map((user) => {
const initial = user.name?.charAt(0).toUpperCase() || "?";
return (
<div key={user.id} className="flex items-center gap-3">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{user.name || user.email.split("@")[0]}</p>
<p className="text-xs text-muted-foreground/50">{user.points}分</p>
</div>
</div>
);
})}
{/* Right Sidebar */}
<aside className="hidden lg:block">
<div className="sticky top-[68px] space-y-5">
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30">
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-blue-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
<h3 className="text-sm font-semibold text-foreground">社区指南</h3>
</div>
</div>
<div className="p-4 text-xs text-muted-foreground space-y-3">
<div className="flex items-start gap-2">
<span className="text-amber-500 font-bold mt-0.5">1.</span>
<p>在对应板块发布高质量话题,获得积分奖励</p>
</div>
<div className="flex items-start gap-2">
<span className="text-amber-500 font-bold mt-0.5">2.</span>
<p>积极参与讨论,有价值的回复将被标记为最佳答案</p>
</div>
<div className="flex items-start gap-2">
<span className="text-amber-500 font-bold mt-0.5">3.</span>
<p>分享你的AI工具使用心得和技能开发经验</p>
</div>
<div className="flex items-start gap-2">
<span className="text-amber-500 font-bold mt-0.5">4.</span>
<p>积分越高等级越高,解锁更多社区特权</p>
</div>
</div>
</div>
</div>
</div>
</aside>
</div>
</div>
);
}
}
+4 -4
View File
@@ -79,9 +79,9 @@ export default async function TopicPage({ params }: Props) {
</div>
)}
<div>
<p className="text-sm font-medium">{topic.user.name || topic.user.email.split("@")[0]}</p>
<p className="text-sm font-medium">{topic.user.name || topic.user.email?.split("@")[0]}</p>
<p className="text-xs text-muted-foreground/50">
{topic.createdAt.toLocaleDateString("zh-CN")} {topic.createdAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
{topic.createdAt?.toLocaleDateString("zh-CN")} {topic.createdAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground/50">
@@ -113,9 +113,9 @@ export default async function TopicPage({ params }: Props) {
</div>
)}
<div>
<p className="text-sm font-medium">{post.user.name || post.user.email.split("@")[0]}</p>
<p className="text-sm font-medium">{post.user.name || post.user.email?.split("@")[0]}</p>
<p className="text-xs text-muted-foreground/50">
{post.createdAt.toLocaleDateString("zh-CN")} {post.createdAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
{post.createdAt?.toLocaleDateString("zh-CN")} {post.createdAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
{isTopicAuthor && (
+1 -2
View File
@@ -360,8 +360,7 @@ export default function DailyPage() {
<HeroBanner
title="追光"
highlight="日报"
highlightColor="amber"
starStyle="dawn"
colorScheme="dawn"
stats={[
{ label: "日报", value: allItems.length, unit: "条" },
{ label: "分类", value: CATEGORY_KEYS.length, unit: "个" },
+8 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
@@ -181,10 +181,16 @@ export function ReviewsList({ initialReviews, initialTotal }: Props) {
setReviews(data.reviews || []);
setTotal(data.total || 0);
}
} catch {}
} catch {
console.error("加载评测列表失败");
}
setLoading(false);
}, []);
useEffect(() => {
fetchReviews(1);
}, []);
const handlePageChange = (newPage: number) => {
if (isInitial) setIsInitial(false);
setPage(newPage);
+175 -67
View File
@@ -1,80 +1,188 @@
import { prisma } from "@/lib/prisma";
import { Metadata } from "next";
"use client";
import { useState, useEffect } from "react";
import { HeroBanner } from "@/components/common/HeroBanner";
import { ReviewsList } from "./ReviewsList";
import { SidebarRankings } from "./SidebarRankings";
export const metadata: Metadata = {
title: "AI评测",
description: "基于五维模型的AI技能深度评测,涵盖场景能力、开发体验、成本许可、社区健康、性能稳定",
};
interface RankingItem {
id: number;
overall?: number;
skill?: { name: string; slug: string; stars?: number };
name?: string;
slug?: string;
githubStars?: number;
starsChangeWeek?: number;
starsChangeDay?: number;
}
export default async function ReviewsPage() {
const pageSize = 12;
interface RankingsData {
topByRating: RankingItem[];
topByStarsChange: RankingItem[];
totalReviews: number;
}
const [reviews, total] = await Promise.all([
prisma.skillReview.findMany({
where: { status: "PUBLISHED" },
include: {
skill: {
select: {
id: true,
name: true,
slug: true,
description: true,
sourceUrl: true,
category: { select: { name: true, slug: true } },
},
},
},
orderBy: { overall: "desc" },
skip: 0,
take: pageSize,
}),
prisma.skillReview.count({ where: { status: "PUBLISHED" } }),
]);
export default function ReviewsPage() {
const [rankings, setRankings] = useState<RankingsData | null>(null);
const [totalReviews, setTotalReviews] = useState(0);
const serializedReviews = 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 as Record<string, { score: number; summary: string; detail: string }> | undefined,
skill: r.skill,
reviewedAt: r.reviewedAt.toISOString(),
}));
useEffect(() => {
fetch("/api/skill-reviews/rankings")
.then((r) => r.json())
.then((data) => {
setRankings(data);
setTotalReviews(data.totalReviews || 0);
})
.catch(() => {
console.error("加载排行数据失败");
});
}, []);
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 className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[208px_1fr_280px] gap-6">
{/* Left Sidebar: Rankings */}
<aside className="hidden lg:block">
<div className="sidebar-bg rounded-xl p-3 sticky top-[68px] max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin space-y-4">
{/* External Hot Data Ranking */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-emerald-500 shrink-0">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">外部热度排行</span>
</div>
<div className="space-y-0.5">
{rankings ? (
rankings.topByStarsChange.slice(0, 10).map((item, i) => (
<a
key={item.id}
href={`/skills/${item.slug || ""}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-emerald-100 text-emerald-700" : "bg-muted/30 text-muted-foreground"}`}>{i + 1}</span>
<span className="flex-1 truncate text-left">{item.name}</span>
<span className="text-[10px] text-emerald-600 font-medium tabular-nums">
{(item.starsChangeWeek && item.starsChangeWeek > 0 ? "+" : "")}{item.starsChangeWeek || 0}
</span>
</a>
))
) : (
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 animate-pulse">
<div className="h-5 w-5 rounded bg-muted/40" />
<div className="h-3 flex-1 rounded bg-muted/40" />
</div>
))
)}
</div>
</div>
<div className="h-px bg-border/40" />
{/* Internal Review Score Ranking */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-500 shrink-0">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">评测分值排行</span>
</div>
<div className="space-y-0.5">
{rankings ? (
rankings.topByRating.slice(0, 10).map((item, i) => (
<a
key={item.id}
href={`/skills/${item.skill?.slug || ""}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-amber-100 text-amber-700" : "bg-muted/30 text-muted-foreground"}`}>{i + 1}</span>
<span className="flex-1 truncate text-left">{item.skill?.name}</span>
<span className="text-[10px] font-semibold text-violet-600 tabular-nums">{item.overall}</span>
</a>
))
) : (
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 animate-pulse">
<div className="h-5 w-5 rounded bg-muted/40" />
<div className="h-3 flex-1 rounded bg-muted/40" />
</div>
))
)}
</div>
</div>
</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>
</aside>
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1 min-w-0">
<ReviewsList initialReviews={serializedReviews} initialTotal={total} />
</div>
{/* Middle Content */}
<main className="min-w-0">
<HeroBanner
title="AI"
highlight="评测"
colorScheme="aurora"
stats={[
{ label: "评测", value: totalReviews, unit: "项" },
{ label: "维度", value: 5, unit: "维" },
]}
slogan="基于五维模型的AI技能深度评测"
subSlogan="场景能力 · 开发体验 · 成本许可 · 社区健康 · 性能稳定"
/>
<ReviewsList initialReviews={[]} initialTotal={0} />
</main>
{/* Right Sidebar: Trending Tools */}
<aside className="hidden lg:block">
<div className="sticky top-[68px] space-y-5">
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30">
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-violet-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.26 10.147a60.438 60.438 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5" />
</svg>
<h3 className="text-sm font-semibold text-foreground">五维模型</h3>
</div>
</div>
<div className="p-4">
<div className="space-y-3">
{[
{ label: "场景能力", desc: "任务完成度与准确性", color: "bg-emerald-50 text-emerald-700 border-emerald-200" },
{ label: "开发体验", desc: "API/文档易用性", color: "bg-blue-50 text-blue-700 border-blue-200" },
{ label: "成本许可", desc: "价格合理性与开源度", color: "bg-amber-50 text-amber-700 border-amber-200" },
{ label: "社区健康", desc: "活跃度与维护情况", color: "bg-purple-50 text-purple-700 border-purple-200" },
{ label: "性能稳定", desc: "响应速度与可靠性", color: "bg-rose-50 text-rose-700 border-rose-200" },
].map((dim) => (
<div key={dim.label} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border ${dim.color}`}>
<div className="flex-1">
<p className="text-xs font-semibold">{dim.label}</p>
<p className="text-[10px] opacity-60">{dim.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30">
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-emerald-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605" />
</svg>
<h3 className="text-sm font-semibold text-foreground">评测说明</h3>
</div>
</div>
<div className="p-4 text-xs text-muted-foreground space-y-2">
<p>每个维度满分5分,综合得分由加权平均得出。</p>
<p className="text-[11px]">4.5+ 卓越 · 4.0+ 优秀 · 3.5+ 良好 · 3.0+ 中等</p>
</div>
</div>
</div>
</aside>
<div className="lg:w-64 flex-shrink-0">
<SidebarRankings />
</div>
</div>
</div>
);
+1 -2
View File
@@ -251,8 +251,7 @@ export default function SkillsPageClient({
<HeroBanner
title="追光"
highlight="Skills"
highlightColor="violet"
starStyle="aurora"
colorScheme="crimson"
stats={[
{ label: "AI技能", value: totalSkills, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
+4 -4
View File
@@ -90,14 +90,14 @@ export default async function UserProfilePage({ params }: Props) {
<div className="pt-16 px-6 pb-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="text-xl font-bold">{user.name || user.email.split("@")[0]}</h1>
<h1 className="text-xl font-bold">{user.name || user.email?.split("@")[0]}</h1>
<p className="text-sm text-muted-foreground/60 mt-1">{user.email}</p>
{user.bio && <p className="text-sm mt-2 text-muted-foreground/70">{user.bio}</p>}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<MemberBadge level={user.level} points={user.points} size="md" />
<span className="text-xs text-muted-foreground/50 flex items-center gap-1">
<Calendar className="w-3 h-3" />
注册于 {user.createdAt.toLocaleDateString("zh-CN")}
注册于 {user.createdAt?.toLocaleDateString("zh-CN")}
</span>
</div>
</div>
@@ -139,7 +139,7 @@ export default async function UserProfilePage({ params }: Props) {
<h3 className="text-sm font-medium">{topic.title}</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>{topic.category.name}</span>
<span>{topic.createdAt.toLocaleDateString("zh-CN")}</span>
<span>{topic.createdAt?.toLocaleDateString("zh-CN")}</span>
</div>
</div>
</Link>
@@ -163,7 +163,7 @@ export default async function UserProfilePage({ params }: Props) {
{target && (
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>于 {comment.tool ? "工具" : "技能"}: {target.name}</span>
<span>{comment.createdAt.toLocaleDateString("zh-CN")}</span>
<span>{comment.createdAt?.toLocaleDateString("zh-CN")}</span>
</div>
)}
</div>
+211 -360
View File
@@ -2,16 +2,15 @@
import { useEffect, useState, useRef, useCallback } from "react";
type StarStyle = "cosmos" | "aurora" | "dawn";
type ColorScheme = "cosmos" | "aurora" | "dawn" | "forest" | "crimson";
interface HeroBannerProps {
title: string;
highlight: string;
highlightColor?: "blue" | "violet" | "amber";
colorScheme?: ColorScheme;
stats: { label: string; value: number | string; unit: string }[];
slogan: string;
subSlogan?: string;
starStyle?: StarStyle;
}
interface Star {
@@ -38,14 +37,112 @@ interface ShootingStar {
size: number;
}
interface SchemeConfig {
bgGradient: string;
highlight: string;
star: { r: number; g: number; b: number };
shoot: { r: number; g: number; b: number };
topBorder: string;
bottomBorder: string;
statBg: string;
statColor: string;
statBorder: string;
overlays: { position: string; color: string }[];
}
const SCHEMES: Record<ColorScheme, SchemeConfig> = {
cosmos: {
bgGradient: "linear-gradient(135deg, #1e3a5f 0%, #1e40af 35%, #2563eb 65%, #1d4ed8 100%)",
highlight: "#93c5fd",
star: { r: 191, g: 219, b: 254 },
shoot: { r: 200, g: 220, b: 255 },
topBorder: "linear-gradient(90deg, transparent, rgba(96,165,250,0.5), rgba(59,130,246,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(96,165,250,0.4), rgba(59,130,246,0.3), transparent)",
statBg: "rgba(255,255,255,0.15)",
statColor: "#fff",
statBorder: "1px solid rgba(255,255,255,0.25)",
overlays: [
{ position: "30% 50%", color: "rgba(59,130,246,0.15)" },
{ position: "80% 30%", color: "rgba(96,165,250,0.10)" },
],
},
aurora: {
bgGradient: "linear-gradient(135deg, #4c1d95 0%, #5b21b6 30%, #7c3aed 60%, #6d28d9 100%)",
highlight: "#c4b5fd",
star: { r: 210, g: 196, b: 255 },
shoot: { r: 220, g: 210, b: 255 },
topBorder: "linear-gradient(90deg, transparent, rgba(167,139,250,0.5), rgba(124,58,237,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(167,139,250,0.4), rgba(124,58,237,0.3), transparent)",
statBg: "rgba(167,139,250,0.15)",
statColor: "#ddd6fe",
statBorder: "1px solid rgba(167,139,250,0.25)",
overlays: [
{ position: "40% 40%", color: "rgba(124,58,237,0.22)" },
{ position: "75% 60%", color: "rgba(139,92,246,0.16)" },
],
},
dawn: {
bgGradient: "linear-gradient(135deg, #78350f 0%, #92400e 25%, #b45309 55%, #a16207 100%)",
highlight: "#fcd34d",
star: { r: 253, g: 230, b: 138 },
shoot: { r: 251, g: 191, b: 36 },
topBorder: "linear-gradient(90deg, transparent, rgba(251,191,36,0.5), rgba(245,158,11,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(251,191,36,0.4), rgba(245,158,11,0.3), transparent)",
statBg: "rgba(251,191,36,0.15)",
statColor: "#fef3c7",
statBorder: "1px solid rgba(251,191,36,0.25)",
overlays: [
{ position: "50% 80%", color: "rgba(245,158,11,0.20)" },
{ position: "20% 30%", color: "rgba(251,191,36,0.12)" },
{ position: "80% 60%", color: "rgba(217,119,6,0.10)" },
],
},
forest: {
bgGradient: "linear-gradient(135deg, #064e3b 0%, #065f46 25%, #059669 55%, #047857 100%)",
highlight: "#6ee7b7",
star: { r: 110, g: 231, b: 183 },
shoot: { r: 130, g: 240, b: 200 },
topBorder: "linear-gradient(90deg, transparent, rgba(52,211,153,0.5), rgba(16,185,129,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(52,211,153,0.4), rgba(16,185,129,0.3), transparent)",
statBg: "rgba(52,211,153,0.15)",
statColor: "#d1fae5",
statBorder: "1px solid rgba(52,211,153,0.25)",
overlays: [
{ position: "30% 50%", color: "rgba(16,185,129,0.15)" },
{ position: "70% 30%", color: "rgba(52,211,153,0.10)" },
],
},
crimson: {
bgGradient: "linear-gradient(135deg, #881337 0%, #9f1239 25%, #be123c 55%, #e11d48 100%)",
highlight: "#fda4af",
star: { r: 253, g: 164, b: 175 },
shoot: { r: 255, g: 180, b: 190 },
topBorder: "linear-gradient(90deg, transparent, rgba(251,113,133,0.5), rgba(244,63,94,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(251,113,133,0.4), rgba(244,63,94,0.3), transparent)",
statBg: "rgba(251,113,133,0.15)",
statColor: "#ffe4e6",
statBorder: "1px solid rgba(251,113,133,0.25)",
overlays: [
{ position: "40% 40%", color: "rgba(244,63,94,0.18)" },
{ position: "75% 60%", color: "rgba(251,113,133,0.12)" },
],
},
};
const SCHEME_KEYS: ColorScheme[] = ["cosmos", "aurora", "dawn", "forest", "crimson"];
const STAR_COUNTS: Record<ColorScheme, number> = {
cosmos: 120, aurora: 80, dawn: 100, forest: 90, crimson: 85,
};
const SHOOTING_INTERVAL = 5000;
export function HeroBanner({
title,
highlight,
highlightColor = "blue",
colorScheme = "cosmos",
stats,
slogan,
subSlogan,
starStyle = "cosmos",
}: HeroBannerProps) {
const [mounted, setMounted] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -56,22 +153,16 @@ export function HeroBanner({
const lastShootingTimeRef = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const isCosmos = starStyle === "cosmos";
const isAurora = starStyle === "aurora";
const isDawn = starStyle === "dawn";
const STAR_COUNT = isCosmos ? 120 : isDawn ? 100 : 80;
const SHOOTING_INTERVAL = 5000;
const scheme = SCHEMES[colorScheme];
const starCount = STAR_COUNTS[colorScheme];
const createStars = useCallback((width: number, height: number) => {
const arr: Star[] = [];
for (let i = 0; i < STAR_COUNT; i++) {
for (let i = 0; i < starCount; i++) {
const bx = Math.random() * width;
const by = Math.random() * height;
arr.push({
x: bx,
y: by,
baseX: bx,
baseY: by,
x: bx, y: by, baseX: bx, baseY: by,
size: Math.random() * 1.8 + 0.5,
alpha: Math.random() * 0.5 + 0.5,
alphaSpeed: Math.random() * 0.08 + 0.05,
@@ -82,39 +173,29 @@ export function HeroBanner({
});
}
starsRef.current = arr;
}, [STAR_COUNT]);
}, [starCount]);
const createShootingStar = useCallback((width: number, height: number) => {
const startX = Math.random() * width * 0.7 + width * 0.05;
const startY = Math.random() * height * 0.25;
const speed = 3 + Math.random() * 3;
const angle = Math.PI / 4 + (Math.random() - 0.5) * Math.PI / 8;
shootingStarsRef.current.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1,
length: 50 + Math.random() * 40,
size: 1.2 + Math.random() * 0.8,
x: startX, y: startY,
vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
life: 1, length: 50 + Math.random() * 40, size: 1.2 + Math.random() * 0.8,
});
}, []);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => { setMounted(true); }, []);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
if (mq.matches) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const resize = () => {
const rect = container.getBoundingClientRect();
@@ -126,44 +207,27 @@ export function HeroBanner({
ctx.scale(dpr, dpr);
createStars(rect.width, rect.height);
};
resize();
const starColor = isCosmos
? { r: 191, g: 219, b: 254 }
: isDawn
? { r: 253, g: 230, b: 138 }
: { r: 210, g: 196, b: 255 };
const shootColor = isCosmos
? { r: 200, g: 220, b: 255 }
: isDawn
? { r: 251, g: 191, b: 36 }
: { r: 220, g: 210, b: 255 };
const sc = scheme.star;
const sh = scheme.shoot;
const animate = () => {
timeRef.current += 0.016;
const t = timeRef.current;
const rect = container.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
ctx.clearRect(0, 0, w, h);
for (const star of starsRef.current) {
const fastTwinkle = 0.5 + Math.sin(t * star.alphaSpeed * 12 + star.twinkleOffset) * 0.5;
const secondaryTwinkle = 0.7 + Math.sin(t * star.alphaSpeed * 7 + star.twinkleOffset * 1.5) * 0.3;
const easedAlpha = fastTwinkle * fastTwinkle * (3 - 2 * fastTwinkle) * secondaryTwinkle;
const driftX = Math.sin(t * star.driftSpeed + star.driftOffset) * 8
+ Math.cos(t * star.driftSpeed * 1.3 + star.driftOffset2) * 4;
const driftY = Math.cos(t * star.driftSpeed * 0.8 + star.driftOffset * 1.2) * 6
+ Math.sin(t * star.driftSpeed * 0.6 + star.driftOffset2 * 0.9) * 3;
const driftX = Math.sin(t * star.driftSpeed + star.driftOffset) * 8 + Math.cos(t * star.driftSpeed * 1.3 + star.driftOffset2) * 4;
const driftY = Math.cos(t * star.driftSpeed * 0.8 + star.driftOffset * 1.2) * 6 + Math.sin(t * star.driftSpeed * 0.6 + star.driftOffset2 * 0.9) * 3;
star.x = star.baseX + driftX;
star.y = star.baseY + driftY;
const currentAlpha = star.alpha * easedAlpha;
ctx.beginPath();
@@ -175,9 +239,8 @@ export function HeroBanner({
const glowSize = star.size * 2.5;
const gradient = ctx.createRadialGradient(star.x, star.y, 0, star.x, star.y, glowSize);
gradient.addColorStop(0, `rgba(255, 255, 255, ${currentAlpha * 0.5})`);
gradient.addColorStop(0.4, `rgba(${starColor.r}, ${starColor.g}, ${starColor.b}, ${currentAlpha * 0.2})`);
gradient.addColorStop(1, `rgba(${starColor.r}, ${starColor.g}, ${starColor.b}, 0)`);
gradient.addColorStop(0.4, `rgba(${sc.r}, ${sc.g}, ${sc.b}, ${currentAlpha * 0.2})`);
gradient.addColorStop(1, `rgba(${sc.r}, ${sc.g}, ${sc.b}, 0)`);
ctx.beginPath();
ctx.arc(star.x, star.y, glowSize, 0, Math.PI * 2);
ctx.fillStyle = gradient;
@@ -196,347 +259,145 @@ export function HeroBanner({
ss.x += ss.vx;
ss.y += ss.vy;
ss.life -= 0.008;
const tailX = ss.x - ss.vx * ss.length * 0.12;
const tailY = ss.y - ss.vy * ss.length * 0.12;
const gradient = ctx.createLinearGradient(tailX, tailY, ss.x, ss.y);
gradient.addColorStop(0, "rgba(255, 255, 255, 0)");
gradient.addColorStop(0.3, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.3})`);
gradient.addColorStop(0.7, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.7})`);
gradient.addColorStop(1, `rgba(255, 255, 255, ${ss.life})`);
const grad = ctx.createLinearGradient(tailX, tailY, ss.x, ss.y);
grad.addColorStop(0, "rgba(255, 255, 255, 0)");
grad.addColorStop(0.3, `rgba(${sh.r}, ${sh.g}, ${sh.b}, ${ss.life * 0.3})`);
grad.addColorStop(0.7, `rgba(${sh.r}, ${sh.g}, ${sh.b}, ${ss.life * 0.7})`);
grad.addColorStop(1, `rgba(255, 255, 255, ${ss.life})`);
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(ss.x, ss.y);
ctx.strokeStyle = gradient;
ctx.lineWidth = ss.size * 1.5;
ctx.lineCap = "round";
ctx.stroke();
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(ss.x, ss.y);
ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(ss.x, ss.y);
ctx.strokeStyle = grad; ctx.lineWidth = ss.size * 1.5; ctx.lineCap = "round"; ctx.stroke();
ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(ss.x, ss.y);
ctx.strokeStyle = `rgba(255, 255, 255, ${ss.life * 0.6})`;
ctx.lineWidth = ss.size * 0.5;
ctx.stroke();
const headGlow = ctx.createRadialGradient(ss.x, ss.y, 0, ss.x, ss.y, ss.size * 3);
headGlow.addColorStop(0, `rgba(255, 255, 255, ${ss.life})`);
headGlow.addColorStop(0.4, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.5})`);
headGlow.addColorStop(1, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, 0)`);
ctx.beginPath();
ctx.arc(ss.x, ss.y, ss.size * 3, 0, Math.PI * 2);
ctx.fillStyle = headGlow;
ctx.fill();
ctx.lineWidth = ss.size * 0.5; ctx.stroke();
const hg = ctx.createRadialGradient(ss.x, ss.y, 0, ss.x, ss.y, ss.size * 3);
hg.addColorStop(0, `rgba(255, 255, 255, ${ss.life})`);
hg.addColorStop(0.4, `rgba(${sh.r}, ${sh.g}, ${sh.b}, ${ss.life * 0.5})`);
hg.addColorStop(1, `rgba(${sh.r}, ${sh.g}, ${sh.b}, 0)`);
ctx.beginPath(); ctx.arc(ss.x, ss.y, ss.size * 3, 0, Math.PI * 2);
ctx.fillStyle = hg; ctx.fill();
ctx.restore();
}
animationIdRef.current = requestAnimationFrame(animate);
};
lastShootingTimeRef.current = Date.now();
animate();
const handleResize = () => resize();
window.addEventListener("resize", handleResize);
return () => {
if (animationIdRef.current !== null) {
cancelAnimationFrame(animationIdRef.current);
}
if (animationIdRef.current !== null) cancelAnimationFrame(animationIdRef.current);
window.removeEventListener("resize", handleResize);
};
}, [isCosmos, isDawn, createStars, createShootingStar]);
const highlightStyle: React.CSSProperties = isCosmos
? { color: "#93c5fd" }
: isDawn
? { color: "#fcd34d" }
: { color: "#c4b5fd" };
const bgGradient = isCosmos
? "linear-gradient(135deg, #1e3a5f 0%, #1e40af 35%, #2563eb 65%, #1d4ed8 100%)"
: isDawn
? "linear-gradient(135deg, #78350f 0%, #92400e 25%, #b45309 55%, #a16207 100%)"
: "linear-gradient(135deg, #4c1d95 0%, #5b21b6 30%, #7c3aed 60%, #6d28d9 100%)";
}, [colorScheme, scheme, createStars, createShootingStar]);
return (
<div ref={containerRef} className="relative w-full overflow-hidden rounded-2xl mb-6 star-banner">
<div className="absolute inset-0" style={{ background: bgGradient }} />
<div className="absolute inset-0" style={{ background: scheme.bgGradient }} />
{isCosmos ? (
<>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 30% 50%, rgba(59,130,246,0.15) 0%, transparent 60%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 80% 30%, rgba(96,165,250,0.10) 0%, transparent 50%)",
}}
/>
<svg
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]"
viewBox="0 0 1000 300"
preserveAspectRatio="none"
>
<line x1="120" y1="60" x2="280" y2="90" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="280" y1="90" x2="350" y2="50" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="350" y1="50" x2="500" y2="80" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="600" y1="40" x2="720" y2="70" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="720" y1="70" x2="850" y2="55" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="850" y1="55" x2="920" y2="90" stroke="#bfdbfe" strokeWidth="0.5" />
<circle cx="120" cy="60" r="2" fill="#bfdbfe" />
<circle cx="280" cy="90" r="1.5" fill="#bfdbfe" />
<circle cx="350" cy="50" r="2" fill="#bfdbfe" />
<circle cx="500" cy="80" r="1.5" fill="#bfdbfe" />
<circle cx="600" cy="40" r="2" fill="#bfdbfe" />
<circle cx="720" cy="70" r="1.5" fill="#bfdbfe" />
<circle cx="850" cy="55" r="2" fill="#bfdbfe" />
<circle cx="920" cy="90" r="1.5" fill="#bfdbfe" />
</svg>
</>
) : isDawn ? (
<>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 50% 80%, rgba(245,158,11,0.20) 0%, transparent 55%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 20% 30%, rgba(251,191,36,0.12) 0%, transparent 50%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 80% 60%, rgba(217,119,6,0.10) 0%, transparent 45%)",
}}
/>
<svg
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.06]"
viewBox="0 0 1000 300"
preserveAspectRatio="none"
>
<ellipse cx="500" cy="280" rx="400" ry="60" stroke="#fcd34d" strokeWidth="0.5" fill="none" opacity="0.6" />
<ellipse cx="500" cy="280" rx="300" ry="40" stroke="#fbbf24" strokeWidth="0.5" fill="none" opacity="0.4" />
<line x1="500" y1="40" x2="500" y2="220" stroke="#fcd34d" strokeWidth="0.3" opacity="0.3" />
<line x1="460" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
<line x1="540" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
<circle cx="500" cy="40" r="3" fill="#fcd34d" opacity="0.5" />
</svg>
</>
) : (
<>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 40% 40%, rgba(124,58,237,0.22) 0%, transparent 55%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 75% 60%, rgba(139,92,246,0.16) 0%, transparent 50%)",
}}
/>
<svg
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]"
viewBox="0 0 1000 300"
preserveAspectRatio="none"
>
<ellipse cx="500" cy="150" rx="200" ry="80" stroke="#c4b5fd" strokeWidth="0.5" fill="none" opacity="0.5" />
<ellipse cx="500" cy="150" rx="140" ry="50" stroke="#a78bfa" strokeWidth="0.5" fill="none" opacity="0.4" />
</svg>
</>
{scheme.overlays.map((ov, i) => (
<div key={i} className="absolute inset-0 pointer-events-none"
style={{ background: `radial-gradient(ellipse at ${ov.position}, ${ov.color} 0%, transparent 55%)` }}
/>
))}
{/* Background SVG pattern */}
{colorScheme === "cosmos" && (
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]" viewBox="0 0 1000 300" preserveAspectRatio="none">
<line x1="120" y1="60" x2="280" y2="90" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="280" y1="90" x2="350" y2="50" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="350" y1="50" x2="500" y2="80" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="600" y1="40" x2="720" y2="70" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="720" y1="70" x2="850" y2="55" stroke="#bfdbfe" strokeWidth="0.5" />
<line x1="850" y1="55" x2="920" y2="90" stroke="#bfdbfe" strokeWidth="0.5" />
<circle cx="120" cy="60" r="2" fill="#bfdbfe" /><circle cx="280" cy="90" r="1.5" fill="#bfdbfe" />
<circle cx="350" cy="50" r="2" fill="#bfdbfe" /><circle cx="500" cy="80" r="1.5" fill="#bfdbfe" />
<circle cx="600" cy="40" r="2" fill="#bfdbfe" /><circle cx="720" cy="70" r="1.5" fill="#bfdbfe" />
<circle cx="850" cy="55" r="2" fill="#bfdbfe" /><circle cx="920" cy="90" r="1.5" fill="#bfdbfe" />
</svg>
)}
{colorScheme === "dawn" && (
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.06]" viewBox="0 0 1000 300" preserveAspectRatio="none">
<ellipse cx="500" cy="280" rx="400" ry="60" stroke="#fcd34d" strokeWidth="0.5" fill="none" opacity="0.6" />
<ellipse cx="500" cy="280" rx="300" ry="40" stroke="#fbbf24" strokeWidth="0.5" fill="none" opacity="0.4" />
<line x1="500" y1="40" x2="500" y2="220" stroke="#fcd34d" strokeWidth="0.3" opacity="0.3" />
<line x1="460" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
<line x1="540" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
<circle cx="500" cy="40" r="3" fill="#fcd34d" opacity="0.5" />
</svg>
)}
{(colorScheme === "aurora" || colorScheme === "forest" || colorScheme === "crimson") && (
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]" viewBox="0 0 1000 300" preserveAspectRatio="none">
<ellipse cx="500" cy="150" rx="200" ry="80" stroke={scheme.highlight} strokeWidth="0.5" fill="none" opacity="0.5" />
<ellipse cx="500" cy="150" rx="140" ry="50" stroke={scheme.highlight} strokeWidth="0.5" fill="none" opacity="0.4" />
</svg>
)}
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 2 }}
/>
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full pointer-events-none" style={{ zIndex: 2 }} />
<div
className="absolute top-0 left-0 right-0 h-px"
style={{
background: isCosmos
? "linear-gradient(90deg, transparent, rgba(96,165,250,0.5), rgba(59,130,246,0.4), transparent)"
: isDawn
? "linear-gradient(90deg, transparent, rgba(251,191,36,0.5), rgba(245,158,11,0.4), transparent)"
: "linear-gradient(90deg, transparent, rgba(167,139,250,0.5), rgba(124,58,237,0.4), transparent)",
zIndex: 3,
}}
/>
<div className="absolute top-0 left-0 right-0 h-px" style={{ background: scheme.topBorder, zIndex: 3 }} />
<div className="relative px-6 py-8 sm:px-8 sm:py-10 lg:px-10 lg:py-12" style={{ zIndex: 4 }}>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
<div className="flex-1 min-w-0">
<div
className={`transition-all duration-700 ${
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
>
<div className={`transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}>
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-white" style={{ textShadow: "0 2px 16px rgba(0,0,0,0.3)" }}>
{title}
<span style={highlightStyle}>{highlight}</span>
<span style={{ color: scheme.highlight }}>{highlight}</span>
</h1>
</div>
<div
className={`mt-3 transition-all duration-700 delay-150 ${
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
>
<p className="text-base sm:text-lg font-medium text-white/85 leading-relaxed">
{slogan}
</p>
{subSlogan && (
<p className="mt-1.5 text-sm text-white/55 leading-relaxed">
{subSlogan}
</p>
)}
<div className={`mt-3 transition-all duration-700 delay-150 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}>
<p className="text-base sm:text-lg font-medium text-white/85 leading-relaxed">{slogan}</p>
{subSlogan && <p className="mt-1.5 text-sm text-white/55 leading-relaxed">{subSlogan}</p>}
</div>
<div
className={`mt-5 flex flex-wrap items-center gap-4 sm:gap-6 transition-all duration-700 delay-300 ${
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
>
<div className={`mt-5 flex flex-wrap items-center gap-4 sm:gap-6 transition-all duration-700 delay-300 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}>
{stats.map((stat, i) => (
<div key={i} className="flex items-center gap-2">
<span
className="inline-flex items-center justify-center h-9 min-w-[2.25rem] px-2 rounded-lg text-sm font-bold tabular-nums"
style={{
background: isCosmos
? "rgba(255,255,255,0.15)"
: isDawn
? "rgba(251,191,36,0.15)"
: "rgba(167,139,250,0.15)",
color: isCosmos ? "#fff" : isDawn ? "#fef3c7" : "#ddd6fe",
border: isCosmos
? "1px solid rgba(255,255,255,0.25)"
: isDawn
? "1px solid rgba(251,191,36,0.25)"
: "1px solid rgba(167,139,250,0.25)",
backdropFilter: "blur(4px)",
}}
>
<span className="inline-flex items-center justify-center h-9 min-w-[2.25rem] px-2 rounded-lg text-sm font-bold tabular-nums"
style={{ background: scheme.statBg, color: scheme.statColor, border: scheme.statBorder, backdropFilter: "blur(4px)" }}>
{typeof stat.value === "number" ? stat.value.toLocaleString() : stat.value}
</span>
<span className="text-sm text-white/70">
{stat.unit}
<span className="text-white/50 ml-0.5">{stat.label}</span>
</span>
<span className="text-sm text-white/70">{stat.unit}<span className="text-white/50 ml-0.5">{stat.label}</span></span>
</div>
))}
</div>
</div>
<div
className={`hidden lg:flex items-center justify-center transition-all duration-700 delay-200 ${
mounted ? "opacity-100 scale-100" : "opacity-0 scale-90"
}`}
>
<div className={`hidden lg:flex items-center justify-center transition-all duration-700 delay-200 ${mounted ? "opacity-100 scale-100" : "opacity-0 scale-90"}`}>
<div className="relative w-36 h-36">
{isCosmos ? (
{colorScheme === "cosmos" && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<circle cx="72" cy="72" r="60" stroke="rgba(147,197,253,0.25)" strokeWidth="0.5" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="45" stroke="rgba(191,219,254,0.20)" strokeWidth="0.5" fill="none" strokeDasharray="4 6">
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="30" stroke="rgba(147,197,253,0.15)" strokeWidth="0.5" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="20s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="8" fill="rgba(147,197,253,0.25)" />
<circle cx="72" cy="72" r="3" fill="rgba(191,219,254,0.8)" />
<circle cx="72" cy="12" r="2" fill="rgba(191,219,254,0.6)">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="27" r="1.5" fill="rgba(147,197,253,0.5)">
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="60" stroke="rgba(147,197,253,0.25)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="45" stroke="rgba(191,219,254,0.20)" strokeWidth="0.5" fill="none" strokeDasharray="4 6"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="30" stroke="rgba(147,197,253,0.15)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="20s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="8" fill="rgba(147,197,253,0.25)" /><circle cx="72" cy="72" r="3" fill="rgba(191,219,254,0.8)" />
<circle cx="72" cy="12" r="2" fill="rgba(191,219,254,0.6)"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="27" r="1.5" fill="rgba(147,197,253,0.5)"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /></circle>
</svg>
) : isDawn ? (
)}
{colorScheme === "dawn" && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<defs>
<linearGradient id="dawn-sun-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#fbbf24" stopOpacity="0.4" />
<stop offset="50%" stopColor="#f59e0b" stopOpacity="0.3" />
<stop offset="100%" stopColor="#d97706" stopOpacity="0.2" />
</linearGradient>
</defs>
<circle cx="72" cy="72" r="60" stroke="rgba(251,191,36,0.20)" strokeWidth="0.5" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="45" stroke="rgba(245,158,11,0.15)" strokeWidth="0.5" fill="none" strokeDasharray="6 4">
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="28s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="30" stroke="rgba(251,191,36,0.12)" strokeWidth="0.5" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="22s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="18" fill="url(#dawn-sun-grad)" />
<circle cx="72" cy="72" r="10" fill="rgba(251,191,36,0.25)" />
<circle cx="72" cy="72" r="4" fill="rgba(253,230,138,0.7)" />
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(45 72 72)">
<animateTransform attributeName="transform" type="rotate" from="45 72 72" to="405 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(90 72 72)">
<animateTransform attributeName="transform" type="rotate" from="90 72 72" to="450 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(135 72 72)">
<animateTransform attributeName="transform" type="rotate" from="135 72 72" to="495 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(180 72 72)">
<animateTransform attributeName="transform" type="rotate" from="180 72 72" to="540 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(225 72 72)">
<animateTransform attributeName="transform" type="rotate" from="225 72 72" to="585 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(270 72 72)">
<animateTransform attributeName="transform" type="rotate" from="270 72 72" to="630 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(315 72 72)">
<animateTransform attributeName="transform" type="rotate" from="315 72 72" to="675 72 72" dur="35s" repeatCount="indefinite" />
</line>
<defs><linearGradient id="dawn-sun" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#fbbf24" stopOpacity="0.4" /><stop offset="50%" stopColor="#f59e0b" stopOpacity="0.3" /><stop offset="100%" stopColor="#d97706" stopOpacity="0.2" /></linearGradient></defs>
<circle cx="72" cy="72" r="60" stroke="rgba(251,191,36,0.20)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="45" stroke="rgba(245,158,11,0.15)" strokeWidth="0.5" fill="none" strokeDasharray="6 4"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="28s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="30" stroke="rgba(251,191,36,0.12)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="22s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="18" fill="url(#dawn-sun)" /><circle cx="72" cy="72" r="10" fill="rgba(251,191,36,0.25)" /><circle cx="72" cy="72" r="4" fill="rgba(253,230,138,0.7)" />
{[0,45,90,135,180,225,270,315].map(deg => (
<line key={deg} x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform={`rotate(${deg} 72 72)`}><animateTransform attributeName="transform" type="rotate" from={`${deg} 72 72`} to={`${deg+360} 72 72`} dur="35s" repeatCount="indefinite" /></line>
))}
</svg>
) : (
)}
{(colorScheme === "aurora" || colorScheme === "forest" || colorScheme === "crimson") && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<defs>
<linearGradient id="aurora-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.3" />
<stop offset="50%" stopColor="#06b6d4" stopOpacity="0.2" />
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0.3" />
</linearGradient>
</defs>
<circle cx="72" cy="72" r="55" stroke="url(#aurora-grad)" strokeWidth="1" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="40s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="38" stroke="rgba(139,92,246,0.15)" strokeWidth="0.8" fill="none" strokeDasharray="8 4">
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="30s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="22" fill="rgba(124,58,237,0.1)" />
<circle cx="72" cy="72" r="10" fill="rgba(139,92,246,0.15)" />
<circle cx="72" cy="72" r="3" fill="rgba(196,181,253,0.6)" />
<path d="M72 17L79 52H112L86 72L96 105L72 87L48 105L58 72L32 52H65Z" stroke="rgba(196,181,253,0.2)" strokeWidth="0.8" fill="rgba(124,58,237,0.05)">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="60s" repeatCount="indefinite" />
</path>
<defs><linearGradient id="orb-grad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor={scheme.highlight} stopOpacity="0.3" /><stop offset="50%" stopColor={scheme.highlight} stopOpacity="0.15" /><stop offset="100%" stopColor={scheme.highlight} stopOpacity="0.25" /></linearGradient></defs>
<circle cx="72" cy="72" r="55" stroke="url(#orb-grad)" strokeWidth="1" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="40s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="38" stroke={scheme.highlight} strokeWidth="0.8" strokeOpacity="0.15" fill="none" strokeDasharray="8 4"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="30s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="22" fill={scheme.highlight} fillOpacity="0.08" /><circle cx="72" cy="72" r="10" fill={scheme.highlight} fillOpacity="0.12" />
<circle cx="72" cy="72" r="3" fill={scheme.highlight} fillOpacity="0.5" />
<path d="M72 17L79 52H112L86 72L96 105L72 87L48 105L58 72L32 52H65Z" stroke={scheme.highlight} strokeWidth="0.8" strokeOpacity="0.18" fill={scheme.highlight} fillOpacity="0.04"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="60s" repeatCount="indefinite" /></path>
</svg>
)}
</div>
@@ -544,17 +405,7 @@ export function HeroBanner({
</div>
</div>
<div
className="absolute bottom-0 left-0 right-0 h-px"
style={{
background: isCosmos
? "linear-gradient(90deg, transparent, rgba(96,165,250,0.4), rgba(59,130,246,0.3), transparent)"
: isDawn
? "linear-gradient(90deg, transparent, rgba(251,191,36,0.4), rgba(245,158,11,0.3), transparent)"
: "linear-gradient(90deg, transparent, rgba(167,139,250,0.4), rgba(124,58,237,0.3), transparent)",
zIndex: 3,
}}
/>
<div className="absolute bottom-0 left-0 right-0 h-px" style={{ background: scheme.bottomBorder, zIndex: 3 }} />
</div>
);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ const navLinks = [
{ 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: "社区", href: "/community", icon: "M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v2l-3-3H6a2 2 0 01-2-2v-2l3-3h10z" },
{ label: "社区", href: "/community", icon: "M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" },
];
export default function Header() {
+27 -12
View File
@@ -80,18 +80,33 @@ export const authOptions = {
});
if (!existing) {
const newUser = await prisma.user.create({
data: {
email: email || `github_${oauthId}@placeholder.com`,
name: profile.name || user.name || null,
avatarUrl: profile.avatar_url || user.image || null,
oauthProvider: "github",
oauthId,
role: "user",
},
});
user.id = newUser.id.toString();
user.role = newUser.role;
try {
const newUser = await prisma.user.create({
data: {
email: email || `github_${oauthId}@placeholder.com`,
name: profile.name || user.name || null,
avatarUrl: profile.avatar_url || user.image || null,
oauthProvider: "github",
oauthId,
role: "user",
},
});
user.id = newUser.id.toString();
user.role = newUser.role;
} catch (createErr: unknown) {
const ce = createErr as { code?: string };
if (ce.code === "P2002") {
const retry = await prisma.user.findFirst({
where: { oauthProvider: "github", oauthId },
});
if (retry) {
user.id = retry.id.toString();
user.role = retry.role;
return true;
}
}
throw createErr;
}
} else {
user.id = existing.id.toString();
user.role = existing.role;
+5 -1
View File
@@ -4,8 +4,12 @@ import { extractJSON } from "./utils";
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
timeout: 120000,
maxRetries: 2,
});
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const REVIEW_PROMPT = `你是一个GitHub开源项目评测专家。请基于以下项目信息进行五维深度评测,并以JSON格式返回。务必只返回JSON,不要包含任何其他文字或Markdown格式。
项目信息:
@@ -84,7 +88,7 @@ export async function generateReview(
.replace("{readmeSummary}", readme.substring(0, 1500));
const completion = await openai.chat.completions.create({
model: "deepseek-v4-pro",
model: DEEPSEEK_MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.3,
});
+43 -12
View File
@@ -2,8 +2,34 @@ import { Octokit } from "octokit";
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
request: {
timeout: 30000,
},
});
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 2,
label = "GitHub API"
): Promise<T> {
let lastError: unknown;
for (let i = 0; i <= maxRetries; i++) {
try {
return await fn();
} catch (err: unknown) {
lastError = err;
const e = err as { status?: number; response?: unknown };
if (e.status === 403 || e.status === 404 || e.status === 422) throw err;
if (i < maxRetries) {
const delay = 1000 * Math.pow(2, i);
console.warn(`${label} 请求失败,${delay}ms 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, delay));
}
}
}
throw lastError;
}
export interface GitHubRepo {
id: number;
full_name: string;
@@ -24,13 +50,15 @@ export async function searchHotRepos(
maxResults: number = 10
): Promise<GitHubRepo[]> {
const q = `${query} stars:>${minStars} pushed:>2024-01-01`;
const { data } = await octokit.rest.search.repos({
q,
sort: "stars",
order: "desc",
per_page: maxResults,
return withRetry(async () => {
const { data } = await octokit.rest.search.repos({
q,
sort: "stars",
order: "desc",
per_page: maxResults,
});
return data.items as GitHubRepo[];
});
return data.items as GitHubRepo[];
}
export async function getRepoReadme(
@@ -38,12 +66,15 @@ export async function getRepoReadme(
repo: string
): Promise<string> {
try {
const { data } = await octokit.rest.repos.getReadme({
owner,
repo,
});
return Buffer.from(data.content, "base64").toString("utf-8");
} catch {
return await withRetry(async () => {
const { data } = await octokit.rest.repos.getReadme({
owner,
repo,
});
return Buffer.from(data.content, "base64").toString("utf-8");
}, 2, `GitHub Readme: ${owner}/${repo}`);
} catch (err) {
console.warn(`github.ts: 获取 ${owner}/${repo} README 失败`, err);
return "";
}
}
+10 -9
View File
@@ -96,7 +96,7 @@ async function getConfig(): Promise<LogoFetchConfig> {
if (row?.value && typeof row.value === "object") {
return { ...DEFAULT_CONFIG, ...(row.value as Partial<LogoFetchConfig>) };
}
} catch { /* */ }
} catch (err) { console.warn("logo-fetcher: Failed to load logoFetchConfig from database, using defaults", err); }
return DEFAULT_CONFIG;
}
@@ -129,7 +129,7 @@ async function checkImageUrl(url: string, timeout: number, minSize: number): Pro
}
}
}
} catch { /* */ }
} catch (err) { console.warn("logo-fetcher: Failed to check image URL", err); }
return "";
}
@@ -160,7 +160,7 @@ async function fetchManifestIcons(origin: string, html: string, timeout: number,
manifestHref = `${origin}${candidate}`;
break;
}
} catch { /* */ }
} catch (err) { console.warn("logo-fetcher: Failed to probe manifest candidate path", err); }
}
}
@@ -188,7 +188,7 @@ async function fetchManifestIcons(origin: string, html: string, timeout: number,
if (valid) return valid;
}
}
} catch { /* */ }
} catch (err) { console.warn("logo-fetcher: Failed to fetch manifest icons", err); }
return "";
}
@@ -258,7 +258,7 @@ async function fetchSvgLogo(origin: string, html: string, timeout: number, minSi
}
}
}
} catch { /* */ }
} catch (err) { console.warn("logo-fetcher: Failed to fetch SVG logo candidate", err); }
}
return "";
@@ -329,7 +329,7 @@ async function fetchBrandfetch(domain: string, timeout: number): Promise<string>
if (best?.src) return best.src;
}
}
} catch { /* */ }
} catch (err) { console.warn("logo-fetcher: Failed to fetch brand from Brandfetch", err); }
return "";
}
@@ -361,7 +361,7 @@ async function fetchScreenshotFallback(domain: string, timeout: number): Promise
}
}
}
} catch { /* */ }
} catch (err) { console.warn("logo-fetcher: Failed to fetch screenshot fallback", err); }
}
return "";
@@ -559,7 +559,7 @@ export async function fetchLogoWithMethod(websiteUrl: string, html?: string): Pr
return { url: valid, method: `Favicon服务(${svc.name})` };
}
}
} catch { /* */ }
} catch (err) { console.warn("logo-fetcher: Failed to fetch favicon from service", err); }
}
}
@@ -588,7 +588,8 @@ export async function fetchLogoWithMethod(websiteUrl: string, html?: string): Pr
setCachedLogo(domain, "");
return { url: "", method: "" };
} catch {
} catch (err) {
console.warn("logo-fetcher: Failed to fetch logo for URL", err);
return { url: "", method: "" };
}
}
+19 -4
View File
@@ -4,6 +4,19 @@ import path from "path";
import crypto from "crypto";
const LOGOS_DIR = path.join(process.cwd(), "public", "logos");
const MAX_LOGO_SIZE = 5 * 1024 * 1024;
const SVG_DANGEROUS_TAGS = /<script[\s>]|<iframe[\s>]|<object[\s>]|<embed[\s>]|<foreignObject[\s>]/gi;
const SVG_DANGEROUS_ATTRS = /\bon\w+\s*=/gi;
function sanitizeSvg(content: string): string {
if (SVG_DANGEROUS_TAGS.test(content) || SVG_DANGEROUS_ATTRS.test(content)) {
return content
.replace(SVG_DANGEROUS_TAGS, "<!--sanitized-->")
.replace(SVG_DANGEROUS_ATTRS, "data-sanitized=");
}
return content;
}
async function ensureDir() {
if (!existsSync(LOGOS_DIR)) {
@@ -38,11 +51,11 @@ function getExtension(url: string, contentType?: string): string {
function generateFilename(url: string, ext: string): string {
try {
const domain = new URL(url).hostname.replace(/^www\./, "");
const hash = crypto.createHash("md5").update(url).digest("hex").slice(0, 8);
const hash = crypto.createHash("sha256").update(url).digest("hex").slice(0, 8);
const safeName = domain.replace(/[^a-z0-9.-]/g, "_").substring(0, 40);
return `${safeName}_${hash}${ext}`;
} catch {
const hash = crypto.createHash("md5").update(url).digest("hex").slice(0, 12);
const hash = crypto.createHash("sha256").update(url).digest("hex").slice(0, 12);
return `logo_${hash}${ext}`;
}
}
@@ -58,7 +71,7 @@ export async function downloadLogo(
if (logoUrl.startsWith("data:image/svg+xml,")) {
await ensureDir();
const hash = crypto
.createHash("md5")
.createHash("sha256")
.update(logoUrl)
.digest("hex")
.slice(0, 12);
@@ -66,9 +79,10 @@ export async function downloadLogo(
const filePath = path.join(LOGOS_DIR, filename);
if (!existsSync(filePath)) {
const svgContent = decodeURIComponent(
const rawContent = decodeURIComponent(
logoUrl.replace("data:image/svg+xml,", "")
);
const svgContent = sanitizeSvg(rawContent);
await writeFile(filePath, svgContent, "utf-8");
}
@@ -102,6 +116,7 @@ export async function downloadLogo(
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 50) return logoUrl;
if (buffer.length > MAX_LOGO_SIZE) return logoUrl;
const ext = getExtension(logoUrl, contentType);
const filename = generateFilename(logoUrl, ext);
+12 -8
View File
@@ -92,10 +92,14 @@ export async function getUserStats(userId: number) {
select: {
points: true,
level: true,
comments: { select: { id: true } },
forumTopics: { select: { id: true } },
forumPosts: { select: { id: true } },
reviews: { select: { id: true } },
_count: {
select: {
comments: true,
forumTopics: true,
forumPosts: true,
reviews: true,
},
},
},
});
@@ -104,9 +108,9 @@ export async function getUserStats(userId: number) {
return {
points: user.points,
level: getLevelInfo(user.points),
commentCount: user.comments.length,
topicCount: user.forumTopics.length,
postCount: user.forumPosts.length,
reviewCount: user.reviews.length,
commentCount: user._count.comments,
topicCount: user._count.forumTopics,
postCount: user._count.forumPosts,
reviewCount: user._count.reviews,
};
}
+4 -1
View File
@@ -6,7 +6,10 @@ const globalForPrisma = globalThis as unknown as {
};
function createPrismaClient() {
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL 环境变量未设置,无法连接数据库");
}
const base = process.env.DATABASE_URL;
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+15
View File
@@ -1,6 +1,21 @@
const ipCounts = new Map<string, { count: number; resetAt: number }>();
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
let lastCleanup = Date.now();
function cleanupExpired() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = now;
ipCounts.forEach((record, key) => {
if (now > record.resetAt) {
ipCounts.delete(key);
}
});
}
export function rateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
cleanupExpired();
const now = Date.now();
const record = ipCounts.get(ip);
if (!record || now > record.resetAt) {
+45 -47
View File
@@ -28,21 +28,12 @@ const CATEGORY_KEYWORDS: Record<string, string[]> = {
"Prompt工程": ["prompt", "prompting", "instruction"],
};
async function guessCategoryId(
prisma: PrismaClient,
function matchCategoryId(
categories: { id: number; name: string }[],
name: string,
description: string,
tags: string[]
): Promise<number> {
const categories = await prisma.skillCategory.findMany({
select: { id: true, name: true },
orderBy: { sortOrder: "asc" },
});
if (categories.length === 0) {
throw new Error("数据库中没有技能分类,请先创建分类");
}
): number {
const text = `${name} ${description} ${tags.join(" ")}`.toLowerCase();
for (const [categoryName, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
@@ -102,6 +93,19 @@ export async function discoverSkills(
details: [],
};
const allCategories = await prisma.skillCategory.findMany({
select: { id: true, name: true },
orderBy: { sortOrder: "asc" },
});
if (allCategories.length === 0) {
throw new Error("数据库中没有技能分类,请先创建分类");
}
const allSkillSlugs = new Set(
(await prisma.skill.findMany({ select: { slug: true } })).map((s) => s.slug)
);
for (const query of queries) {
if (result.discovered >= dailyLimit) break;
@@ -119,13 +123,30 @@ export async function discoverSkills(
continue;
}
const repoSourceUrls = repos.map((r) => r.html_url);
const existingSkills = new Set(
(
await prisma.skill.findMany({
where: { sourceUrl: { in: repoSourceUrls } },
select: { sourceUrl: true },
})
).map((s) => s.sourceUrl)
);
const existingPendingSkills = new Set(
(
await prisma.pendingSkill.findMany({
where: { sourceUrl: { in: repoSourceUrls } },
select: { sourceUrl: true },
})
).map((s) => s.sourceUrl)
);
for (const repo of repos) {
if (result.discovered >= dailyLimit) break;
const existing = await prisma.skill.findFirst({
where: { sourceUrl: repo.html_url },
});
if (existing) {
if (existingSkills.has(repo.html_url)) {
result.details.push({
name: repo.full_name,
status: "skipped",
@@ -135,10 +156,7 @@ export async function discoverSkills(
continue;
}
const existingPending = await prisma.pendingSkill.findFirst({
where: { sourceUrl: repo.html_url },
});
if (existingPending) {
if (existingPendingSkills.has(repo.html_url)) {
result.details.push({
name: repo.full_name,
status: "skipped",
@@ -149,8 +167,7 @@ export async function discoverSkills(
}
const slug = generateSlug(repo.full_name);
const slugExists = await prisma.skill.findUnique({ where: { slug } });
if (slugExists) {
if (allSkillSlugs.has(slug)) {
result.details.push({
name: repo.full_name,
status: "skipped",
@@ -184,14 +201,14 @@ export async function discoverSkills(
}
}
const categoryId = await guessCategoryId(
prisma,
const categoryId = matchCategoryId(
allCategories,
repo.full_name,
repo.description || "",
reviewData?.tags || []
);
const skill = await prisma.skill.create({
const pendingSkill = await prisma.pendingSkill.create({
data: {
name: repo.full_name.substring(0, 100),
slug,
@@ -205,39 +222,20 @@ export async function discoverSkills(
language: repo.language,
license: repo.license?.spdx_id || null,
topics: repo.topics || [],
reviewData: reviewData ? JSON.parse(JSON.stringify(reviewData)) : null,
},
tags: reviewData?.tags || [],
status: "draft",
lastReviewedAt: reviewData ? new Date() : null,
githubStarsHistory: [
{
date: new Date().toISOString().split("T")[0],
stars: repo.stargazers_count,
},
],
status: "pending",
},
});
if (reviewData) {
await prisma.skillReview.create({
data: {
skillId: skill.id,
capability: reviewData.capability.score,
devExp: reviewData.devExp.score,
costLicense: reviewData.costLicense.score,
community: reviewData.community.score,
performance: reviewData.performance.score,
overall: reviewData.overall,
reviews: JSON.parse(JSON.stringify(reviewData)),
status: "DRAFT",
},
});
result.reviewed++;
}
await prisma.reviewGenerationLog.create({
data: {
skillId: skill.id,
skillId: pendingSkill.id,
status: "SUCCESS",
prompt: `discover: ${repo.full_name}`,
response: reviewData ? JSON.stringify(reviewData) : null,
+1 -1
View File
@@ -20,7 +20,7 @@ export const toolImportSchema = z.object({
tools: z.array(z.object({
name: z.string().min(1, "名称不能为空"),
description: z.string().optional(),
websiteUrl: z.string().url().optional().or(z.literal("")),
websiteUrl: z.string().url().optional().or(z.literal("").transform(() => undefined)),
logoUrl: z.string().optional(),
pricingModel: z.enum(["free", "freemium", "paid", "subscription"]).optional(),
pricingDetail: z.string().optional(),
+2 -2
View File
@@ -11,7 +11,7 @@ export default withAuth(
return NextResponse.redirect(new URL("/admin/login", req.url));
}
const role = (token as { role?: string }).role;
if (role !== "admin" && role !== "vip") {
if (role !== "admin") {
return NextResponse.redirect(new URL("/admin/login?error=unauthorized", req.url));
}
}
@@ -21,7 +21,7 @@ export default withAuth(
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const role = (token as { role?: string }).role;
if (role !== "admin" && role !== "vip") {
if (role !== "admin") {
return NextResponse.json({ error: "无管理员权限" }, { status: 403 });
}
}