2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间

This commit is contained in:
ZhuiGuangAI Dev
2026-05-24 23:05:38 +08:00
parent 6c78c32157
commit 9bf3e7574a
118 changed files with 11842 additions and 3262 deletions
+51 -6
View File
@@ -32,6 +32,7 @@ const emptyForm = {
export default function CategoriesAdminPage() {
const [categories, setCategories] = useState<CategoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState("");
const [editCat, setEditCat] = useState<CategoryItem | null>(null);
const [isNew, setIsNew] = useState(false);
@@ -40,12 +41,13 @@ export default function CategoriesAdminPage() {
const [error, setError] = useState("");
const fetchCategories = useCallback(async () => {
setFetchError("");
try {
const res = await fetch("/api/admin/categories");
const data = await res.json();
setCategories(data);
} catch {
console.error("Fetch categories failed");
setFetchError("加载分类失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -199,11 +201,54 @@ export default function CategoriesAdminPage() {
</Button>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
))}
{fetchError ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{fetchError}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchCategories()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
{Array.from({ length: 6 }).map((_, i) => (
<th key={i} className="px-5 py-3.5">
<div className="h-3 w-14 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-5 py-3.5">
<div className="flex items-center gap-2.5">
<div className="h-7 w-7 animate-pulse rounded-lg bg-muted/30" />
<div className="h-3 w-20 animate-pulse rounded bg-muted/20" />
</div>
</td>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : categories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
+29
View File
@@ -0,0 +1,29 @@
"use client"
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4 text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-red-50 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-foreground">页面加载出错</h2>
<p className="text-sm text-muted-foreground">{error.message || "请稍后重试"}</p>
<button
onClick={reset}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
重新加载
</button>
</div>
</div>
)
}
+16
View File
@@ -116,6 +116,22 @@ const menuGroups: MenuGroup[] = [
},
],
},
{
title: "任务调度",
items: [
{
label: "任务中心",
href: "/admin/tasks",
icon: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664zM21 12a9 9 0 11-18 0 9 9 0 0118 0z",
badge: null,
},
{
label: "任务日志",
href: "/admin/tasks/logs",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01",
},
],
},
];
export default function AdminLayout({
+10
View File
@@ -0,0 +1,10 @@
export default function AdminLoading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground text-sm">加载中...</p>
</div>
</div>
)
}
+20 -4
View File
@@ -1,15 +1,23 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, Suspense } from "react";
import { getCsrfToken } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
export default function AdminLoginPage() {
function LoginForm() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/admin/pending";
const errorParam = searchParams.get("error");
const [csrfToken, setCsrfToken] = useState("");
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (errorParam === "unauthorized") {
setError("您的账号没有管理员权限");
}
const fetchToken = async () => {
try {
const token = await getCsrfToken();
@@ -42,7 +50,7 @@ export default function AdminLoginPage() {
}
};
fetchToken();
}, []);
}, [errorParam]);
return (
<div className="relative flex min-h-screen items-center justify-center px-4 overflow-hidden">
@@ -77,7 +85,7 @@ export default function AdminLoginPage() {
className="space-y-4"
>
<input name="csrfToken" type="hidden" value={csrfToken} />
<input name="callbackUrl" type="hidden" value="/admin/pending" />
<input name="callbackUrl" type="hidden" value={callbackUrl} />
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
@@ -127,3 +135,11 @@ export default function AdminLoginPage() {
</div>
);
}
export default function AdminLoginPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" /></div>}>
<LoginForm />
</Suspense>
);
}
+52 -6
View File
@@ -32,6 +32,7 @@ interface Category {
export default function PendingSkillsPage() {
const [skills, setSkills] = useState<PendingSkill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [categories, setCategories] = useState<Category[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
@@ -55,13 +56,14 @@ export default function PendingSkillsPage() {
const [saving, setSaving] = useState(false);
const fetchSkills = useCallback(async () => {
setError("");
try {
const res = await fetch(`/api/admin/pending-skills?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
setSkills(data.skills || []);
setTotal(data.total || 0);
} catch {
console.error("Failed to fetch skills");
setError("加载待审核技能失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -421,11 +423,54 @@ export default function PendingSkillsPage() {
</div>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
))}
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchSkills()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3.5 w-10">
<div className="h-4 w-4 animate-pulse rounded bg-muted/30 mx-auto" />
</th>
{Array.from({ length: 7 }).map((_, i) => (
<th key={i} className="px-4 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-4 py-3">
<div className="h-4 w-4 animate-pulse rounded bg-muted/20 mx-auto" />
</td>
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-3 animate-pulse rounded bg-muted/20" style={{ width: `${60 + Math.floor(Math.random() * 60)}%` }} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
@@ -435,6 +480,7 @@ export default function PendingSkillsPage() {
</svg>
</div>
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无待审核技能</p>
<p className="mt-1 text-xs text-muted-foreground/60">在探索技能页面发现新技能后即可在此审核</p>
</div>
) : (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
+140 -6
View File
@@ -24,6 +24,11 @@ interface PendingTool {
sourceUrl: string | null;
createdAt: string;
category: { id: number; name: string } | null;
checkWebsiteOk: boolean | null;
checkLogoOk: boolean | null;
checkDescOk: boolean | null;
checkCategoryOk: boolean | null;
checkDetail: unknown;
}
interface Category {
@@ -93,6 +98,7 @@ export default function PendingPage() {
const [tools, setTools] = useState<PendingTool[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [categories, setCategories] = useState<Category[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [logoFilter, setLogoFilter] = useState<"all" | "has" | "missing">("all");
@@ -119,13 +125,14 @@ export default function PendingPage() {
const [saving, setSaving] = useState(false);
const fetchTools = useCallback(async () => {
setError("");
try {
const res = await fetch(`/api/admin/pending?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
console.error("Failed to fetch tools");
setError("加载待审核工具失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -229,6 +236,37 @@ export default function PendingPage() {
}
};
const handlePublishAllQualified = async () => {
const qualified = tools.filter(
(t) => t.checkWebsiteOk && t.checkLogoOk && t.checkDescOk && t.checkCategoryOk
);
if (qualified.length === 0) {
setMessage("没有全部检查合格的工具可一键通过");
return;
}
if (!confirm(`确定一键通过全部 ${qualified.length} 个检查合格的工具?`)) return;
setBatchPublishing(true);
try {
const res = await fetch("/api/admin/pending/batch-publish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: qualified.map((t) => t.id) }),
});
const data = await res.json();
if (data.success) {
setMessage(`一键通过完成:成功 ${data.published} 个,跳过 ${data.skipped} 个`);
fetchTools();
setSelectedIds(new Set());
} else {
setMessage("一键通过失败: " + (data.error || "未知错误"));
}
} catch {
setMessage("一键通过失败");
} finally {
setBatchPublishing(false);
}
};
const handleFetchLogos = async (refetch = false) => {
setFetchingLogos(true);
setMessage(refetch ? "正在重新抓取所有Logo..." : "正在抓取缺失Logo...");
@@ -480,6 +518,19 @@ export default function PendingPage() {
<span className="text-sm font-medium text-foreground">
已选择 <span className="text-primary">{selectedIds.size}</span> 个工具
</span>
{tools.filter((t) => t.checkWebsiteOk && t.checkLogoOk && t.checkDescOk && t.checkCategoryOk).length > 0 && (
<Button
size="sm"
onClick={handlePublishAllQualified}
disabled={batchPublishing || batchRejecting}
className="gap-1.5 rounded-lg text-xs h-8 px-3 bg-violet-600 hover:bg-violet-500"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
一键通过全部合格 ({tools.filter((t) => t.checkWebsiteOk && t.checkLogoOk && t.checkDescOk && t.checkCategoryOk).length})
</Button>
)}
<div className="flex-1" />
<Button
size="sm"
@@ -527,11 +578,54 @@ export default function PendingPage() {
</div>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
))}
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-100">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchTools()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3.5 w-10">
<div className="h-4 w-4 animate-pulse rounded bg-muted/30 mx-auto" />
</th>
{Array.from({ length: 13 }).map((_, i) => (
<th key={i} className="px-4 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-4 py-3">
<div className="h-4 w-4 animate-pulse rounded bg-muted/20 mx-auto" />
</td>
{Array.from({ length: 13 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-3 animate-pulse rounded bg-muted/20" style={{ width: `${60 + Math.floor(Math.random() * 60)}%` }} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
@@ -573,6 +667,10 @@ export default function PendingPage() {
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">简介</th>
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">网址</th>
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">来源</th>
<th className="px-4 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">网站</th>
<th className="px-4 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">Logo</th>
<th className="px-4 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">描述</th>
<th className="px-4 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">分类</th>
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">发现时间</th>
<th className="px-4 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">操作</th>
</tr>
@@ -668,6 +766,42 @@ export default function PendingPage() {
AI发现
</span>
</td>
<td className="px-4 py-3 text-center">
{tool.checkWebsiteOk === true ? (
<span className="inline-flex rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">✓</span>
) : tool.checkWebsiteOk === false ? (
<span className="inline-flex rounded-md bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600">✗</span>
) : (
<span className="inline-flex rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
{tool.checkLogoOk === true ? (
<span className="inline-flex rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">✓</span>
) : tool.checkLogoOk === false ? (
<span className="inline-flex rounded-md bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600">✗</span>
) : (
<span className="inline-flex rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
{tool.checkDescOk === true ? (
<span className="inline-flex rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">✓</span>
) : tool.checkDescOk === false ? (
<span className="inline-flex rounded-md bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600">✗</span>
) : (
<span className="inline-flex rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
{tool.checkCategoryOk === true ? (
<span className="inline-flex rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">✓</span>
) : tool.checkCategoryOk === false ? (
<span className="inline-flex rounded-md bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600">✗</span>
) : (
<span className="inline-flex rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3">
<span className="text-[11px] text-muted-foreground">
{new Date(tool.createdAt).toLocaleDateString("zh-CN")}
+129 -1
View File
@@ -26,6 +26,7 @@ export default function RolesPage() {
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
@@ -44,7 +45,10 @@ export default function RolesPage() {
}, []);
useEffect(() => {
Promise.all([fetchRoles(), fetchPermissions()]).finally(() => setLoading(false));
setError("");
Promise.all([fetchRoles(), fetchPermissions()])
.catch(() => setError("加载角色数据失败,请稍后重试"))
.finally(() => setLoading(false));
}, [fetchRoles, fetchPermissions]);
const selectRole = (role: Role) => {
@@ -132,6 +136,130 @@ export default function RolesPage() {
const groups = Array.from(new Set(permissions.map((p) => p.groupName)));
const selectedRole = roles.find((r) => r.id === selectedRoleId);
if (error) {
return (
<div className="flex items-center justify-center h-[calc(100vh-4rem)]">
<div className="flex flex-col items-center">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<button
onClick={() => { setError(""); setLoading(true); Promise.all([fetchRoles(), fetchPermissions()]).catch(() => setError("加载角色数据失败,请稍后重试")).finally(() => setLoading(false)); }}
className="mt-4 rounded-xl border border-border/50 px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
重新加载
</button>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex h-[calc(100vh-4rem)]">
<div className="w-64 shrink-0 border-r border-border/50 bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<div className="h-5 w-16 animate-pulse rounded bg-muted/30" />
<div className="h-7 w-14 animate-pulse rounded-lg bg-muted/30" />
</div>
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg px-3 py-2.5 space-y-1.5">
<div className="h-3 w-24 animate-pulse rounded bg-muted/20" />
<div className="h-2.5 w-32 animate-pulse rounded bg-muted/20" />
</div>
))}
</div>
</div>
<div className="flex-1 p-6">
<div className="space-y-4">
<div className="h-6 w-48 animate-pulse rounded bg-muted/30" />
<div className="h-4 w-64 animate-pulse rounded bg-muted/20" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/50 bg-white p-4 space-y-3">
<div className="h-4 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid grid-cols-3 gap-2">
{Array.from({ length: 6 }).map((_, j) => (
<div key={j} className="h-9 animate-pulse rounded-lg bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (roles.length === 0) {
return (
<div className="flex h-[calc(100vh-4rem)]">
<div className="w-64 shrink-0 border-r border-border/50 bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">角色列表</h2>
<button
onClick={() => { setShowCreate(true); setSelectedRoleId(null); }}
className="rounded-lg bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90 transition-colors"
>
+ 新建
</button>
</div>
{showCreate && (
<div className="mb-3 rounded-xl border border-border/50 bg-muted/30 p-3 space-y-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="角色名称"
className="h-9 w-full rounded-lg border border-border/50 bg-white px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/15"
/>
<input
value={newSlug}
onChange={(e) => setNewSlug(e.target.value.replace(/[^a-z0-9_]/g, ""))}
placeholder="标识 (英文)"
className="h-9 w-full rounded-lg border border-border/50 bg-white px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/15"
/>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={saving || !newName.trim() || !newSlug.trim()}
className="rounded-lg bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
创建
</button>
<button
onClick={() => { setShowCreate(false); setNewName(""); setNewSlug(""); }}
className="rounded-lg border border-border/50 px-3 py-1 text-xs text-muted-foreground hover:bg-muted transition-colors"
>
取消
</button>
</div>
</div>
)}
<div className="flex flex-col items-center justify-center py-16">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<p className="mt-4 text-sm font-medium text-muted-foreground">暂无角色</p>
<p className="mt-1 text-xs text-muted-foreground/60">点击上方按钮创建第一个角色</p>
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<svg className="mx-auto mb-4 h-16 w-16 text-muted-foreground/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
<p className="text-sm font-medium text-foreground">选择一个角色查看权限配置</p>
<p className="mt-1 text-xs text-muted-foreground">从左侧列表选择角色,查看和编辑其权限</p>
</div>
</div>
</div>
);
}
return (
<div className="flex h-[calc(100vh-4rem)]">
<div className="w-64 shrink-0 border-r border-border/50 bg-white p-4 overflow-y-auto">
@@ -32,6 +32,7 @@ const emptyForm = {
export default function SkillCategoriesAdminPage() {
const [categories, setCategories] = useState<SkillCategoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState("");
const [editCat, setEditCat] = useState<SkillCategoryItem | null>(null);
const [isNew, setIsNew] = useState(false);
@@ -40,12 +41,13 @@ export default function SkillCategoriesAdminPage() {
const [error, setError] = useState("");
const fetchCategories = useCallback(async () => {
setFetchError("");
try {
const res = await fetch("/api/admin/skill-categories");
const data = await res.json();
setCategories(data);
} catch {
console.error("Fetch skill categories failed");
setFetchError("加载技能分类失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -207,11 +209,54 @@ export default function SkillCategoriesAdminPage() {
</Button>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
))}
{fetchError ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{fetchError}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchCategories()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
{Array.from({ length: 6 }).map((_, i) => (
<th key={i} className="px-5 py-3.5">
<div className="h-3 w-14 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-5 py-3.5">
<div className="flex items-center gap-2.5">
<div className="h-7 w-7 animate-pulse rounded-lg bg-muted/30" />
<div className="h-3 w-20 animate-pulse rounded bg-muted/20" />
</div>
</td>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : categories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
+54 -6
View File
@@ -30,6 +30,7 @@ interface Skill {
export default function SkillsPage() {
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [total, setTotal] = useState(0);
const [categories, setCategories] = useState<{ id: number; name: string }[]>([]);
const [page, setPage] = useState(1);
@@ -49,13 +50,14 @@ export default function SkillsPage() {
const [saving, setSaving] = useState(false);
const fetchSkills = useCallback(async () => {
setError("");
try {
const res = await fetch(`/api/admin/skills?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
setSkills(data.skills || []);
setTotal(data.total || 0);
} catch {
console.error("Failed to fetch skills");
setError("加载技能列表失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -204,11 +206,57 @@ export default function SkillsPage() {
</div>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
))}
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchSkills()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
{Array.from({ length: 6 }).map((_, i) => (
<th key={i} className="px-5 py-3.5">
<div className="h-3 w-14 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="h-9 w-9 animate-pulse rounded-lg bg-muted/30" />
<div className="space-y-1.5">
<div className="h-3 w-24 animate-pulse rounded bg-muted/20" />
<div className="h-2.5 w-40 animate-pulse rounded bg-muted/20" />
</div>
</div>
</td>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
@@ -0,0 +1,189 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface TaskLogItem {
id: number;
taskKey: string;
status: string;
startedAt: string;
finishedAt: string | null;
duration: number | null;
result: Record<string, unknown> | null;
error: string | null;
}
const TASK_META: Record<string, { name: string; desc: string; cron: string; metrics: string[] }> = {
"task1-discover-tools": { name: "发现AI工具", desc: "每天从多源搜索新AI工具,过滤去重后入库等待审核", cron: "0 6 * * *", metrics: ["发现工具数", "通过检查数", "入库数"] },
"task3-check-tools": { name: "工具巡检", desc: "检查所有已入库工具的网站可用性、Logo完整性,自动发布合格工具", cron: "0 3 * * *", metrics: ["巡检总数", "网站失效数", "Logo异常数", "自动发布数"] },
"task4-discover-skills": { name: "发现AI技能", desc: "从GitHub搜索热门AI开源项目,获取README并入库等待审核", cron: "0 2 * * *", metrics: ["搜索关键词数", "搜索结果数", "新增入库数"] },
"task5-update-stars": { name: "更新星值", desc: "更新所有GitHub技能的最新Stars数,计算日/周/月变化趋势", cron: "0 1 * * *", metrics: ["总项目数", "成功更新数", "失败数"] },
"task6-review-hot": { name: "评测热门", desc: "对未评测的最热门2个AI技能进行五维深度评测并自动发布", cron: "0 4 * * *", metrics: ["候选技能数", "评测完成数"] },
"daily-news": { name: "AI日报生成", desc: "搜索真实新闻素材,AI整理格式生成当日AI日报", cron: "30 7 * * *", metrics: ["新闻源数", "获取素材数", "最终条目数"] },
};
export default function TaskDetailPage() {
const params = useParams();
const taskKey = params.taskKey as string;
const meta = TASK_META[taskKey] || { name: taskKey, desc: "", cron: "", metrics: [] };
const [logs, setLogs] = useState<TaskLogItem[]>([]);
const [config, setConfig] = useState<{ enabled: boolean; cronExpr: string }>({ enabled: true, cronExpr: meta.cron });
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [lRes, cRes] = await Promise.all([
fetch(`/api/admin/tasks/logs?taskKey=${taskKey}&pageSize=30`),
fetch("/api/admin/tasks/configs"),
]);
if (lRes.ok) setLogs((await lRes.json()).logs || []);
if (cRes.ok) {
const tasks = (await cRes.json()).tasks || [];
const t = tasks.find((x: { taskKey: string }) => x.taskKey === taskKey);
if (t) setConfig({ enabled: t.enabled, cronExpr: t.cronExpr });
}
} catch {}
setLoading(false);
}, [taskKey]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleRun = async () => {
setRunning(true);
try {
const res = await fetch("/api/admin/tasks/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskKey }),
});
const data = await res.json();
alert(`执行${data.status === "completed" ? "完成" : "失败"}\n${(data.output || "").slice(-300)}`);
} catch (e: unknown) {
alert(`错误: ${(e as Error).message}`);
}
setRunning(false);
fetchData();
};
const successCount = logs.filter((l) => l.status === "completed").length;
const failCount = logs.filter((l) => l.status === "failed").length;
return (
<div className="p-6 lg:p-8">
<div className="mb-6">
<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 ${config.enabled ? "bg-emerald-100" : "bg-gray-100"}`}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={config.enabled ? "text-emerald-600" : "text-gray-400"}>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
{meta.name}
<span className={`text-xs px-2 py-0.5 rounded-full ${config.enabled ? "bg-emerald-50 text-emerald-700" : "bg-gray-50 text-gray-500"}`}>
{config.enabled ? "已启用" : "已停用"}
</span>
</h1>
<p className="mt-1 text-sm text-muted-foreground">{meta.desc}</p>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<code className="bg-muted/50 px-2 py-0.5 rounded">{config.cronExpr || "未配置"}</code>
<button
onClick={handleRun}
disabled={running}
className="px-3 py-1.5 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{running ? "执行中..." : "手动执行"}
</button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{logs.length}</div>
<div className="text-[11px] text-muted-foreground mt-1">总执行次数</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-emerald-600">{successCount}</div>
<div className="text-[11px] text-muted-foreground mt-1">成功</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-red-600">{failCount}</div>
<div className="text-[11px] text-muted-foreground mt-1">失败</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-blue-600">{Math.round((successCount / Math.max(logs.length, 1)) * 100)}%</div>
<div className="text-[11px] text-muted-foreground mt-1">成功率</div>
</CardContent>
</Card>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-muted/20 rounded-lg animate-pulse" />
))}
</div>
) : logs.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground">暂无执行记录,点击"手动执行"触发首次运行</p>
</div>
) : (
<div className="space-y-2">
{logs.map((log) => {
const isSuccess = log.status === "completed";
const isFailed = log.status === "failed";
return (
<details key={log.id} className="group">
<summary className="list-none cursor-pointer">
<Card className="border-border/60 bg-white shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${isSuccess ? "bg-emerald-500" : isFailed ? "bg-red-500" : "bg-amber-500"}`} />
<span className="text-xs font-medium text-foreground">
{isSuccess ? "成功" : isFailed ? "失败" : log.status}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
<span>{new Date(log.startedAt).toLocaleString("zh-CN")}</span>
{log.duration && <span>耗时: {(log.duration / 1000).toFixed(1)}s</span>}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="group-open:rotate-180 transition-transform">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
</CardContent>
</Card>
</summary>
{log.result && (
<div className="mx-1 mt-1 p-3 bg-slate-50 border border-border/30 rounded-lg">
<pre className="text-[11px] text-muted-foreground whitespace-pre-wrap break-all max-h-80 overflow-auto">
{typeof log.result === "string" ? log.result : JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{log.error && (
<div className="mx-1 mt-1 p-3 bg-red-50 border border-red-100 rounded-lg">
<p className="text-[11px] text-red-700 whitespace-pre-wrap">{log.error}</p>
</div>
)}
</details>
);
})}
</div>
)}
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
interface TaskLogItem {
id: number;
taskKey: string;
taskName: string;
status: string;
startedAt: string;
finishedAt: string | null;
duration: number | null;
result: Record<string, unknown> | null;
error: string | null;
triggerBy: string;
}
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: "已完成", color: "text-emerald-700", bg: "bg-emerald-50" },
failed: { label: "失败", color: "text-red-700", bg: "bg-red-50" },
running: { label: "运行中", color: "text-blue-700", bg: "bg-blue-50" },
completed_with_errors: { label: "部分失败", color: "text-amber-700", bg: "bg-amber-50" },
};
const TASK_LABELS: Record<string, string> = {
"task1-discover-tools": "发现AI工具",
"task3-check-tools": "工具巡检",
"task4-discover-skills": "发现AI技能",
"task5-update-stars": "更新星值",
"task6-review-hot": "评测热门",
"daily-news": "AI日报",
};
export default function TaskLogsPage() {
const [logs, setLogs] = useState<TaskLogItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [taskFilter, setTaskFilter] = useState("");
const [expandedId, setExpandedId] = useState<number | null>(null);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), pageSize: "20" });
if (taskFilter) params.set("taskKey", taskFilter);
const res = await fetch(`/api/admin/tasks/logs?${params}`);
if (res.ok) {
const data = await res.json();
setLogs(data.logs || []);
setTotal(data.total || 0);
}
} catch {}
setLoading(false);
}, [page, taskFilter]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
return (
<div className="p-6 lg:p-8">
<div className="mb-6">
<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-slate-100">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-slate-600">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
任务执行日志
</h1>
<p className="mt-2 text-sm text-muted-foreground">
所有定时任务的历史执行记录,共 {total} 条
</p>
</div>
<div className="mb-4 flex items-center gap-2 flex-wrap">
<select
value={taskFilter}
onChange={(e) => { setTaskFilter(e.target.value); setPage(1); }}
className="px-3 py-1.5 text-sm border border-border/60 rounded-lg bg-white text-muted-foreground"
>
<option value="">全部任务</option>
{Object.entries(TASK_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<span className="text-xs text-muted-foreground ml-auto">共 {total} 条记录</span>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-muted/20 rounded-lg animate-pulse" />
))}
</div>
) : logs.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground">暂无执行日志</p>
</div>
) : (
<div className="space-y-2">
{logs.map((log) => {
const st = STATUS_MAP[log.status] || { label: log.status, color: "text-gray-600", bg: "bg-gray-50" };
const isExpanded = expandedId === log.id;
return (
<div key={log.id}>
<Card
className="border-border/60 bg-white shadow-sm cursor-pointer hover:shadow-md transition-shadow"
onClick={() => setExpandedId(isExpanded ? null : log.id)}
>
<CardContent className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block px-1.5 py-0.5 text-[10px] rounded ${st.bg} ${st.color}`}>
{st.label}
</span>
<span className="text-xs font-medium text-foreground">{TASK_LABELS[log.taskKey] || log.taskKey}</span>
<span className="text-[10px] text-muted-foreground/50">{log.triggerBy === "manual" ? "手动" : "自动"}</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted-foreground flex-shrink-0">
<span>{new Date(log.startedAt).toLocaleString("zh-CN")}</span>
{log.duration && <span>{(log.duration / 1000).toFixed(1)}s</span>}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={`transition-transform ${isExpanded ? "rotate-180" : ""}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
{log.error && !isExpanded && (
<p className="mt-1 text-[10px] text-red-600 truncate">{log.error}</p>
)}
</CardContent>
</Card>
{isExpanded && log.result && (
<div className="mx-1 mt-1 p-3 bg-slate-50 border border-border/30 rounded-lg">
<pre className="text-[11px] text-muted-foreground whitespace-pre-wrap break-all max-h-60 overflow-auto">
{typeof log.result === "string" ? log.result : JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{isExpanded && log.error && (
<div className="mx-1 mt-1 p-3 bg-red-50 border border-red-100 rounded-lg">
<p className="text-[11px] text-red-700 whitespace-pre-wrap">{log.error}</p>
</div>
)}
</div>
);
})}
</div>
)}
{total > 20 && (
<div className="mt-4 flex items-center justify-center gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-xs rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40"
>
上一页
</button>
<span className="text-xs text-muted-foreground">{page} / {Math.ceil(total / 20)}</span>
<button
onClick={() => setPage(page + 1)}
disabled={page * 20 >= total}
className="px-3 py-1.5 text-xs rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40"
>
下一页
</button>
</div>
)}
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
interface TaskItem {
taskKey: string;
taskName: string;
cronExpr: string;
enabled: boolean;
description: string;
config: unknown;
updatedAt: string | null;
}
interface TaskLogItem {
id: number;
taskKey: string;
taskName: string;
status: string;
startedAt: string;
finishedAt: string | null;
duration: number | null;
result: Record<string, unknown> | null;
error: string | null;
}
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: "已完成", color: "text-emerald-700", bg: "bg-emerald-50" },
failed: { label: "失败", color: "text-red-700", bg: "bg-red-50" },
running: { label: "运行中", color: "text-blue-700", bg: "bg-blue-50" },
};
function getLastRun(logs: TaskLogItem[] | null, taskKey: string) {
if (!logs) return null;
return logs.find((l) => l.taskKey === taskKey) || null;
}
export default function TasksPage() {
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [logs, setLogs] = useState<TaskLogItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [runningTask, setRunningTask] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setError("");
try {
const [tRes, lRes] = await Promise.all([
fetch("/api/admin/tasks/configs"),
fetch("/api/admin/tasks/logs?pageSize=7"),
]);
if (tRes.ok) setTasks((await tRes.json()).tasks || []);
if (lRes.ok) setLogs((await lRes.json()).logs || []);
} catch {
setError("加载任务数据失败,请稍后重试");
}
setLoading(false);
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const runTask = async (taskKey: string) => {
setRunningTask(taskKey);
try {
const res = await fetch("/api/admin/tasks/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskKey }),
});
const data = await res.json();
alert(`任务 ${taskKey} 执行${data.status === "completed" ? "完成" : "失败"}\n${(data.output || "").slice(-500)}`);
} catch (e: unknown) {
alert(`执行失败: ${(e as Error).message}`);
}
setRunningTask(null);
fetchData();
};
const toggleTask = async (taskKey: string, enabled: boolean) => {
await fetch("/api/admin/tasks/configs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskKey, enabled }),
});
fetchData();
};
if (error) {
return (
<div className="p-6 lg:p-8">
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<button
onClick={() => fetchData()}
className="mt-4 rounded-xl border border-border/50 px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
重新加载
</button>
</div>
</div>
);
}
if (loading) {
return (
<div className="p-6 lg:p-8">
<div className="mb-8 space-y-1.5">
<div className="h-7 w-44 animate-pulse rounded bg-muted/30" />
<div className="h-4 w-80 animate-pulse rounded bg-muted/20" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/60 bg-white p-3 space-y-2">
<div className="h-3 w-16 animate-pulse rounded bg-muted/20" />
<div className="h-2.5 w-20 animate-pulse rounded bg-muted/20" />
</div>
))}
</div>
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/60 bg-white p-4 space-y-3">
<div className="flex items-center gap-2">
<div className="h-4 w-28 animate-pulse rounded bg-muted/30" />
<div className="h-4 w-20 animate-pulse rounded bg-muted/20" />
</div>
<div className="h-3 w-72 animate-pulse rounded bg-muted/20" />
<div className="h-3 w-48 animate-pulse rounded bg-muted/20" />
</div>
))}
</div>
</div>
);
}
return (
<div className="p-6 lg:p-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-blue-100">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
任务调度中心
</h1>
<p className="mt-2 text-sm text-muted-foreground">
管理所有定时任务,查看执行历史和运行状态,支持手动触发
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{["task1-discover-tools", "task3-check-tools", "task4-discover-skills", "task5-update-stars", "task6-review-hot", "daily-news"].map((key) => {
const t = tasks.find((x) => x.taskKey === key);
const lastLog = getLastRun(logs, key);
const st = lastLog ? STATUS_MAP[lastLog.status] : null;
return (
<Card key={key} className="border-border/60 bg-white shadow-sm">
<CardContent className="p-3">
<div className="text-xs text-muted-foreground mb-1">{t?.taskName || key}</div>
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${t?.enabled ? "bg-emerald-500" : "bg-gray-300"}`} />
<span className="text-xs font-medium">{t?.enabled ? "启用" : "停用"}</span>
{st && <span className={`text-[10px] ml-auto ${st.color}`}>{st.label}</span>}
</div>
</CardContent>
</Card>
);
})}
</div>
<div className="space-y-3">
{tasks.map((task) => {
const lastLog = getLastRun(logs, task.taskKey);
const st = lastLog ? STATUS_MAP[lastLog.status] : null;
return (
<Card key={task.taskKey} className="border-border/60 bg-white shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">{task.taskName}</h3>
<span className="text-[10px] text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">{task.taskKey}</span>
{st && (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${st.bg} ${st.color}`}>
{st.label}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground/70">{task.description}</p>
<div className="mt-2 flex items-center gap-4 text-[11px] text-muted-foreground">
<span>Cron: <code className="text-foreground/70 bg-muted/30 px-1 rounded">{task.cronExpr || "未配置"}</code></span>
{lastLog && (
<>
<span>上次: {new Date(lastLog.startedAt).toLocaleString("zh-CN")}</span>
{lastLog.duration && <span>耗时: {(lastLog.duration / 1000).toFixed(1)}s</span>}
</>
)}
</div>
{lastLog?.error && (
<p className="mt-1 text-[10px] text-red-600 truncate bg-red-50 rounded px-2 py-1">
{lastLog.error}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => toggleTask(task.taskKey, !task.enabled)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
task.enabled
? "bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200"
: "bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200"
}`}
>
{task.enabled ? "启用中" : "已停用"}
</button>
<button
onClick={() => runTask(task.taskKey)}
disabled={runningTask === task.taskKey}
className="px-2.5 py-1 text-xs rounded-md bg-blue-50 text-blue-700 hover:bg-blue-100 border border-blue-200 disabled:opacity-50 transition-colors"
>
{runningTask === task.taskKey ? "执行中..." : "立即执行"}
</button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{tasks.length === 0 && (
<div className="flex flex-col items-center justify-center py-20">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无任务配置</p>
<p className="mt-1 text-xs text-muted-foreground/60">请检查任务配置是否已初始化</p>
</div>
)}
</div>
);
}
+54 -6
View File
@@ -128,6 +128,7 @@ export default function ToolsAdminPage() {
const [tools, setTools] = useState<Tool[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 20;
@@ -138,13 +139,14 @@ export default function ToolsAdminPage() {
const [saving, setSaving] = useState(false);
const fetchTools = useCallback(async () => {
setError("");
try {
const res = await fetch(`/api/admin/tools?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
console.error("Fetch tools failed");
setError("加载工具列表失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -314,11 +316,57 @@ export default function ToolsAdminPage() {
</Button>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
))}
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-100">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchTools()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
{Array.from({ length: 6 }).map((_, i) => (
<th key={i} className="px-5 py-3.5">
<div className="h-3 w-14 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="h-9 w-9 animate-pulse rounded-lg bg-muted/30" />
<div className="space-y-1.5">
<div className="h-3 w-24 animate-pulse rounded bg-muted/20" />
<div className="h-2.5 w-40 animate-pulse rounded bg-muted/20" />
</div>
</div>
</td>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
+396
View File
@@ -0,0 +1,396 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
}
interface FlatCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
toolCount: number;
tools: Tool[];
}
interface ParentCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
interface HomePageClientProps {
flatCategories: FlatCategory[];
parentCategories: ParentCategory[];
totalTools: number;
totalSkills: number;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
paid: "bg-amber-50 text-amber-700 border border-amber-200",
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
};
export default function HomePageClient({
flatCategories: categories,
parentCategories,
totalTools,
totalSkills,
}: HomePageClientProps) {
const router = useRouter();
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const handleSearch = () => {
if (search.trim()) {
router.push(`/tools?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
const isEmpty = categories.length === 0 && parentCategories.length === 0;
if (isEmpty) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索工具..."
className="input-field-compact"
/>
</div>
<div className="h-px bg-border/40 my-2" />
{parentCategories.map((p) => {
const isActive = activeSection === `cat-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.toolCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.toolCount, 0);
const totalCount = ownCount + childCount;
return (
<button
key={p.id}
onClick={() => scrollToSection(`cat-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-primary/10 font-semibold text-primary shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-primary/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalTools} 个工具
</p>
</div>
</nav>
);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI工具..."
className="input-field !pl-10 !h-10 !rounded-xl"
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="AI"
highlightColor="blue"
starStyle="cosmos"
stats={[
{ label: "AI工具", value: totalTools, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="汇聚全球优质AI工具,赋能每一个创新者"
subSlogan="发现、探索、使用 — 让人工智能触手可及"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.toolCount, 0);
const allTools = allCatsInGroup.flatMap((c) => c.tools);
const displayTools = activeTab === "all"
? allTools
: children.find((c) => c.slug === activeTab)?.tools || ownCat?.tools || [];
const maxDisplay = 8;
const visibleTools = displayTools.slice(0, maxDisplay);
const hasMore = displayTools.length > maxDisplay;
return (
<section
key={`cat-${parent.slug}`}
id={`cat-${parent.slug}`}
ref={(el) => { sectionRefs.current[`cat-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold text-primary tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.toolCount}</span>
</button>
))}
</div>
)}
{visibleTools.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleTools.map((tool) => (
<Card
key={tool.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/30 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-xs font-semibold text-primary transition-all duration-300 group-hover:scale-105 ${tool.logoUrl ? 'hidden' : ''}`}>
{tool.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{tool.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/categories/${cat}`);
}}
>
查看全部 {displayTools.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}
+118 -99
View File
@@ -3,6 +3,109 @@ import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { fetchLogoWithMethod } from "@/lib/logo-fetcher";
import { downloadLogo } from "@/lib/logo-storage";
import { checkTypeSchema } from "@/lib/validation";
interface CheckResult {
id: number;
name: string;
url: string;
accessible: boolean;
logoFetched: boolean;
logoMethod: string;
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) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(url, {
method: "GET",
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
});
clearTimeout(timeout);
accessible = res.status >= 200 && res.status < 400;
if (accessible) {
const ct = res.headers.get("content-type") || "";
if (ct.includes("text/html")) {
html = await res.text();
}
}
} catch {
accessible = false;
}
}
let logoFetched = false;
let logoMethod = "";
let logoUrl = tool.logoUrl || "";
const needsLogo = !logoUrl || logoUrl === "" || (!logoUrl.startsWith("/logos/") && !logoUrl.startsWith("data:"));
if (accessible && url && needsLogo) {
try {
let logoResult = { url: "", method: "" };
if (html) {
logoResult = await fetchLogoWithMethod(url, html);
}
if (!logoResult.url) {
logoResult = await fetchLogoWithMethod(url);
}
if (logoResult.url) {
const localPath = await downloadLogo(logoResult.url, tool.name.replace(/[^a-z0-9]/gi, "_").substring(0, 20));
if (type === "published") {
await prisma.tool.update({
where: { id: tool.id },
data: { logoUrl: localPath },
});
} else {
await prisma.pendingTool.update({
where: { id: tool.id },
data: { logoUrl: localPath },
});
}
logoFetched = true;
logoMethod = logoResult.method;
logoUrl = localPath;
}
} catch {
// Logo fetch failed silently
}
}
return {
id: tool.id,
name: tool.name,
url: url || "",
accessible,
logoFetched,
logoMethod,
logoUrl,
};
}
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -10,15 +113,13 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { type } = body as { type: "published" | "pending" };
if (!type || !["published", "pending"].includes(type)) {
return NextResponse.json(
{ error: "type 参数无效,必须为 published 或 pending" },
{ status: 400 }
);
const parsed = checkTypeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { type } = parsed.data;
const tools =
type === "published"
? await prisma.tool.findMany({
@@ -28,101 +129,19 @@ export async function POST(request: Request) {
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
});
const results: {
id: number;
name: string;
url: string;
accessible: boolean;
logoFetched: boolean;
logoMethod: string;
logoUrl: string;
}[] = [];
const CONCURRENCY = 10;
const results: CheckResult[] = [];
for (const tool of tools) {
const url = tool.websiteUrl;
let accessible = false;
let html: string | null = null;
if (url) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(url, {
method: "GET",
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
});
clearTimeout(timeout);
accessible = res.status >= 200 && res.status < 400;
if (accessible) {
const ct = res.headers.get("content-type") || "";
if (ct.includes("text/html")) {
html = await res.text();
}
}
} catch {
accessible = false;
for (let i = 0; i < tools.length; i += CONCURRENCY) {
const batch = tools.slice(i, i + CONCURRENCY);
const batchResults = await Promise.allSettled(
batch.map((tool) => checkSingleTool(tool, type))
);
for (const r of batchResults) {
if (r.status === "fulfilled") {
results.push(r.value);
}
}
let logoFetched = false;
let logoMethod = "";
let logoUrl = tool.logoUrl || "";
const needsLogo = !logoUrl || logoUrl === "" || (!logoUrl.startsWith("/logos/") && !logoUrl.startsWith("data:"));
if (accessible && url && needsLogo) {
try {
let logoResult = { url: "", method: "" };
if (html) {
logoResult = await fetchLogoWithMethod(url, html);
}
if (!logoResult.url) {
logoResult = await fetchLogoWithMethod(url);
}
if (logoResult.url) {
const localPath = await downloadLogo(logoResult.url, tool.name.replace(/[^a-z0-9]/gi, "_").substring(0, 20));
if (type === "published") {
await prisma.tool.update({
where: { id: tool.id },
data: { logoUrl: localPath },
});
} else {
await prisma.pendingTool.update({
where: { id: tool.id },
data: { logoUrl: localPath },
});
}
logoFetched = true;
logoMethod = logoResult.method;
logoUrl = localPath;
}
} catch {
// Logo fetch failed silently
}
}
results.push({
id: tool.id,
name: tool.name,
url: url || "",
accessible,
logoFetched,
logoMethod,
logoUrl,
});
}
return NextResponse.json({ results });
+6 -6
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { newsImportSchema } from "@/lib/validation";
function getTodayDate() {
const now = new Date();
@@ -13,14 +14,13 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { items } = body as {
items: { title: string; summary?: string; detail?: string; category?: string; sourceUrl?: string; hotScore?: number }[];
};
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: "items 数组不能为空" }, { status: 400 });
const parsed = newsImportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { items } = parsed.data;
const today = getTodayDate();
const existing = await prisma.dailyReport.findUnique({
+5 -10
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { skillImportSchema } from "@/lib/validation";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -8,18 +9,12 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { skills, categoryId } = body as {
skills: { name: string; description?: string; sourceUrl?: string; sourceType?: string; rating?: number; features?: string[]; tags?: string[] }[];
categoryId: number;
};
if (!skills || !Array.isArray(skills) || skills.length === 0) {
return NextResponse.json({ error: "skills 数组不能为空" }, { status: 400 });
const parsed = skillImportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
if (!categoryId) {
return NextResponse.json({ error: "categoryId 不能为空" }, { status: 400 });
}
const { skills, categoryId } = parsed.data;
const [existingPending, existingPublished] = await Promise.all([
prisma.pendingSkill.findMany({ select: { name: true, sourceUrl: true } }),
+5 -10
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { toolImportSchema } from "@/lib/validation";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -8,18 +9,12 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { tools, categoryId } = body as {
tools: { name: string; description?: string; websiteUrl?: string; logoUrl?: string; pricingModel?: string; pricingDetail?: string; features?: string[]; tags?: string[] }[];
categoryId: number;
};
if (!tools || !Array.isArray(tools) || tools.length === 0) {
return NextResponse.json({ error: "tools 数组不能为空" }, { status: 400 });
const parsed = toolImportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
if (!categoryId) {
return NextResponse.json({ error: "categoryId 不能为空" }, { status: 400 });
}
const { tools, categoryId } = parsed.data;
const [existingPending, existingPublished] = await Promise.all([
prisma.pendingTool.findMany({ select: { name: true, websiteUrl: true } }),
@@ -11,28 +11,35 @@ export async function POST(request: Request, { params }: { params: { id: string
const pending = await prisma.pendingSkill.findUnique({ where: { id } });
if (!pending) return NextResponse.json({ error: "未找到" }, { status: 404 });
if (pending.status !== "pending") {
return NextResponse.json({ error: "该记录已被处理" }, { 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[] : [];
const tags = Array.isArray(pending.tags) ? pending.tags as any[] : [];
const skill = await prisma.skill.create({
data: {
name: pending.name,
slug,
categoryId: pending.categoryId!,
description: pending.description || "",
logoUrl: pending.logoUrl,
sourceUrl: pending.sourceUrl || "",
sourceType: pending.sourceType || "github",
features,
tags,
status: "published",
},
const skillRef: { current: { id: number } | null } = { current: null };
await prisma.$transaction(async (tx) => {
skillRef.current = await tx.skill.create({
data: {
name: pending.name,
slug,
categoryId: pending.categoryId!,
description: pending.description || "",
logoUrl: pending.logoUrl,
sourceUrl: pending.sourceUrl || "",
sourceType: pending.sourceType || "github",
features,
tags,
status: "published",
},
});
await tx.pendingSkill.delete({ where: { id } });
});
await prisma.pendingSkill.delete({ where: { id } });
return NextResponse.json({ skill });
return NextResponse.json({ skill: skillRef.current });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
+16
View File
@@ -17,6 +17,22 @@ export async function GET(request: Request) {
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
name: true,
slug: true,
description: true,
sourceUrl: true,
sourceType: true,
rating: true,
features: true,
tags: true,
categoryId: true,
status: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.pendingSkill.count({ where: { status: "pending" } }),
]);
+24 -17
View File
@@ -39,6 +39,10 @@ export async function POST(
return NextResponse.json({ error: "待审核工具不存在" }, { status: 404 });
}
if (pending.status !== "pending") {
return NextResponse.json({ error: "该记录已被处理" }, { status: 409 });
}
let slug = pinyinSlug(pending.name);
const existing = await prisma.tool.findUnique({ where: { slug } });
if (existing) {
@@ -49,27 +53,30 @@ export async function POST(
await prisma.category.findFirst({ orderBy: { sortOrder: "asc" } })
)?.id ?? 1);
const tool = await prisma.tool.create({
data: {
name: pending.name,
slug,
categoryId,
description: pending.description ?? "",
logoUrl: pending.logoUrl,
websiteUrl: pending.websiteUrl ?? "",
pricingModel: pending.pricingModel ?? "freemium",
pricingDetail: pending.pricingDetail,
features: pending.features as any,
tags: pending.tags as any,
status: "published",
},
let toolId = 0;
await prisma.$transaction(async (tx) => {
const tool = await tx.tool.create({
data: {
name: pending.name,
slug,
categoryId,
description: pending.description ?? "",
logoUrl: pending.logoUrl,
websiteUrl: pending.websiteUrl ?? "",
pricingModel: pending.pricingModel ?? "freemium",
pricingDetail: pending.pricingDetail,
features: pending.features as any,
tags: pending.tags as any,
status: "published",
},
});
toolId = tool.id;
await tx.pendingTool.delete({ where: { id } });
});
await prisma.pendingTool.delete({ where: { id } });
return NextResponse.json({
success: true,
toolId: tool.id,
toolId,
message: "发布成功",
});
} catch (error: unknown) {
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const tasks = [
{ taskKey: "task1-discover-tools", taskName: "发现AI工具", cronExpr: "0 6 * * *", enabled: true, desc: "每天从多源搜索新AI工具,过滤去重后入库等待审核" },
{ taskKey: "task3-check-tools", taskName: "工具巡检", cronExpr: "0 3 * * *", enabled: true, desc: "检查所有已入库工具的网站可用性、Logo完整性、自动发布合格工具" },
{ taskKey: "task4-discover-skills", taskName: "发现AI技能", cronExpr: "0 2 * * *", enabled: true, desc: "从GitHub搜索热门AI开源项目(≥1000 stars),获取README并入库" },
{ taskKey: "task5-update-stars", taskName: "更新星值", cronExpr: "0 1 * * *", enabled: true, desc: "更新所有GitHub技能的最新Stars数,计算日/周/月变化趋势" },
{ taskKey: "task6-review-hot", taskName: "评测热门", cronExpr: "0 4 * * *", enabled: true, desc: "对未评测的最热门2个AI技能进行五维深度评测并自动发布" },
{ taskKey: "daily-news", taskName: "AI日报生成", cronExpr: "30 7 * * *", enabled: true, desc: "搜索真实新闻素材,AI整理格式生成当日AI日报" },
];
const configs = await prisma.taskConfig.findMany();
const configMap = new Map(configs.map((c) => [c.taskKey, c]));
const merged = tasks.map((t) => {
const cfg = configMap.get(t.taskKey);
return {
taskKey: t.taskKey,
taskName: t.taskName,
cronExpr: cfg?.cronExpr || t.cronExpr,
enabled: cfg?.enabled ?? t.enabled,
description: t.desc,
config: cfg?.config || null,
updatedAt: cfg?.updatedAt?.toISOString() || null,
};
});
return NextResponse.json({ tasks: merged });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const body = await request.json();
const taskKey = body.taskKey as string;
const { enabled, cronExpr, config } = body;
if (!taskKey) {
return NextResponse.json({ error: "缺少taskKey" }, { status: 400 });
}
const existing = await prisma.taskConfig.findUnique({ where: { taskKey } });
let result;
if (existing) {
result = await prisma.taskConfig.update({
where: { taskKey },
data: {
enabled: enabled ?? existing.enabled,
cronExpr: cronExpr ?? existing.cronExpr,
config: config ?? existing.config,
},
});
} else {
const taskMeta: Record<string, string> = {
"task1-discover-tools": "发现AI工具",
"task3-check-tools": "工具巡检",
"task4-discover-skills": "发现AI技能",
"task5-update-stars": "更新星值",
"task6-review-hot": "评测热门",
"daily-news": "AI日报生成",
};
result = await prisma.taskConfig.create({
data: {
taskKey,
taskName: taskMeta[taskKey] || taskKey,
enabled: enabled ?? true,
cronExpr: cronExpr || null,
config: config || null,
},
});
}
return NextResponse.json({ task: result });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const taskKey = searchParams.get("taskKey");
const page = parseInt(searchParams.get("page") || "1");
const pageSize = parseInt(searchParams.get("pageSize") || "20");
const where: Record<string, unknown> = {};
if (taskKey) where.taskKey = taskKey;
const [logs, total] = await Promise.all([
prisma.taskLog.findMany({
where,
orderBy: { startedAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.taskLog.count({ where }),
]);
return NextResponse.json({
logs: logs.map((l) => ({
...l,
startedAt: l.startedAt.toISOString(),
finishedAt: l.finishedAt?.toISOString() || null,
createdAt: l.createdAt.toISOString(),
})),
total,
page,
pageSize,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { requireAdmin } from "@/lib/admin-auth";
import { taskRunSchema } from "@/lib/validation";
import path from "path";
const execAsync = promisify(exec);
const SCRIPT_MAP: Record<string, string> = {
"task1-discover-tools": "scripts/task1-discover-tools.mjs",
"task3-check-tools": "scripts/task3-check-tools.mjs",
"task4-discover-skills": "scripts/daily-discover.mjs",
"task5-update-stars": "scripts/task5-update-stars.mjs",
"task6-review-hot": "scripts/task6-review-hot.mjs",
"daily-news": "scripts/daily-news.mjs",
};
const PROJECT_ROOT = process.env.SCRIPTS_ROOT || process.cwd();
export async function POST(request: Request) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await request.json();
const parsed = taskRunSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { taskKey } = parsed.data;
const scriptPath = SCRIPT_MAP[taskKey];
if (!scriptPath) {
return NextResponse.json({ error: `未知任务: ${taskKey}` }, { status: 400 });
}
const fullPath = path.join(PROJECT_ROOT, scriptPath);
const result: { taskKey: string; status: string; pid: number; output: string } = {
taskKey,
status: "started",
pid: 0,
output: "",
};
try {
const { stdout, stderr } = await execAsync(
`node "${fullPath}" 2>&1`,
{
timeout: 120000,
maxBuffer: 1024 * 1024 * 2,
env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: "0" },
}
);
result.status = stderr ? "completed_with_errors" : "completed";
result.output = (stdout + (stderr ? "\n" + stderr : "")).slice(-5000);
} catch (execError: unknown) {
const e = execError as { message?: string; stdout?: string; stderr?: string };
result.status = "failed";
result.output = (e.stdout || "") + "\n" + (e.stderr || "") + "\n" + (e.message || "");
}
return NextResponse.json(result);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+37 -28
View File
@@ -1,36 +1,45 @@
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { registerSchema } from "@/lib/validation";
import { rateLimit } from "@/lib/rate-limit";
export async function POST(request: Request) {
const body = await request.json();
const { email, password, name } = body;
try {
const ip = request.headers.get("x-forwarded-for") || "unknown";
if (!rateLimit(ip, 5, 60000)) {
return NextResponse.json({ error: "请求过于频繁,请稍后再试" }, { status: 429 });
}
if (!email || !password) {
return NextResponse.json({ error: "邮箱和密码不能为空" }, { status: 400 });
const body = await request.json();
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { email, password, name } = parsed.data;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "该邮箱已注册" }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
email,
passwordHash,
name: name || null,
role: "user",
},
});
return NextResponse.json(
{ id: user.id, email: user.email, name: user.name },
{ status: 201 }
);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "注册失败" }, { status: 500 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "该邮箱已注册" }, { status: 409 });
}
if (password.length < 6) {
return NextResponse.json({ error: "密码至少6位" }, { status: 400 });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
email,
passwordHash,
name: name || null,
role: "user",
},
});
return NextResponse.json(
{ id: user.id, email: user.email, name: user.name },
{ status: 201 }
);
}
+67
View File
@@ -0,0 +1,67 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const commentId = parseInt(params.id);
const [existing] = await prisma.commentLike.findMany({
where: { commentId, userId },
});
if (existing) {
await prisma.commentLike.delete({
where: { commentId_userId: { commentId, userId } },
});
await prisma.comment.update({
where: { id: commentId },
data: { likeCount: { decrement: 1 } },
});
return NextResponse.json({ liked: false });
}
const result = await prisma.$transaction(async (tx) => {
await tx.commentLike.create({
data: { commentId, userId },
});
await tx.comment.update({
where: { id: commentId },
data: { likeCount: { increment: 1 } },
});
const comment = await tx.comment.findUnique({
where: { id: commentId },
select: { userId: true },
});
if (comment && comment.userId !== userId) {
await tx.notification.create({
data: {
userId: comment.userId,
type: "COMMENT_LIKE",
title: `${session.user.name || "用户"} 点赞了你的评论`,
link: "/notifications",
},
});
}
return comment;
});
if (result) {
await rewardPoints(result.userId, "comment_like", "评论被点赞", "comment", commentId);
}
return NextResponse.json({ liked: true });
}
+206
View File
@@ -0,0 +1,206 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const toolId = searchParams.get("toolId");
const skillId = searchParams.get("skillId");
const parentId = searchParams.get("parentId");
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
if (!toolId && !skillId) {
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
}
const where: any = { status: "ACTIVE" };
if (toolId) where.toolId = parseInt(toolId);
if (skillId) where.skillId = parseInt(skillId);
if (parentId) where.parentId = parseInt(parentId);
else where.parentId = null;
const [comments, totalCount] = await Promise.all([
prisma.comment.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
likes: { select: { userId: true } },
replies: {
where: { status: "ACTIVE" },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
likes: { select: { userId: true } },
},
orderBy: { createdAt: "asc" },
take: 5,
},
_count: { select: { replies: true } },
},
orderBy: [
{ isPinned: "desc" },
{ createdAt: "desc" },
],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.comment.count({ where }),
]);
const session = await getServerSession(authOptions);
const currentUserId = session?.user?.id ? parseInt(session.user.id) : null;
const formatted = comments.map((c: any) => ({
id: c.id,
content: c.content,
rating: c.rating,
likeCount: c.likeCount,
isPinned: c.isPinned,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
replyCount: c._count.replies,
liked: currentUserId ? c.likes.some((l: any) => l.userId === currentUserId) : false,
user: {
id: c.user.id,
name: c.user.name || c.user.email.split("@")[0],
avatarUrl: c.user.avatarUrl,
level: c.user.level,
points: c.user.points,
},
replies: c.replies.map((r: any) => ({
id: r.id,
content: r.content,
likeCount: r.likeCount,
createdAt: r.createdAt,
liked: currentUserId ? r.likes.some((l: any) => l.userId === currentUserId) : false,
user: {
id: r.user.id,
name: r.user.name || r.user.email.split("@")[0],
avatarUrl: r.user.avatarUrl,
level: r.user.level,
points: r.user.points,
},
})),
}));
return NextResponse.json({ comments: formatted, totalCount, page, pageSize });
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await req.json();
const { toolSlug, skillSlug, content, rating, parentId } = body;
const userId = parseInt(session.user.id);
if (!content || !content.trim()) {
return NextResponse.json({ error: "评论内容不能为空" }, { status: 400 });
}
if (content.trim().length > 2000) {
return NextResponse.json({ error: "评论内容不能超过2000个字符" }, { status: 400 });
}
let toolId: number | undefined;
let skillId: number | undefined;
if (toolSlug) {
const tool = await prisma.tool.findUnique({ where: { slug: toolSlug } });
if (!tool) return NextResponse.json({ error: "工具不存在" }, { status: 404 });
toolId = tool.id;
} else if (skillSlug) {
const skill = await prisma.skill.findUnique({ where: { slug: skillSlug } });
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
skillId = skill.id;
} else {
return NextResponse.json({ error: "必须提供 toolSlug 或 skillSlug" }, { status: 400 });
}
if (!parentId && rating && (rating < 1 || rating > 5)) {
return NextResponse.json({ error: "评分必须为1-5" }, { status: 400 });
}
const comment = await prisma.$transaction(async (tx) => {
const newComment = await tx.comment.create({
data: {
userId,
toolId,
skillId,
parentId: parentId || null,
content: content.trim(),
rating: parentId ? null : rating || null,
},
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
});
if (!parentId) {
if (toolId) {
await tx.tool.update({
where: { id: toolId },
data: { commentCount: { increment: 1 } },
});
}
if (skillId) {
await tx.skill.update({
where: { id: skillId },
data: { commentCount: { increment: 1 } },
});
}
}
const parentComment = parentId
? await tx.comment.findUnique({ where: { id: parentId }, select: { userId: true } })
: null;
if (parentComment && parentComment.userId !== userId) {
const replyTo = await tx.user.findUnique({
where: { id: parentId ? (await tx.comment.findUnique({ where: { id: parentId } }))?.userId : undefined },
select: { id: true, name: true },
});
if (replyTo) {
await tx.notification.create({
data: {
userId: replyTo.id,
type: "COMMENT_REPLY",
title: `${session.user.name || "用户"} 回复了你的评论`,
content: content.trim().slice(0, 100),
link: toolSlug ? `/tools/${toolSlug}` : `/skills/${skillSlug}`,
},
});
}
}
return newComment;
});
if (!parentId) {
await rewardPoints(userId, "comment", `评论${toolSlug || skillSlug}`, toolSlug ? "tool" : "skill", toolId || skillId);
}
const formatted = {
id: comment.id,
content: comment.content,
rating: comment.rating,
likeCount: 0,
isPinned: comment.isPinned,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
replyCount: 0,
liked: false,
user: {
id: comment.user.id,
name: comment.user.name || comment.user.email.split("@")[0],
avatarUrl: comment.user.avatarUrl,
level: comment.user.level,
points: comment.user.points,
},
};
return NextResponse.json(formatted, { status: 201 });
}
+16
View File
@@ -0,0 +1,16 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET() {
const categories = await prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return NextResponse.json({ categories });
}
@@ -0,0 +1,84 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function PUT(req: Request, { params }: { params: { id: string; postId: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const postId = parseInt(params.postId);
const userId = parseInt(session.user.id);
const post = await prisma.forumPost.findUnique({ where: { id: postId } });
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (post.userId !== userId) {
return NextResponse.json({ error: "无权操作" }, { status: 403 });
}
const body = await req.json();
const { content } = body;
if (!content || !content.trim()) {
return NextResponse.json({ error: "回复内容不能为空" }, { status: 400 });
}
if (content.trim().length > 5000) {
return NextResponse.json({ error: "回复不能超过5000个字符" }, { status: 400 });
}
const updated = await prisma.forumPost.update({
where: { id: postId },
data: { content: content.trim() },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
});
return NextResponse.json({
id: updated.id,
content: updated.content,
isAnswer: updated.isAnswer,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
user: {
id: updated.user.id,
name: updated.user.name || updated.user.email.split("@")[0],
avatarUrl: updated.user.avatarUrl,
level: updated.user.level,
points: updated.user.points,
},
});
}
export async function DELETE(req: Request, { params }: { params: { id: string; postId: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const postId = parseInt(params.postId);
const userId = parseInt(session.user.id);
const post = await prisma.forumPost.findUnique({ where: { id: postId }, include: { topic: true } });
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (post.userId !== userId) {
return NextResponse.json({ error: "无权操作" }, { status: 403 });
}
await prisma.forumPost.delete({ where: { id: postId } });
await prisma.forumTopic.update({
where: { id: post.topicId },
data: {
replyCount: { decrement: 1 },
},
});
return NextResponse.json({ success: true });
}
@@ -0,0 +1,126 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
export async function GET(req: Request, { params }: { params: { id: string } }) {
const topicId = parseInt(params.id);
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const [posts, totalCount] = await Promise.all([
prisma.forumPost.findMany({
where: { topicId },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
orderBy: { createdAt: "asc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumPost.count({ where: { topicId } }),
]);
const formatted = posts.map((p) => ({
id: p.id,
content: p.content,
isAnswer: p.isAnswer,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
user: {
id: p.user.id,
name: p.user.name || p.user.email.split("@")[0],
avatarUrl: p.user.avatarUrl,
level: p.user.level,
points: p.user.points,
},
}));
return NextResponse.json({ posts: formatted, totalCount, page, pageSize });
}
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
const body = await req.json();
const { content } = body;
if (!content || !content.trim()) {
return NextResponse.json({ error: "回复内容不能为空" }, { status: 400 });
}
if (content.trim().length > 5000) {
return NextResponse.json({ error: "回复不能超过5000个字符" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({ where: { id: topicId } });
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.isLocked) {
return NextResponse.json({ error: "话题已锁定" }, { status: 400 });
}
const post = await prisma.$transaction(async (tx) => {
const newPost = await tx.forumPost.create({
data: {
topicId,
userId,
content: content.trim(),
},
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
});
const now = new Date();
await tx.forumTopic.update({
where: { id: topicId },
data: {
replyCount: { increment: 1 },
lastReplyAt: now,
updatedAt: now,
},
});
if (topic.userId !== userId) {
await tx.notification.create({
data: {
userId: topic.userId,
type: "TOPIC_REPLY",
title: `${session.user.name || "用户"} 回复了你的话题`,
content: content.trim().slice(0, 100),
link: `/community/topic/${topicId}`,
},
});
}
return newPost;
});
await rewardPoints(userId, "forum_post", `回复话题`, "topic", topicId);
return NextResponse.json(
{
id: post.id,
content: post.content,
isAnswer: post.isAnswer,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
user: {
id: post.user.id,
name: post.user.name || post.user.email.split("@")[0],
avatarUrl: post.user.avatarUrl,
level: post.user.level,
points: post.user.points,
},
},
{ status: 201 }
);
}
+113
View File
@@ -0,0 +1,113 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(req: Request, { params }: { params: { id: string } }) {
const topicId = parseInt(params.id);
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
await prisma.forumTopic.update({
where: { id: topicId },
data: { viewCount: { increment: 1 } },
});
return NextResponse.json({
topic: {
id: topic.id,
title: topic.title,
slug: topic.slug,
content: topic.content,
isPinned: topic.isPinned,
isLocked: topic.isLocked,
viewCount: topic.viewCount + 1,
replyCount: topic._count.posts,
lastReplyAt: topic.lastReplyAt,
createdAt: topic.createdAt,
updatedAt: topic.updatedAt,
category: topic.category,
user: {
id: topic.user.id,
name: topic.user.name || topic.user.email.split("@")[0],
avatarUrl: topic.user.avatarUrl,
level: topic.user.level,
points: topic.user.points,
},
},
});
}
export async function PUT(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
const topic = await prisma.forumTopic.findUnique({ where: { id: topicId } });
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.userId !== userId) {
return NextResponse.json({ error: "无权操作" }, { status: 403 });
}
if (topic.isLocked) {
return NextResponse.json({ error: "话题已锁定" }, { status: 400 });
}
const body = await req.json();
const { title, content } = body;
if (title && title.length > 100) {
return NextResponse.json({ error: "标题不能超过100个字符" }, { status: 400 });
}
if (content && content.length > 10000) {
return NextResponse.json({ error: "内容不能超过10000个字符" }, { status: 400 });
}
const updated = await prisma.forumTopic.update({
where: { id: topicId },
data: {
title: title?.trim(),
content: content?.trim(),
},
});
return NextResponse.json(updated);
}
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
const topic = await prisma.forumTopic.findUnique({ where: { id: topicId } });
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.userId !== userId) {
return NextResponse.json({ error: "无权操作" }, { status: 403 });
}
await prisma.forumTopic.delete({ where: { id: topicId } });
return NextResponse.json({ success: true });
}
+136
View File
@@ -0,0 +1,136 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const category = searchParams.get("category");
const search = searchParams.get("search");
const sort = searchParams.get("sort") || "latest";
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const where: any = {};
if (category) where.category = { slug: category };
if (search) {
where.title = { contains: search };
}
const orderBy: any = {};
if (sort === "latest") orderBy.createdAt = "desc";
else if (sort === "hot") orderBy.viewCount = "desc";
else if (sort === "active") orderBy.lastReplyAt = "desc";
else orderBy.createdAt = "desc";
const [topics, totalCount] = await Promise.all([
prisma.forumTopic.findMany({
where,
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where }),
]);
const formatted = topics.map((t) => ({
id: t.id,
title: t.title,
slug: t.slug,
isPinned: t.isPinned,
isLocked: t.isLocked,
viewCount: t.viewCount,
replyCount: t._count.posts,
lastReplyAt: t.lastReplyAt,
createdAt: t.createdAt,
category: t.category,
user: {
id: t.user.id,
name: t.user.name || t.user.email.split("@")[0],
avatarUrl: t.user.avatarUrl,
level: t.user.level,
points: t.user.points,
},
}));
return NextResponse.json({ topics: formatted, totalCount, page, pageSize });
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await req.json();
const { categorySlug, title, content } = body;
const userId = parseInt(session.user.id);
if (!categorySlug || !title || !content) {
return NextResponse.json({ error: "请填写完整信息" }, { status: 400 });
}
if (title.length > 100) {
return NextResponse.json({ error: "标题不能超过100个字符" }, { status: 400 });
}
if (content.length > 10000) {
return NextResponse.json({ error: "内容不能超过10000个字符" }, { status: 400 });
}
const category = await prisma.forumCategory.findUnique({
where: { slug: categorySlug },
});
if (!category) {
return NextResponse.json({ error: "板块不存在" }, { status: 404 });
}
const slug = `${title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]/g, "-")}-${Date.now()}`.slice(0, 120);
const topic = await prisma.$transaction(async (tx) => {
const newTopic = await tx.forumTopic.create({
data: {
categoryId: category.id,
userId,
title: title.trim(),
slug,
content: content.trim(),
},
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
category: { select: { name: true, slug: true, icon: true } },
},
});
await tx.forumCategory.update({
where: { id: category.id },
data: { topicCount: { increment: 1 } },
});
return newTopic;
});
await rewardPoints(userId, "forum_topic", `发布话题: ${title}`, "topic", topic.id);
return NextResponse.json(
{
id: topic.id,
title: topic.title,
slug: topic.slug,
category: topic.category,
user: {
id: topic.user.id,
name: topic.user.name || topic.user.email.split("@")[0],
avatarUrl: topic.user.avatarUrl,
level: topic.user.level,
points: topic.user.points,
},
},
{ status: 201 }
);
}
+54
View File
@@ -0,0 +1,54 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const [notifications, totalCount, unreadCount] = await Promise.all([
prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.notification.count({ where: { userId } }),
prisma.notification.count({ where: { userId, isRead: false } }),
]);
return NextResponse.json({ notifications, totalCount, unreadCount, page, pageSize });
}
export async function PUT(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const body = await req.json();
const { ids } = body;
if (ids && Array.isArray(ids)) {
await prisma.notification.updateMany({
where: { id: { in: ids }, userId },
data: { isRead: true },
});
} else {
await prisma.notification.updateMany({
where: { userId, isRead: false },
data: { isRead: true },
});
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const [topReviews, topStarsChange, allReviews] = await Promise.all([
prisma.skillReview.findMany({
where: { status: "PUBLISHED" },
include: {
skill: { select: { id: true, name: true, slug: true, description: true, githubStars: true } },
},
orderBy: { overall: "desc" },
take: 10,
}),
prisma.skill.findMany({
where: { githubStars: { gt: 0 }, starsChangeWeek: { not: 0 } },
select: { id: true, name: true, slug: true, githubStars: true, starsChangeDay: true, starsChangeWeek: true, starsChangeMonth: true },
orderBy: { starsChangeWeek: "desc" },
take: 10,
}),
prisma.skillReview.count({ where: { status: "PUBLISHED" } }),
]);
return NextResponse.json(
{
topByRating: topReviews.map((r) => ({
id: r.id,
overall: r.overall,
capability: r.capability,
devExp: r.devExp,
costLicense: r.costLicense,
community: r.community,
performance: r.performance,
skill: { id: r.skill.id, name: r.skill.name, slug: r.skill.slug, stars: r.skill.githubStars },
})),
topByStarsChange: topStarsChange.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
githubStars: s.githubStars,
starsChangeDay: s.starsChangeDay,
starsChangeWeek: s.starsChangeWeek,
starsChangeMonth: s.starsChangeMonth,
})),
totalReviews: allReviews,
},
{
headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" },
}
);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
@@ -3,6 +3,30 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(
_request: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const collectionId = parseInt(params.id);
const userId = parseInt(session.user.id);
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
include: { _count: { select: { items: true } } },
});
if (!collection || collection.userId !== userId) {
return NextResponse.json({ error: "收藏夹不存在或无权限" }, { status: 403 });
}
return NextResponse.json(collection);
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
+110 -95
View File
@@ -4,108 +4,123 @@ import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const type = searchParams.get("type") || "all";
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const where: Record<string, unknown> = { userId: parseInt(session.user.id) };
if (type === "tool") {
where.toolId = { not: null };
} else if (type === "skill") {
where.skillId = { not: null };
}
const [favorites, total] = await Promise.all([
prisma.favorite.findMany({
where,
include: {
tool: true,
skill: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.favorite.count({ where }),
]);
return NextResponse.json({ favorites, total, page, pageSize });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取收藏失败" }, { status: 500 });
}
const { searchParams } = new URL(req.url);
const type = searchParams.get("type") || "all";
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const where: any = { userId: parseInt(session.user.id) };
if (type === "tool") {
where.toolId = { not: null };
} else if (type === "skill") {
where.skillId = { not: null };
}
const [favorites, total] = await Promise.all([
prisma.favorite.findMany({
where,
include: {
tool: true,
skill: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.favorite.count({ where }),
]);
return NextResponse.json({ favorites, total, page, pageSize });
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await req.json();
const { toolId, skillId } = body;
const userId = parseInt(session.user.id);
if (!toolId && !skillId) {
return NextResponse.json(
{ error: "必须提供 toolId 或 skillId" },
{ status: 400 }
);
}
if (toolId) {
const tool = await prisma.tool.findUnique({ where: { id: toolId } });
if (!tool) return NextResponse.json({ error: "工具不存在" }, { status: 404 });
}
if (skillId) {
const skill = await prisma.skill.findUnique({ where: { id: skillId } });
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
}
const where: Record<string, unknown> = { userId };
if (toolId) where.toolId = toolId;
if (skillId) where.skillId = skillId;
const existing = await prisma.favorite.findFirst({ where });
if (existing) {
return NextResponse.json({ error: "已收藏" }, { status: 409 });
}
const fav = await prisma.favorite.create({
data: {
userId,
toolId: toolId || null,
skillId: skillId || null,
},
});
return NextResponse.json(fav, { status: 201 });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "添加收藏失败" }, { status: 500 });
}
const body = await req.json();
const { toolId, skillId } = body;
const userId = parseInt(session.user.id);
if (!toolId && !skillId) {
return NextResponse.json(
{ error: "必须提供 toolId 或 skillId" },
{ status: 400 }
);
}
if (toolId) {
const tool = await prisma.tool.findUnique({ where: { id: toolId } });
if (!tool) return NextResponse.json({ error: "工具不存在" }, { status: 404 });
}
if (skillId) {
const skill = await prisma.skill.findUnique({ where: { id: skillId } });
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
}
const where: any = { userId };
if (toolId) where.toolId = toolId;
if (skillId) where.skillId = skillId;
const existing = await prisma.favorite.findFirst({ where });
if (existing) {
return NextResponse.json({ error: "已收藏" }, { status: 409 });
}
const fav = await prisma.favorite.create({
data: {
userId,
toolId: toolId || null,
skillId: skillId || null,
},
});
return NextResponse.json(fav, { status: 201 });
}
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await req.json();
const { toolId, skillId } = body;
const userId = parseInt(session.user.id);
if (!toolId && !skillId) {
return NextResponse.json(
{ error: "必须提供 toolId 或 skillId" },
{ status: 400 }
);
}
const where: Record<string, unknown> = { userId };
if (toolId) where.toolId = toolId;
if (skillId) where.skillId = skillId;
const deleted = await prisma.favorite.deleteMany({ where });
return NextResponse.json({ success: true, deleted: deleted.count });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "取消收藏失败" }, { status: 500 });
}
const body = await req.json();
const { toolId, skillId } = body;
const userId = parseInt(session.user.id);
if (!toolId && !skillId) {
return NextResponse.json(
{ error: "必须提供 toolId 或 skillId" },
{ status: 400 }
);
}
const where: any = { userId };
if (toolId) where.toolId = toolId;
if (skillId) where.skillId = skillId;
const deleted = await prisma.favorite.deleteMany({ where });
return NextResponse.json({ success: true, deleted: deleted.count });
}
+76 -66
View File
@@ -4,75 +4,85 @@ import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const history = await prisma.browsingHistory.findMany({
where: { userId },
include: {
tool: {
select: {
id: true,
name: true,
slug: true,
description: true,
logoUrl: true,
pricingModel: true,
category: { select: { id: true, name: true, slug: true } },
},
},
skill: {
select: {
id: true,
name: true,
slug: true,
description: true,
logoUrl: true,
sourceType: true,
category: { select: { id: true, name: true, slug: true } },
},
},
},
orderBy: { viewedAt: "desc" },
take: 20,
});
return NextResponse.json({ history });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取历史记录失败" }, { status: 500 });
}
const userId = parseInt(session.user.id);
const history = await prisma.browsingHistory.findMany({
where: { userId },
include: {
tool: {
select: {
id: true,
name: true,
slug: true,
description: true,
logoUrl: true,
pricingModel: true,
category: { select: { id: true, name: true, slug: true } },
},
},
skill: {
select: {
id: true,
name: true,
slug: true,
description: true,
logoUrl: true,
sourceType: true,
category: { select: { id: true, name: true, slug: true } },
},
},
},
orderBy: { viewedAt: "desc" },
take: 20,
});
return NextResponse.json({ history });
}
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await request.json();
const { toolId, skillId } = body;
const userId = parseInt(session.user.id);
if (!toolId && !skillId) {
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
}
if (toolId) {
await prisma.browsingHistory.upsert({
where: { userId_toolId: { userId, toolId } },
update: { viewedAt: new Date() },
create: { userId, toolId },
});
}
if (skillId) {
await prisma.browsingHistory.upsert({
where: { userId_skillId: { userId, skillId } },
update: { viewedAt: new Date() },
create: { userId, skillId },
});
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "记录浏览历史失败" }, { status: 500 });
}
const body = await request.json();
const { toolId, skillId } = body;
const userId = parseInt(session.user.id);
if (!toolId && !skillId) {
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
}
if (toolId) {
await prisma.browsingHistory.upsert({
where: { userId_toolId: { userId, toolId } },
update: { viewedAt: new Date() },
create: { userId, toolId },
});
}
if (skillId) {
await prisma.browsingHistory.upsert({
where: { userId_skillId: { userId, skillId } },
update: { viewedAt: new Date() },
create: { userId, skillId },
});
}
return NextResponse.json({ success: true });
}
+39
View File
@@ -0,0 +1,39 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { getUserPointsHistory, getUserStats, getLevelInfo, getReasonLabel } from "@/lib/points-reward";
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const { searchParams } = new URL(req.url);
const type = searchParams.get("type");
if (type === "history") {
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const { history, total } = await getUserPointsHistory(userId, page, pageSize);
const formatted = history.map((h) => ({
id: h.id,
points: h.points,
reason: getReasonLabel(h.reason),
detail: h.detail,
createdAt: h.createdAt,
}));
return NextResponse.json({ history: formatted, total, page, pageSize });
}
if (type === "stats") {
const stats = await getUserStats(userId);
return NextResponse.json(stats);
}
const stats = await getUserStats(userId);
return NextResponse.json(stats);
}
+61 -44
View File
@@ -4,53 +4,70 @@ import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: parseInt(session.user.id) },
select: {
id: true,
email: true,
name: true,
bio: true,
avatarUrl: true,
role: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
}
return NextResponse.json({ id: user.id, email: user.email, name: user.name, bio: user.bio, avatarUrl: user.avatarUrl, createdAt: user.createdAt });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取用户信息失败" }, { status: 500 });
}
const user = await prisma.user.findUnique({
where: { id: parseInt(session.user.id) },
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
role: true,
},
});
if (!user) {
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
}
return NextResponse.json(user);
}
export async function PUT(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await request.json();
const { name, bio } = body;
if (name !== undefined && (typeof name !== "string" || name.length > 100)) {
return NextResponse.json({ error: "昵称格式不正确" }, { status: 400 });
}
if (bio !== undefined && (typeof bio !== "string" || bio.length > 500)) {
return NextResponse.json({ error: "简介不能超过500个字符" }, { status: 400 });
}
const user = await prisma.user.update({
where: { id: parseInt(session.user.id) },
data: { name: name || null, bio: bio ?? undefined },
select: {
id: true,
email: true,
name: true,
bio: true,
avatarUrl: true,
role: true,
createdAt: true,
},
});
return NextResponse.json(user);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "更新用户信息失败" }, { status: 500 });
}
const body = await request.json();
const { name } = body;
if (name !== undefined && (typeof name !== "string" || name.length > 100)) {
return NextResponse.json({ error: "昵称格式不正确" }, { status: 400 });
}
const user = await prisma.user.update({
where: { id: parseInt(session.user.id) },
data: { name: name || null },
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
role: true,
},
});
return NextResponse.json(user);
}
@@ -0,0 +1,216 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
category: { id: number; name: string; slug: string } | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
interface Props {
initialTools: Tool[];
initialTotal: number;
initialPage: number;
categoryId: number;
categoryName: string;
}
export function CategoryContent({ initialTools, initialTotal, initialPage, categoryId, categoryName }: Props) {
const router = useRouter();
const [tools, setTools] = useState<Tool[]>(initialTools);
const [total, setTotal] = useState(initialTotal);
const [page, setPage] = useState(initialPage);
const [loading, setLoading] = useState(false);
const [isInitial, setIsInitial] = useState(true);
const pageSize = 12;
const fetchTools = useCallback(async (p: number) => {
setLoading(true);
try {
const res = await fetch(
`/api/tools?categoryId=${categoryId}&page=${p}&pageSize=${pageSize}`
);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [categoryId]);
const handlePageChange = (newPage: number) => {
if (isInitial) setIsInitial(false);
setPage(newPage);
fetchTools(newPage);
};
const totalPages = Math.ceil(total / pageSize);
if (!isInitial && loading) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/30" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/20" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
if (tools.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-4 text-sm text-muted-foreground">该分类暂无工具</p>
<Button variant="ghost" size="sm" className="mt-2 text-muted-foreground" onClick={() => router.push("/tools")}>
浏览全部工具
</Button>
</div>
);
}
return (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool, i) => (
<Card
key={tool.id}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 60}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{tool.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/50">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount}
</span>
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
className="gap-1.5 rounded-lg"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
上一页
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
page === pageNum
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{pageNum}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
className="gap-1.5 rounded-lg"
>
下一页
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</Button>
</div>
)}
</>
);
}
+52 -230
View File
@@ -1,42 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface Category {
id: number;
name: string;
slug: string;
children: Category[];
}
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
category: { id: number; name: string; slug: string } | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import { CategoryContent } from "./CategoryContent";
const categoryAccent: Record<string, { bg: string; text: string; border: string }> = {
"文本生成": { bg: "bg-blue-50", text: "text-blue-700", border: "border-blue-200/60" },
@@ -51,64 +16,47 @@ const categoryAccent: Record<string, { bg: string; text: string; border: string
"内容检测": { bg: "bg-indigo-50", text: "text-indigo-700", border: "border-indigo-200/60" },
};
export default function CategoryPage() {
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
}
const [categoryName, setCategoryName] = useState("");
const [categoryId, setCategoryId] = useState<number | null>(null);
const [tools, setTools] = useState<Tool[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const category = await prisma.category.findUnique({
where: { slug },
select: { name: true },
});
if (!category) {
return { title: "分类不存在" };
}
return {
title: `${category.name} - AI 工具分类`,
description: `浏览${category.name}分类下的所有 AI 工具`,
};
}
export default async function CategoryPage({ params, searchParams }: Props) {
const { slug } = await params;
const { page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1"));
const pageSize = 12;
useEffect(() => {
fetch("/api/categories")
.then((r) => r.json())
.then((cats: Category[]) => {
const find = (list: Category[]): Category | undefined => {
for (const c of list) {
if (c.slug === slug) return c;
const found = find(c.children);
if (found) return found;
}
return undefined;
};
const cat = find(cats);
if (cat) {
setCategoryName(cat.name);
setCategoryId(cat.id);
}
})
.catch(() => {});
}, [slug]);
const category = await prisma.category.findUnique({ where: { slug } });
if (!category) notFound();
const fetchTools = useCallback(async () => {
if (!categoryId) return;
setLoading(true);
try {
const res = await fetch(
`/api/tools?categoryId=${categoryId}&page=${page}&pageSize=${pageSize}`
);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [categoryId, page]);
const [tools, total] = await Promise.all([
prisma.tool.findMany({
where: { categoryId: category.id, status: "published" },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { viewCount: "desc" },
include: { category: { select: { id: true, name: true, slug: true } } },
}),
prisma.tool.count({ where: { categoryId: category.id, status: "published" } }),
]);
useEffect(() => {
fetchTools();
}, [fetchTools]);
const totalPages = Math.ceil(total / pageSize);
const accent = categoryAccent[categoryName] || { bg: "bg-primary/10", text: "text-primary", border: "border-primary/20" };
const accent = categoryAccent[category.name] || { bg: "bg-primary/10", text: "text-primary", border: "border-primary/20" };
return (
<div className="relative">
@@ -119,11 +67,11 @@ export default function CategoryPage() {
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<a href="/" className="transition-colors hover:text-foreground">首页</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<button onClick={() => router.push("/tools")} className="transition-colors hover:text-foreground">全部工具</button>
<a href="/tools" className="transition-colors hover:text-foreground">全部工具</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">{categoryName || "分类"}</span>
<span className="text-foreground">{category.name}</span>
</nav>
<div className="mb-10">
@@ -134,7 +82,7 @@ export default function CategoryPage() {
<span className={`text-sm font-medium ${accent.text}`}>分类</span>
</div>
<h1 className="text-3xl font-bold tracking-tight">
{categoryName || "分类"}
{category.name}
</h1>
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-muted-foreground">共</span>
@@ -143,140 +91,14 @@ export default function CategoryPage() {
</div>
</div>
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/30" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/20" />
</div>
</CardContent>
</Card>
))}
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-4 text-sm text-muted-foreground">该分类暂无工具</p>
<Button variant="ghost" size="sm" className="mt-2 text-muted-foreground" onClick={() => router.push("/tools")}>
浏览全部工具
</Button>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool, i) => (
<Card
key={tool.id}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 60}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{tool.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/50">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount}
</span>
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="gap-1.5 rounded-lg"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
上一页
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setPage(pageNum)}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
page === pageNum
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{pageNum}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
className="gap-1.5 rounded-lg"
>
下一页
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</Button>
</div>
)}
</>
)}
<CategoryContent
initialTools={tools}
initialTotal={total}
initialPage={page}
categoryId={category.id}
categoryName={category.name}
/>
</div>
</div>
);
}
}
+133
View File
@@ -0,0 +1,133 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
interface Props {
params: { category: string };
searchParams: { sort?: string; page?: string };
}
export default async function CategoryPage({ params, searchParams }: Props) {
const category = await prisma.forumCategory.findUnique({
where: { slug: params.category },
});
if (!category) notFound();
const sort = searchParams.sort || "latest";
const page = parseInt(searchParams.page || "1", 10);
const pageSize = 20;
const orderBy: any = {};
if (sort === "latest") orderBy.createdAt = "desc";
else if (sort === "hot") orderBy.viewCount = "desc";
else if (sort === "active") orderBy.lastReplyAt = "desc";
else orderBy.createdAt = "desc";
const [topics, totalCount] = await Promise.all([
prisma.forumTopic.findMany({
where: { categoryId: category.id },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where: { categoryId: category.id } }),
]);
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<a href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</a>
</div>
<div className="mb-6">
<h1 className="text-2xl font-bold">{category.name}</h1>
{category.description && (
<p className="mt-1 text-sm text-muted-foreground">{category.description}</p>
)}
</div>
<div className="flex items-center gap-2 mb-6">
{["latest", "hot", "active"].map((s) => (
<a
key={s}
href={`/community/${category.slug}?sort=${s}`}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
sort === s ? "bg-primary text-white" : "bg-white border border-border/40 text-muted-foreground hover:border-border/60"
}`}
>
{s === "latest" ? "最新" : s === "hot" ? "热门" : "活跃"}
</a>
))}
</div>
<div className="space-y-3">
{topics.map((topic) => {
const initial = topic.user.name?.charAt(0).toUpperCase() || "?";
return (
<a
key={topic.id}
href={`/community/topic/${topic.id}`}
className="flex items-start gap-3 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 hover:shadow-sm block"
>
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-9 w-9 rounded-full border border-border/40 object-cover flex-shrink-0" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary flex-shrink-0">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{topic.isPinned && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">置顶</span>
)}
{topic.isLocked && (
<span className="text-[10px] font-medium text-muted-foreground bg-muted/30 px-1.5 py-0.5 rounded-full">已锁定</span>
)}
<h3 className="text-sm font-medium hover:text-primary transition-colors truncate">
{topic.title}
</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._count.posts}回复</span>
<span>{topic.viewCount}浏览</span>
</div>
</div>
</a>
);
})}
</div>
{totalCount > pageSize && (
<div className="flex justify-center gap-2 mt-6">
{Array.from({ length: Math.ceil(totalCount / pageSize) }, (_, i) => i + 1).map((p) => (
<a
key={p}
href={`/community/${category.slug}?sort=${sort}&page=${p}`}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
page === p
? "bg-primary text-white"
: "bg-white border border-border/40 text-muted-foreground hover:border-border/60"
}`}
>
{p}
</a>
))}
</div>
)}
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const dynamic = "force-dynamic";
async function getHotTopics() {
return prisma.forumTopic.findMany({
where: {},
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
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="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>
</div>
<div>
<h2 className="text-lg font-semibold mb-4">最新话题</h2>
<div className="space-y-3">
{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}`)}
>
{topic.user.avatarUrl ? (
<img src={topic.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">
<div className="flex items-center gap-2">
{topic.isPinned && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">置顶</span>
)}
<h3 className="text-sm font-medium hover:text-primary transition-colors truncate">
{topic.title}
</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.category.name}</span>
<span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
<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>
);
})}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const dynamic = "force-dynamic";
interface Props {
params: { id: string };
}
export default async function TopicPage({ params }: Props) {
const session = await getServerSession(authOptions);
const topicId = parseInt(params.id);
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
});
if (!topic) notFound();
await prisma.forumTopic.update({
where: { id: topicId },
data: { viewCount: { increment: 1 } },
});
const posts = await prisma.forumPost.findMany({
where: { topicId },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
orderBy: { createdAt: "asc" },
take: 50,
});
const currentUserId = session?.user?.id ? parseInt(session.user.id) : null;
const isAuthor = currentUserId === topic.userId;
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<a href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</a>
</div>
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="p-6 border-b border-border/40">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-0.5 text-[10px] font-medium bg-primary/10 text-primary rounded-full">
{topic.category.name}
</span>
{topic.isPinned && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-amber-50 text-amber-700 rounded-full">
置顶
</span>
)}
{topic.isLocked && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-muted/30 text-muted-foreground rounded-full">
已锁定
</span>
)}
</div>
<h1 className="text-xl font-bold mb-4">{topic.title}</h1>
<div className="flex items-center gap-3 mb-6">
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-10 w-10 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{topic.user.name?.charAt(0).toUpperCase() || "?"}
</div>
)}
<div>
<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" })}
</p>
</div>
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.viewCount + 1}浏览</span>
<span>{topic._count.posts}回复</span>
</div>
</div>
<div className="prose prose-sm max-w-none text-muted-foreground/80 leading-relaxed whitespace-pre-wrap">
{topic.content}
</div>
</div>
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">回复 ({posts.length})</h2>
<div className="space-y-4">
{posts.map((post) => {
const initial = post.user.name?.charAt(0).toUpperCase() || "?";
const isTopicAuthor = post.userId === topic.userId;
return (
<div key={post.id} className="rounded-xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center gap-3 mb-3">
{post.user.avatarUrl ? (
<img src={post.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>
<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" })}
</p>
</div>
{isTopicAuthor && (
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-primary/10 text-primary rounded-full">
楼主
</span>
)}
</div>
<div className="text-sm text-muted-foreground/80 leading-relaxed whitespace-pre-wrap">
{post.content}
</div>
</div>
);
})}
</div>
</div>
{!topic.isLocked && (
<div className="mt-8">
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const content = formData.get("content") as string;
if (!content.trim()) return;
const res = await fetch(`/api/forum/topics/${topicId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (res.ok) {
window.location.reload();
}
}}
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-3">发表回复</h3>
<textarea
name="content"
placeholder="写下你的回复..."
rows={4}
maxLength={5000}
className="w-full resize-none rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors placeholder:text-muted-foreground/40 mb-3"
/>
<div className="flex justify-end">
<button
type="submit"
className="rounded-lg bg-primary text-white text-sm px-5 py-2 hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
发表回复
</button>
</div>
</form>
</div>
)}
{topic.isLocked && (
<div className="mt-8 text-center py-6 rounded-xl border border-border/40 bg-muted/20">
<p className="text-sm text-muted-foreground">此话题已锁定,无法回复</p>
</div>
)}
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4 text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-red-50 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-foreground">页面加载出错</h2>
<p className="text-sm text-muted-foreground">{error.message || "请稍后重试"}</p>
<button
onClick={reset}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
重新加载
</button>
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground text-sm">加载中...</p>
</div>
</div>
)
}
+19 -139
View File
@@ -1,153 +1,33 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginForm } from "@/components/common/LoginForm";
export default function LoginPage() {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
if (mode === "register") {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name: name || undefined }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "注册失败");
}
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) throw new Error("自动登录失败,请手动登录");
router.push("/");
router.refresh();
} else {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) throw new Error("邮箱或密码错误");
router.push("/");
router.refresh();
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md space-y-6 rounded-2xl border border-border/60 bg-white p-8 shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
<div className="text-center">
<h1 className="text-2xl font-bold">
{mode === "login" ? "欢迎回来" : "创建账号"}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{mode === "login" ? "登录以使用收藏等功能" : "注册后即可收藏喜欢的工具和技能"}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{mode === "register" && (
<input
type="text"
placeholder="昵称(选填)"
value={name}
onChange={(e) => setName(e.target.value)}
className="input-field !pl-3 !bg-white !border-border/70"
/>
)}
<input
type="email"
placeholder="邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="input-field !pl-3 !bg-white !border-border/70"
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="input-field !pl-3 !bg-white !border-border/70"
/>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 shadow-sm shadow-primary/20"
>
{loading
? "处理中..."
: mode === "login"
? "登录"
: "注册"}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/40" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-muted-foreground">或</span>
</div>
</div>
<button
type="button"
onClick={() => signIn("github", { callbackUrl: "/" })}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-border/60 bg-white text-sm font-medium text-foreground hover:bg-muted/30 hover:shadow-sm transition-all"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
使用 GitHub 登录
</button>
<p className="text-sm text-center text-muted-foreground">
{mode === "login" ? "还没有账号?" : "已有账号?"}
<button
type="button"
onClick={() => {
setMode(mode === "login" ? "register" : "login");
setError("");
}}
className="ml-1 text-primary hover:underline font-medium"
>
{mode === "login" ? "去注册" : "去登录"}
</button>
</p>
<LoginForm
onSuccess={() => {
router.push("/");
router.refresh();
}}
socialLogin={
<button
type="button"
onClick={() => signIn("github", { callbackUrl: "/" })}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-border/60 bg-white text-sm font-medium text-foreground hover:bg-muted/30 hover:shadow-sm transition-all"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
使用 GitHub 登录
</button>
}
/>
</div>
</div>
);
+19
View File
@@ -0,0 +1,19 @@
import Link from "next/link"
export default function NotFound() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4 text-center max-w-md">
<div className="text-7xl font-bold gradient-text">404</div>
<h2 className="text-lg font-semibold text-foreground">页面未找到</h2>
<p className="text-sm text-muted-foreground">您访问的页面不存在或已被移除</p>
<Link
href="/"
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
返回首页
</Link>
</div>
</div>
)
}
+49 -391
View File
@@ -1,400 +1,58 @@
"use client";
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
import HomePageClient from "./HomePageClient";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
export async function generateMetadata(): Promise<Metadata> {
return {
title: "追光AI - 汇聚全球优质AI工具",
description: "发现、探索、使用 — 让人工智能触手可及。浏览全球优质AI工具,按分类查找最适合你的AI应用。",
openGraph: {
title: "追光AI - 汇聚全球优质AI工具",
description: "发现、探索、使用 — 让人工智能触手可及。浏览全球优质AI工具,按分类查找最适合你的AI应用。",
type: "website",
},
};
}
interface FlatCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
toolCount: number;
tools: Tool[];
}
interface ParentCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
paid: "bg-amber-50 text-amber-700 border border-amber-200",
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
};
export default function HomePage() {
const router = useRouter();
const [categories, setCategories] = useState<FlatCategory[]>([]);
const [parentCategories, setParentCategories] = useState<ParentCategory[]>([]);
const [totalTools, setTotalTools] = useState(0);
const [totalSkills, setTotalSkills] = useState(0);
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
fetch("/api/homepage?type=tools")
.then((r) => r.json())
.then((data) => {
setCategories(data.categories || []);
setParentCategories(data.parentCategories || []);
setTotalTools(data.totalTools || 0);
setTotalSkills(data.totalSkills || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
export default async function HomePage() {
const [allCategories, totalTools, totalSkills] = await Promise.all([
prisma.category.findMany({
include: {
parent: { select: { id: true, name: true, slug: true, icon: true } },
_count: { select: { tools: { where: { status: "published" } } } },
tools: { where: { status: "published" }, take: 8, orderBy: { viewCount: "desc" } },
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
orderBy: { sortOrder: "asc" },
}),
prisma.tool.count({ where: { status: "published" } }),
prisma.skill.count({ where: { status: "published" } }),
]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const flatCategories = allCategories.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
parentId: cat.parentId,
toolCount: cat._count.tools,
tools: cat.tools,
}));
const handleSearch = () => {
if (search.trim()) {
router.push(`/tools?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
if (loading) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索工具..."
className="input-field-compact"
/>
</div>
<div className="h-px bg-border/40 my-2" />
{parentCategories.map((p) => {
const isActive = activeSection === `cat-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.toolCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.toolCount, 0);
const totalCount = ownCount + childCount;
return (
<button
key={p.id}
onClick={() => scrollToSection(`cat-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-primary/10 font-semibold text-primary shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-primary/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalTools} 个工具
</p>
</div>
</nav>
);
const parentCategories = allCategories
.filter((cat) => cat.parentId === null)
.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
}));
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI工具..."
className="input-field !pl-10 !h-10 !rounded-xl"
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="AI"
highlightColor="blue"
starStyle="cosmos"
stats={[
{ label: "AI工具", value: totalTools, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="汇聚全球优质AI工具,赋能每一个创新者"
subSlogan="发现、探索、使用 — 让人工智能触手可及"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.toolCount, 0);
const allTools = allCatsInGroup.flatMap((c) => c.tools);
const displayTools = activeTab === "all"
? allTools
: children.find((c) => c.slug === activeTab)?.tools || ownCat?.tools || [];
const maxDisplay = 8;
const visibleTools = displayTools.slice(0, maxDisplay);
const hasMore = displayTools.length > maxDisplay;
return (
<section
key={`cat-${parent.slug}`}
id={`cat-${parent.slug}`}
ref={(el) => { sectionRefs.current[`cat-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold text-primary tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.toolCount}</span>
</button>
))}
</div>
)}
{visibleTools.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleTools.map((tool) => (
<Card
key={tool.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/30 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-xs font-semibold text-primary transition-all duration-300 group-hover:scale-105 ${tool.logoUrl ? 'hidden' : ''}`}>
{tool.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{tool.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/categories/${cat}`);
}}
>
查看全部 {displayTools.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
<HomePageClient
flatCategories={flatCategories}
parentCategories={parentCategories}
totalTools={totalTools}
totalSkills={totalSkills}
/>
);
}
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { useState, useCallback } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
interface ReviewItem {
id: number;
overall: number;
capability: number;
devExp: number;
costLicense: number;
community: number;
performance: number;
reviews?: Record<string, { score: number; summary: string; detail: string }>;
skill: {
id: number;
name: string;
slug: string;
description: string;
sourceUrl: string;
category: { name: string; slug: string } | null;
};
reviewedAt: string;
}
const dimLabels: Record<string, string> = {
capability: "能力",
devExp: "体验",
costLicense: "成本",
community: "社区",
performance: "性能",
};
function getScoreColor(score: number): string {
if (score >= 4) return "text-emerald-700";
if (score >= 3) return "text-amber-700";
return "text-red-600";
}
function getScoreBg(score: number): string {
if (score >= 4) return "bg-emerald-50";
if (score >= 3) return "bg-amber-50";
return "bg-red-50";
}
function getOverallLabel(score: number): string {
if (score >= 4.5) return "卓越";
if (score >= 4) return "优秀";
if (score >= 3.5) return "良好";
if (score >= 3) return "中等";
return "一般";
}
function MiniRadar({ scores }: { scores: number[] }) {
const size = 80;
const cx = size / 2;
const cy = size / 2;
const r = 30;
const angles = scores.map((_, i) => (Math.PI * 2 * i) / scores.length - Math.PI / 2);
const points = scores.map((s, i) => {
const val = (s / 5) * r;
return `${cx + val * Math.cos(angles[i])},${cy + val * Math.sin(angles[i])}`;
}).join(" ");
const gridRings = [1, 2, 3, 4, 5].map((level) => {
const gridPoints = angles.map((a) => {
const val = (level / 5) * r;
return `${cx + val * Math.cos(a)},${cy + val * Math.sin(a)}`;
}).join(" ");
return <polygon key={level} points={gridPoints} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />;
});
const axisLines = angles.map((a, i) => (
<line key={i} x1={cx} y1={cy} x2={cx + r * Math.cos(a)} y2={cy + r * Math.sin(a)} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
));
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{gridRings}
{axisLines}
<polygon points={points} fill="rgba(139,92,246,0.15)" stroke="rgba(139,92,246,0.7)" strokeWidth="1.5" />
{scores.map((s, i) => (
<circle key={i} cx={cx + (s / 5) * r * Math.cos(angles[i])} cy={cy + (s / 5) * r * Math.sin(angles[i])} r="2.5" fill={s >= 4 ? "#10b981" : s >= 3 ? "#f59e0b" : "#ef4444"} />
))}
</svg>
);
}
function ReviewCard({ review }: { review: ReviewItem }) {
const scores = [review.capability, review.devExp, review.costLicense, review.community, review.performance];
const reviewEntries = review.reviews
? (Object.entries(review.reviews).filter(
([, v]) => v && typeof v === "object" && "score" in v
) as [string, { score: number; summary: string; detail: string }][])
: [];
return (
<Link href={`/skills/${review.skill.slug}`} className="block group">
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] transition-all duration-200 hover:shadow-md hover:border-primary/30 h-full">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate">
{review.skill.name}
</h3>
{review.skill.category && (
<span className="inline-block mt-1 text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded-md">
{review.skill.category.name}
</span>
)}
</div>
<div className={`flex-shrink-0 flex flex-col items-center justify-center rounded-xl px-3 py-2 ${getScoreBg(review.overall)}`}>
<span className={`text-xl font-bold ${getScoreColor(review.overall)}`}>
{review.overall}
</span>
<span className="text-[10px] text-muted-foreground">{getOverallLabel(review.overall)}</span>
</div>
</div>
<div className="flex items-center gap-4 mb-4">
<MiniRadar scores={scores} />
<div className="flex-1 grid grid-cols-5 gap-1">
{scores.map((s, i) => (
<div key={i} className="flex flex-col items-center">
<span className="text-[10px] text-muted-foreground mb-0.5">{dimLabels[Object.keys(dimLabels)[i]]}</span>
<div className="w-full bg-muted/50 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full ${s >= 4 ? "bg-emerald-500" : s >= 3 ? "bg-amber-500" : "bg-red-500"}`}
style={{ width: `${(s / 5) * 100}%` }}
/>
</div>
<span className="text-[10px] font-medium text-foreground/70 mt-0.5">{s}</span>
</div>
))}
</div>
</div>
{reviewEntries.length > 0 && (
<div className="space-y-1.5">
{reviewEntries.slice(0, 3).map(([key, dim]) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground w-10 flex-shrink-0">{dimLabels[key]}</span>
<span className="text-foreground/80 truncate">{dim.summary}</span>
</div>
))}
</div>
)}
{review.skill.description && (
<p className="mt-3 text-xs text-muted-foreground/70 line-clamp-2">
{review.skill.description}
</p>
)}
</CardContent>
</Card>
</Link>
);
}
interface Props {
initialReviews: ReviewItem[];
initialTotal: number;
}
export function ReviewsList({ initialReviews, initialTotal }: Props) {
const [reviews, setReviews] = useState<ReviewItem[]>(initialReviews);
const [total, setTotal] = useState(initialTotal);
const [loading, setLoading] = useState(false);
const [isInitial, setIsInitial] = useState(true);
const [page, setPage] = useState(1);
const pageSize = 12;
const fetchReviews = useCallback(async (p: number) => {
setLoading(true);
try {
const res = await fetch(`/api/skill-reviews?page=${p}&pageSize=${pageSize}`);
if (res.ok) {
const data = await res.json();
setReviews(data.reviews || []);
setTotal(data.total || 0);
}
} catch {}
setLoading(false);
}, []);
const handlePageChange = (newPage: number) => {
if (isInitial) setIsInitial(false);
setPage(newPage);
fetchReviews(newPage);
};
const totalPages = Math.ceil(total / pageSize);
if (!isInitial && loading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/60 bg-white animate-pulse">
<CardContent className="p-5">
<div className="h-5 bg-muted/50 rounded w-2/3 mb-3" />
<div className="h-3 bg-muted/50 rounded w-1/3 mb-4" />
<div className="h-20 bg-muted/30 rounded" />
</CardContent>
</Card>
))}
</div>
);
}
if (reviews.length === 0) {
return (
<div className="text-center py-20">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto text-muted-foreground/40 mb-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p className="text-muted-foreground">暂无评测数据</p>
<p className="text-xs text-muted-foreground/60 mt-1">评测正在生成中,请稍后再来</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<button onClick={() => handlePageChange(Math.max(1, page - 1))} disabled={page === 1} className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors">上一页</button>
<span className="text-sm text-muted-foreground px-2">{page} / {totalPages}</span>
<button onClick={() => handlePageChange(Math.min(totalPages, page + 1))} disabled={page === totalPages} className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors">下一页</button>
</div>
)}
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
function SidebarRankings() {
interface RankingItem {
id: number;
overall?: number;
skill?: { name: string; slug: string; stars?: number };
name?: string;
slug?: string;
githubStars?: number;
starsChangeWeek?: number;
starsChangeDay?: number;
}
const [rankings, setRankings] = useState<{
topByRating: RankingItem[];
topByStarsChange: RankingItem[];
totalReviews: number;
} | null>(null);
useEffect(() => {
fetch("/api/skill-reviews/rankings")
.then((r) => r.json())
.then(setRankings)
.catch(() => {});
}, []);
if (!rankings) {
return (
<div className="space-y-4">
<Card className="border-border/60 bg-white animate-pulse"><CardContent className="p-4"><div className="h-3 bg-muted/50 rounded w-2/3 mb-2" /><div className="h-16 bg-muted/30 rounded" /></CardContent></Card>
<Card className="border-border/60 bg-white animate-pulse"><CardContent className="p-4"><div className="h-3 bg-muted/50 rounded w-2/3 mb-2" /><div className="h-16 bg-muted/30 rounded" /></CardContent></Card>
</div>
);
}
return (
<div className="space-y-5">
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2 mb-3">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-500">
<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>
评测分榜 TOP10
</h3>
<div className="space-y-2">
{rankings.topByRating.slice(0, 10).map((item, i) => (
<Link key={item.id} href={`/skills/${item.skill?.slug || ""}`} className="flex items-center gap-2 hover:bg-accent/50 rounded-md px-1 py-1 transition-colors group">
<span className={`flex-shrink-0 w-5 h-5 flex items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-amber-100 text-amber-700" : "bg-muted/50 text-muted-foreground"}`}>{i + 1}</span>
<span className="text-xs text-foreground/80 truncate flex-1 group-hover:text-primary transition-colors">{item.skill?.name}</span>
<span className="text-xs font-semibold text-violet-600">{item.overall}</span>
</Link>
))}
</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2 mb-3">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-500">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</svg>
Stars涨幅榜 TOP10
</h3>
<div className="space-y-2">
{rankings.topByStarsChange.slice(0, 10).map((item, i) => (
<Link key={item.id} href={`/skills/${item.slug || ""}`} className="flex items-center gap-2 hover:bg-accent/50 rounded-md px-1 py-1 transition-colors group">
<span className={`flex-shrink-0 w-5 h-5 flex items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-emerald-100 text-emerald-700" : "bg-muted/50 text-muted-foreground"}`}>{i + 1}</span>
<span className="text-xs text-foreground/80 truncate flex-1 group-hover:text-primary transition-colors">{item.name}</span>
<span className="text-[10px] text-emerald-600 font-medium">{(item.starsChangeWeek && item.starsChangeWeek > 0 ? "+" : "")}{item.starsChangeWeek || 0}</span>
</Link>
))}
</div>
</CardContent>
</Card>
</div>
);
}
export { SidebarRankings };
+49 -244
View File
@@ -1,191 +1,50 @@
"use client";
import { prisma } from "@/lib/prisma";
import { Metadata } from "next";
import { ReviewsList } from "./ReviewsList";
import { SidebarRankings } from "./SidebarRankings";
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface ReviewItem {
id: number;
overall: number;
capability: number;
devExp: number;
costLicense: number;
community: number;
performance: number;
reviews?: Record<string, { score: number; summary: string; detail: string }>;
skill: {
id: number;
name: string;
slug: string;
description: string;
sourceUrl: string;
category: { name: string; slug: string } | null;
};
reviewedAt: string;
}
const dimLabels: Record<string, string> = {
capability: "能力",
devExp: "体验",
costLicense: "成本",
community: "社区",
performance: "性能",
export const metadata: Metadata = {
title: "AI评测",
description: "基于五维模型的AI技能深度评测,涵盖场景能力、开发体验、成本许可、社区健康、性能稳定",
};
function getScoreColor(score: number): string {
if (score >= 4) return "text-emerald-700";
if (score >= 3) return "text-amber-700";
return "text-red-600";
}
function getScoreBg(score: number): string {
if (score >= 4) return "bg-emerald-50";
if (score >= 3) return "bg-amber-50";
return "bg-red-50";
}
function getOverallLabel(score: number): string {
if (score >= 4.5) return "卓越";
if (score >= 4) return "优秀";
if (score >= 3.5) return "良好";
if (score >= 3) return "中等";
return "一般";
}
function MiniRadar({ scores }: { scores: number[] }) {
const size = 80;
const cx = size / 2;
const cy = size / 2;
const r = 30;
const angles = scores.map((_, i) => (Math.PI * 2 * i) / scores.length - Math.PI / 2);
const points = scores.map((s, i) => {
const val = (s / 5) * r;
return `${cx + val * Math.cos(angles[i])},${cy + val * Math.sin(angles[i])}`;
}).join(" ");
const gridRings = [1, 2, 3, 4, 5].map((level) => {
const gridPoints = angles.map((a) => {
const val = (level / 5) * r;
return `${cx + val * Math.cos(a)},${cy + val * Math.sin(a)}`;
}).join(" ");
return <polygon key={level} points={gridPoints} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />;
});
const axisLines = angles.map((a, i) => (
<line key={i} x1={cx} y1={cy} x2={cx + r * Math.cos(a)} y2={cy + r * Math.sin(a)} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
));
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{gridRings}
{axisLines}
<polygon points={points} fill="rgba(139,92,246,0.15)" stroke="rgba(139,92,246,0.7)" strokeWidth="1.5" />
{scores.map((s, i) => (
<circle key={i} cx={cx + (s / 5) * r * Math.cos(angles[i])} cy={cy + (s / 5) * r * Math.sin(angles[i])} r="2.5" fill={s >= 4 ? "#10b981" : s >= 3 ? "#f59e0b" : "#ef4444"} />
))}
</svg>
);
}
function ReviewCard({ review }: { review: ReviewItem }) {
const scores = [review.capability, review.devExp, review.costLicense, review.community, review.performance];
const reviewEntries = review.reviews
? (Object.entries(review.reviews).filter(
([, v]) => v && typeof v === "object" && "score" in v
) as [string, { score: number; summary: string; detail: string }][])
: [];
return (
<Link href={`/skills/${review.skill.slug}`} className="block group">
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] transition-all duration-200 hover:shadow-md hover:border-primary/30 h-full">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate">
{review.skill.name}
</h3>
{review.skill.category && (
<span className="inline-block mt-1 text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded-md">
{review.skill.category.name}
</span>
)}
</div>
<div className={`flex-shrink-0 flex flex-col items-center justify-center rounded-xl px-3 py-2 ${getScoreBg(review.overall)}`}>
<span className={`text-xl font-bold ${getScoreColor(review.overall)}`}>
{review.overall}
</span>
<span className="text-[10px] text-muted-foreground">{getOverallLabel(review.overall)}</span>
</div>
</div>
<div className="flex items-center gap-4 mb-4">
<MiniRadar scores={scores} />
<div className="flex-1 grid grid-cols-5 gap-1">
{scores.map((s, i) => (
<div key={i} className="flex flex-col items-center">
<span className="text-[10px] text-muted-foreground mb-0.5">{dimLabels[Object.keys(dimLabels)[i]]}</span>
<div className="w-full bg-muted/50 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full ${s >= 4 ? "bg-emerald-500" : s >= 3 ? "bg-amber-500" : "bg-red-500"}`}
style={{ width: `${(s / 5) * 100}%` }}
/>
</div>
<span className="text-[10px] font-medium text-foreground/70 mt-0.5">{s}</span>
</div>
))}
</div>
</div>
{reviewEntries.length > 0 && (
<div className="space-y-1.5">
{reviewEntries.slice(0, 3).map(([key, dim]) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground w-10 flex-shrink-0">{dimLabels[key]}</span>
<span className="text-foreground/80 truncate">{dim.summary}</span>
</div>
))}
</div>
)}
{review.skill.description && (
<p className="mt-3 text-xs text-muted-foreground/70 line-clamp-2">
{review.skill.description}
</p>
)}
</CardContent>
</Card>
</Link>
);
}
function ReviewsContent() {
const searchParams = useSearchParams();
const [reviews, setReviews] = useState<ReviewItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
export default async function ReviewsPage() {
const pageSize = 12;
const fetchReviews = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/skill-reviews?page=${page}&pageSize=${pageSize}`);
if (res.ok) {
const data = await res.json();
setReviews(data.reviews || []);
setTotal(data.total || 0);
}
} catch {}
setLoading(false);
}, [page]);
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" } }),
]);
useEffect(() => {
fetchReviews();
}, [fetchReviews]);
const totalPages = Math.ceil(total / pageSize);
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(),
}));
return (
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 py-8">
@@ -208,69 +67,15 @@ function ReviewsContent() {
</div>
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/60 bg-white animate-pulse">
<CardContent className="p-5">
<div className="h-5 bg-muted/50 rounded w-2/3 mb-3" />
<div className="h-3 bg-muted/50 rounded w-1/3 mb-4" />
<div className="h-20 bg-muted/30 rounded" />
</CardContent>
</Card>
))}
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1 min-w-0">
<ReviewsList initialReviews={serializedReviews} initialTotal={total} />
</div>
) : reviews.length === 0 ? (
<div className="text-center py-20">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto text-muted-foreground/40 mb-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p className="text-muted-foreground">暂无评测数据</p>
<p className="text-xs text-muted-foreground/60 mt-1">评测正在生成中,请稍后再来</p>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
上一页
</button>
<span className="text-sm text-muted-foreground px-2">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
下一页
</button>
</div>
)}
</>
)}
<div className="lg:w-64 flex-shrink-0">
<SidebarRankings />
</div>
</div>
</div>
);
}
export default function ReviewsPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">加载中...</p>
</div>
}>
<ReviewsContent />
</Suspense>
);
}
}
+413
View File
@@ -0,0 +1,413 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Skill {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceUrl: string;
sourceType: string;
rating: number;
viewCount: number;
}
interface FlatSkillCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
skillCount: number;
skills: Skill[];
}
interface ParentSkillCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
interface SkillsPageClientProps {
flatCategories: FlatSkillCategory[];
parentCategories: ParentSkillCategory[];
totalTools: number;
totalSkills: number;
}
const sourceTypeLabel: Record<string, string> = {
gitee: "Gitee",
gitcode: "GitCode",
github: "GitHub",
gitlab: "GitLab",
huggingface: "HuggingFace",
marketplace: "Marketplace",
other: "其他",
};
const sourceTypeColor: Record<string, string> = {
gitee: "bg-red-100 text-red-700 border border-red-200",
gitcode: "bg-orange-100 text-orange-700 border border-orange-200",
github: "bg-slate-100 text-slate-700 border border-slate-200",
gitlab: "bg-purple-100 text-purple-700 border border-purple-200",
huggingface: "bg-yellow-100 text-yellow-700 border border-yellow-200",
marketplace: "bg-blue-100 text-blue-700 border border-blue-200",
other: "bg-secondary text-muted-foreground",
};
export default function SkillsPageClient({
flatCategories: categories,
parentCategories,
totalTools,
totalSkills,
}: SkillsPageClientProps) {
const router = useRouter();
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const handleSearch = () => {
if (search.trim()) {
router.push(`/skills?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatSkillCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
const isEmpty = categories.length === 0 && parentCategories.length === 0;
if (isEmpty) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索技能..."
className="input-field-compact !focus:border-violet-500/50 !focus:ring-violet-500/15"
/>
</div>
<div className="h-px bg-border/40 my-2" />
{parentCategories.map((p) => {
const isActive = activeSection === `skill-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.skillCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.skillCount, 0);
const totalCount = ownCount + childCount;
return (
<button
key={p.id}
onClick={() => scrollToSection(`skill-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-violet-100 font-semibold text-violet-700 shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-violet-600/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalSkills} 个技能
</p>
</div>
</nav>
);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI技能..."
className="input-field !pl-10 !h-10 !rounded-xl !focus:border-violet-500/50 !focus:ring-violet-500/15"
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="Skills"
highlightColor="violet"
starStyle="aurora"
stats={[
{ label: "AI技能", value: totalSkills, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="开源AI技能生态,构建智能未来"
subSlogan="国内优质AI开源项目,一站式发现与学习"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatSkillCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.skillCount, 0);
const allSkills = allCatsInGroup.flatMap((c) => c.skills);
const displaySkills = activeTab === "all"
? allSkills
: children.find((c) => c.slug === activeTab)?.skills || ownCat?.skills || [];
const maxDisplay = 8;
const visibleSkills = displaySkills.slice(0, maxDisplay);
const hasMore = displaySkills.length > maxDisplay;
return (
<section
key={`skill-${parent.slug}`}
id={`skill-${parent.slug}`}
ref={(el) => { sectionRefs.current[`skill-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-violet-100 px-2.5 py-0.5 text-[10px] font-semibold text-violet-700 tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.skillCount}</span>
</button>
))}
</div>
)}
{visibleSkills.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleSkills.map((skill) => (
<Card
key={skill.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-violet-300/50 hover:shadow-[0_8px_24px_rgba(124,58,237,0.1),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
onClick={() => router.push(`/skills/${skill.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="skill" id={skill.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{skill.logoUrl ? (
<img
src={skill.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-violet-300/50 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-violet-100 to-violet-50 text-xs font-semibold text-violet-700 transition-all duration-300 group-hover:scale-105 ${skill.logoUrl ? 'hidden' : ''}`}>
{skill.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-violet-700 transition-colors duration-200">
{skill.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${sourceTypeColor[skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
{sourceTypeLabel[skill.sourceType] || skill.sourceType}
</span>
{skill.rating > 0 && (
<span className="flex items-center gap-0.5 text-[9px] text-amber-600/80">
{Array.from({ length: 5 }).map((_, si) => (
<svg key={si} width="8" height="8" viewBox="0 0 24 24" fill={si < Math.round(skill.rating) ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5">
<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="ml-0.5">{skill.rating.toFixed(1)}</span>
</span>
)}
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{skill.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/skills?category=${cat}`);
}}
>
查看全部 {displaySkills.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}
+45 -94
View File
@@ -1,8 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -14,23 +12,8 @@ const SkillReviewCard = dynamic(
{ ssr: false }
);
interface SkillDetail {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceUrl: string;
sourceType: string;
rating: number;
features: unknown;
tags: unknown;
viewCount: number;
favorited?: boolean;
favoriteCount?: number;
category: { id: number; name: string; slug: string } | null;
createdAt: string;
updatedAt: string;
interface Props {
params: { slug: string };
}
const sourceTypeLabel: Record<string, string> = {
@@ -60,73 +43,41 @@ const tagColors = [
"border-cyan-200 bg-cyan-50 text-cyan-700",
];
export default function SkillDetailPage() {
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
const { status } = useSession();
const [skill, setSkill] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
fetch(`/api/skills/${slug}`)
.then((r) => {
if (!r.ok) throw new Error("技能不存在");
return r.json();
})
.then((data) => {
setSkill(data);
if (status === "authenticated" && data?.id) {
fetch("/api/user/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skillId: data.id }),
}).catch(() => {});
}
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [slug, status]);
if (loading) {
return (
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div className="animate-pulse space-y-8">
<div className="h-4 w-20 rounded bg-muted/40" />
<div className="rounded-2xl border border-border/50 bg-white p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex gap-6">
<div className="h-20 w-20 rounded-2xl bg-muted/40" />
<div className="flex-1 space-y-3">
<div className="h-8 w-56 rounded bg-muted/40" />
<div className="h-5 w-40 rounded bg-muted/30" />
</div>
</div>
</div>
</div>
</div>
);
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const skill = await prisma.skill.findFirst({
where: { slug: params.slug, status: "published" },
select: { name: true, description: true },
});
if (!skill) {
return { title: "技能不存在" };
}
return {
title: skill.name,
description: skill.description,
};
}
if (error || !skill) {
return (
<div className="mx-auto flex max-w-4xl flex-col items-center px-4 py-24 sm:px-6 lg:px-8">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="mt-5 text-lg font-medium text-muted-foreground">{error || "技能不存在"}</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
返回
</Button>
</div>
);
}
export default async function SkillDetailPage({ params }: Props) {
const { slug } = params;
const features = Array.isArray(skill.features) ? skill.features : [];
const tags = Array.isArray(skill.tags) ? skill.tags : [];
const skill = await prisma.skill.findFirst({
where: { slug, status: "published" },
include: {
category: { select: { id: true, name: true, slug: true } },
_count: { select: { favorites: true } },
},
});
if (!skill) notFound();
prisma.skill.update({
where: { id: skill.id },
data: { viewCount: { increment: 1 } },
}).catch(() => {});
const features: string[] = Array.isArray(skill.features) ? skill.features as string[] : [];
const tags: string[] = Array.isArray(skill.tags) ? skill.tags as string[] : [];
const favoriteCount = skill._count.favorites;
return (
<div className="relative">
@@ -137,9 +88,9 @@ export default function SkillDetailPage() {
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<a href="/" className="transition-colors hover:text-foreground">首页</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<button onClick={() => router.push("/skills")} className="transition-colors hover:text-foreground">技能库</button>
<a href="/skills" className="transition-colors hover:text-foreground">技能库</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground truncate max-w-[200px]">{skill.name}</span>
</nav>
@@ -164,17 +115,17 @@ export default function SkillDetailPage() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{skill.name}</h1>
<FavoriteButton type="skill" id={skill.id} initialFavorited={skill.favorited ?? false} />
<FavoriteButton type="skill" id={skill.id} initialFavorited={false} />
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{skill.category && (
<button
onClick={() => router.push(`/skills?category=${skill.category!.slug}`)}
<a
href={`/skills?category=${skill.category.slug}`}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground transition-all duration-200 hover:border-violet-300/50 hover:text-foreground hover:bg-white hover:shadow-sm"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
{skill.category.name}
</button>
</a>
)}
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${sourceTypeColor[skill.sourceType] || "bg-secondary text-muted-foreground border-border"}`}>
{sourceTypeLabel[skill.sourceType] || skill.sourceType}
@@ -193,10 +144,10 @@ export default function SkillDetailPage() {
<span className="ml-0.5 font-medium">{skill.rating.toFixed(1)}</span>
</span>
)}
{skill.favoriteCount !== undefined && (
{favoriteCount > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
{skill.favoriteCount} 人收藏
{favoriteCount} 人收藏
</span>
)}
</div>
+49 -408
View File
@@ -1,417 +1,58 @@
"use client";
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
import SkillsPageClient from "./SkillsPageClient";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Skill {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceUrl: string;
sourceType: string;
rating: number;
viewCount: number;
export async function generateMetadata(): Promise<Metadata> {
return {
title: "追光Skills - 开源AI技能生态",
description: "国内优质AI开源项目,一站式发现与学习。浏览精选开源AI技能,构建智能未来。",
openGraph: {
title: "追光Skills - 开源AI技能生态",
description: "国内优质AI开源项目,一站式发现与学习。浏览精选开源AI技能,构建智能未来。",
type: "website",
},
};
}
interface FlatSkillCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
skillCount: number;
skills: Skill[];
}
interface ParentSkillCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
const sourceTypeLabel: Record<string, string> = {
gitee: "Gitee",
gitcode: "GitCode",
github: "GitHub",
gitlab: "GitLab",
huggingface: "HuggingFace",
marketplace: "Marketplace",
other: "其他",
};
const sourceTypeColor: Record<string, string> = {
gitee: "bg-red-100 text-red-700 border border-red-200",
gitcode: "bg-orange-100 text-orange-700 border border-orange-200",
github: "bg-slate-100 text-slate-700 border border-slate-200",
gitlab: "bg-purple-100 text-purple-700 border border-purple-200",
huggingface: "bg-yellow-100 text-yellow-700 border border-yellow-200",
marketplace: "bg-blue-100 text-blue-700 border border-blue-200",
other: "bg-secondary text-muted-foreground",
};
export default function SkillsHomePage() {
const router = useRouter();
const [categories, setCategories] = useState<FlatSkillCategory[]>([]);
const [parentCategories, setParentCategories] = useState<ParentSkillCategory[]>([]);
const [totalTools, setTotalTools] = useState(0);
const [totalSkills, setTotalSkills] = useState(0);
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
fetch("/api/homepage?type=skills")
.then((r) => r.json())
.then((data) => {
setCategories(data.categories || []);
setParentCategories(data.parentCategories || []);
setTotalTools(data.totalTools || 0);
setTotalSkills(data.totalSkills || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
export default async function SkillsHomePage() {
const [allCategories, totalTools, totalSkills] = await Promise.all([
prisma.skillCategory.findMany({
include: {
parent: { select: { id: true, name: true, slug: true, icon: true } },
_count: { select: { skills: { where: { status: "published" } } } },
skills: { where: { status: "published" }, take: 8, orderBy: { viewCount: "desc" } },
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
orderBy: { sortOrder: "asc" },
}),
prisma.tool.count({ where: { status: "published" } }),
prisma.skill.count({ where: { status: "published" } }),
]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const flatCategories = allCategories.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
parentId: cat.parentId,
skillCount: cat._count.skills,
skills: cat.skills,
}));
const handleSearch = () => {
if (search.trim()) {
router.push(`/skills?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatSkillCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
if (loading) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索技能..."
className="input-field-compact !focus:border-violet-500/50 !focus:ring-violet-500/15"
/>
</div>
<div className="h-px bg-border/40 my-2" />
{parentCategories.map((p) => {
const isActive = activeSection === `skill-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.skillCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.skillCount, 0);
const totalCount = ownCount + childCount;
return (
<button
key={p.id}
onClick={() => scrollToSection(`skill-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-violet-100 font-semibold text-violet-700 shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-violet-600/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalSkills} 个技能
</p>
</div>
</nav>
);
const parentCategories = allCategories
.filter((cat) => cat.parentId === null)
.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
}));
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI技能..."
className="input-field !pl-10 !h-10 !rounded-xl !focus:border-violet-500/50 !focus:ring-violet-500/15"
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="Skills"
highlightColor="violet"
starStyle="aurora"
stats={[
{ label: "AI技能", value: totalSkills, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="开源AI技能生态,构建智能未来"
subSlogan="国内优质AI开源项目,一站式发现与学习"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatSkillCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.skillCount, 0);
const allSkills = allCatsInGroup.flatMap((c) => c.skills);
const displaySkills = activeTab === "all"
? allSkills
: children.find((c) => c.slug === activeTab)?.skills || ownCat?.skills || [];
const maxDisplay = 8;
const visibleSkills = displaySkills.slice(0, maxDisplay);
const hasMore = displaySkills.length > maxDisplay;
return (
<section
key={`skill-${parent.slug}`}
id={`skill-${parent.slug}`}
ref={(el) => { sectionRefs.current[`skill-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-violet-100 px-2.5 py-0.5 text-[10px] font-semibold text-violet-700 tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.skillCount}</span>
</button>
))}
</div>
)}
{visibleSkills.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleSkills.map((skill) => (
<Card
key={skill.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-violet-300/50 hover:shadow-[0_8px_24px_rgba(124,58,237,0.1),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
onClick={() => router.push(`/skills/${skill.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="skill" id={skill.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{skill.logoUrl ? (
<img
src={skill.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-violet-300/50 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-violet-100 to-violet-50 text-xs font-semibold text-violet-700 transition-all duration-300 group-hover:scale-105 ${skill.logoUrl ? 'hidden' : ''}`}>
{skill.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-violet-700 transition-colors duration-200">
{skill.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${sourceTypeColor[skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
{sourceTypeLabel[skill.sourceType] || skill.sourceType}
</span>
{skill.rating > 0 && (
<span className="flex items-center gap-0.5 text-[9px] text-amber-600/80">
{Array.from({ length: 5 }).map((_, si) => (
<svg key={si} width="8" height="8" viewBox="0 0 24 24" fill={si < Math.round(skill.rating) ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5">
<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="ml-0.5">{skill.rating.toFixed(1)}</span>
</span>
)}
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{skill.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/skills?category=${cat}`);
}}
>
查看全部 {displaySkills.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
<SkillsPageClient
flatCategories={flatCategories}
parentCategories={parentCategories}
totalTools={totalTools}
totalSkills={totalSkills}
/>
);
}
}
+524
View File
@@ -0,0 +1,524 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
interface Category {
id: number;
name: string;
slug: string;
children: Category[];
}
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
favorited?: boolean;
category: { id: number; name: string; slug: string } | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
freemium: "bg-blue-500/15 text-blue-400 border-blue-500/20",
paid: "bg-amber-500/15 text-amber-400 border-amber-500/20",
subscription: "bg-purple-500/15 text-purple-400 border-purple-500/20",
};
const pricingDot: Record<string, string> = {
free: "bg-emerald-400",
freemium: "bg-blue-400",
paid: "bg-amber-400",
subscription: "bg-purple-400",
};
const pricingOptions = [
{ value: "", label: "全部" },
{ value: "free", label: "免费" },
{ value: "freemium", label: "免费增值" },
{ value: "paid", label: "付费" },
{ value: "subscription", label: "订阅" },
];
const categoryIcons: Record<string, string> = {
"文本生成": "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7",
"图像生成": "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
"视频生成": "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z",
"音频处理": "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z",
"代码助手": "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
"办公效率": "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
"AI 角色": "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
"设计工具": "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
"数据分析": "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",
"内容检测": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
};
interface ToolsContentProps {
initialCategories: Category[];
initialTools: Tool[];
initialTotal: number;
}
export default function ToolsContent({ initialCategories, initialTools, initialTotal }: ToolsContentProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [categories] = useState<Category[]>(initialCategories);
const [tools, setTools] = useState<Tool[]>(initialTools);
const [total, setTotal] = useState(initialTotal);
const [loading, setLoading] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const page = parseInt(searchParams.get("page") || "1");
const search = searchParams.get("search") || "";
const categoryId = searchParams.get("categoryId") || "";
const pricingModel = searchParams.get("pricingModel") || "";
const pageSize = 12;
const [searchInput, setSearchInput] = useState(search);
const isFirstRender = useRef(true);
const fetchTools = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
});
if (search) params.set("search", search);
if (categoryId) params.set("categoryId", categoryId);
if (pricingModel) params.set("pricingModel", pricingModel);
const res = await fetch(`/api/tools?${params}`);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [page, search, categoryId, pricingModel]);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
fetchTools();
}, [fetchTools]);
const updateParams = (updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
};
const totalPages = Math.ceil(total / pageSize);
const activeCategoryName = categoryId
? categories.find((c) => c.id.toString() === categoryId)?.name || ""
: "全部分类";
const activePricingName = pricingModel
? pricingOptions.find((p) => p.value === pricingModel)?.label || ""
: "";
const Sidebar = () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
分类筛选
</h3>
<nav className="space-y-0.5">
<button
onClick={() => updateParams({ categoryId: "" })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
!categoryId
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
!categoryId ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
}`}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
全部分类
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => updateParams({ categoryId: cat.id.toString() })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
categoryId === cat.id.toString()
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
categoryId === cat.id.toString() ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
}`}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={categoryIcons[cat.name] || "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"} />
</svg>
</div>
{cat.name}
{categoryId === cat.id.toString() && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</nav>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<div>
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
定价模型
</h3>
<nav className="space-y-0.5">
{pricingOptions.map((opt) => (
<button
key={opt.value}
onClick={() => updateParams({ pricingModel: opt.value })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
pricingModel === opt.value
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
{opt.value ? (
<span className={`h-2 w-2 rounded-full transition-all duration-200 ${
pricingModel === opt.value ? "scale-125" : pricingDot[opt.value] || "bg-muted-foreground/40"
}`} style={{ backgroundColor: pricingModel === opt.value ? undefined : undefined }} />
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-muted/50 group-hover:bg-accent">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
)}
{opt.label}
{pricingModel === opt.value && opt.value && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</nav>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<div className="rounded-xl border border-border/50 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-xs text-muted-foreground leading-relaxed">
💡 点击分类或定价模型进行筛选,支持组合筛选
</p>
</div>
</div>
);
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">全部工具</span>
</div>
<h1 className="mt-3 text-3xl font-bold tracking-tight">
探索<span className="gradient-text">AI</span>工具
</h1>
<p className="mt-2 text-muted-foreground">
发现最适合你的AI工具,提升工作效率与创造力
</p>
</div>
<div className="flex gap-8">
<aside className="hidden w-60 shrink-0 md:block">
<div className="sticky top-24 rounded-xl border border-border/50 bg-white p-4 scrollbar-thin shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<Sidebar />
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-6 flex items-center gap-3">
<div className="relative flex-1">
<svg className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && updateParams({ search: searchInput })
}
placeholder="搜索AI工具..."
className="h-11 w-full rounded-xl border border-border/50 bg-muted/50 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
/>
{searchInput && (
<button
onClick={() => { setSearchInput(""); updateParams({ search: "" }); }}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
)}
</div>
<Button
size="default"
onClick={() => updateParams({ search: searchInput })}
className="h-11 gap-2 glow-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
搜索
</Button>
<button
className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/50 text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground md:hidden"
onClick={() => setMobileFilterOpen(!mobileFilterOpen)}
aria-label="筛选"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</button>
</div>
{mobileFilterOpen && (
<div className="mb-6 rounded-xl border border-border/50 bg-white p-4 md:hidden animate-slide-down shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<Sidebar />
</div>
)}
<div className="mb-5 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
{search ? `搜索 "${search}"` : activeCategoryName}
</span>
{activePricingName && (
<>
<span className="text-muted-foreground/30">·</span>
<span className="text-sm text-muted-foreground">{activePricingName}</span>
</>
)}
<span className="text-muted-foreground/30">·</span>
<span className="text-sm font-semibold text-foreground">{total}</span>
<span className="text-sm text-muted-foreground">个工具</span>
</div>
{(categoryId || pricingModel || search) && (
<button
onClick={() => { setSearchInput(""); router.push("/tools"); }}
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
清除筛选
</button>
)}
</div>
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
</div>
</CardContent>
</Card>
))}
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">暂无匹配的工具</p>
<p className="mt-1 text-sm text-muted-foreground/50">试试其他关键词或筛选条件</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => { setSearchInput(""); router.push("/tools"); }}>
清除筛选
</Button>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool, i) => (
<Card
key={tool.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-2 right-2 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
</div>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{tool.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
{tool.category && (
<span className="text-[10px] text-muted-foreground/60">
{tool.category.name}
</span>
)}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground/40">
<span className="flex items-center gap-1">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount}
</span>
</div>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", (page - 1).toString());
router.push(`/tools?${params.toString()}`);
}}
className="gap-1.5 rounded-lg"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
上一页
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", pageNum.toString());
router.push(`/tools?${params.toString()}`);
}}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
page === pageNum
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{pageNum}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", (page + 1).toString());
router.push(`/tools?${params.toString()}`);
}}
className="gap-1.5 rounded-lg"
>
下一页
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</Button>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
);
}
+74 -103
View File
@@ -1,32 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { ReviewSection } from "@/components/common/ReviewSection";
interface ToolDetail {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
pricingDetail: string | null;
features: unknown;
tags: unknown;
viewCount: number;
favorited?: boolean;
favoriteCount?: number;
category: { id: number; name: string; slug: string } | null;
createdAt: string;
updatedAt: string;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
@@ -59,78 +39,71 @@ const tagColors = [
"border-rose-200 bg-rose-50 text-rose-700",
];
export default function ToolDetailPage() {
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
const { status } = useSession();
interface PageProps {
params: { slug: string };
}
const [tool, setTool] = useState<ToolDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
export async function generateMetadata({ params }: PageProps) {
const tool = await prisma.tool.findUnique({
where: { slug: params.slug, status: "published" },
select: { name: true, description: true },
});
if (!tool) {
return { title: "工具不存在" };
}
return {
title: `${tool.name} - 追光AI工具详情`,
description: tool.description,
};
}
useEffect(() => {
fetch(`/api/tools/${slug}`)
.then((r) => {
if (!r.ok) throw new Error("工具不存在");
return r.json();
})
.then((data) => {
setTool(data);
if (status === "authenticated" && data?.id) {
fetch("/api/user/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toolId: data.id }),
}).catch(() => {});
}
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [slug, status]);
export default async function ToolDetailPage({ params }: PageProps) {
const tool = await prisma.tool.findUnique({
where: { slug: params.slug, status: "published" },
select: {
id: true,
name: true,
slug: true,
description: true,
logoUrl: true,
websiteUrl: true,
pricingModel: true,
pricingDetail: true,
features: true,
tags: true,
viewCount: true,
createdAt: true,
updatedAt: true,
category: { select: { id: true, name: true, slug: true } },
},
});
if (loading) {
return (
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div className="animate-pulse space-y-8">
<div className="h-4 w-20 rounded bg-muted/40" />
<div className="rounded-2xl border border-border/50 bg-white p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex gap-6">
<div className="h-20 w-20 rounded-2xl bg-muted/40" />
<div className="flex-1 space-y-3">
<div className="h-8 w-56 rounded bg-muted/40" />
<div className="h-5 w-40 rounded bg-muted/30" />
</div>
</div>
<div className="mt-8 space-y-2">
<div className="h-4 w-full rounded bg-muted/30" />
<div className="h-4 w-3/4 rounded bg-muted/30" />
<div className="h-4 w-1/2 rounded bg-muted/20" />
</div>
</div>
</div>
</div>
);
if (!tool) {
notFound();
}
if (error || !tool) {
return (
<div className="mx-auto flex max-w-4xl flex-col items-center px-4 py-24 sm:px-6 lg:px-8">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="mt-5 text-lg font-medium text-muted-foreground">{error || "工具不存在"}</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
返回
</Button>
</div>
);
prisma.tool.update({
where: { id: tool.id },
data: { viewCount: { increment: 1 } },
}).catch(() => {});
const session = await getServerSession(authOptions);
let favorited = false;
if (session?.user?.id) {
const fav = await prisma.favorite.findFirst({
where: { userId: parseInt(session.user.id), toolId: tool.id },
select: { id: true },
});
favorited = !!fav;
}
const features = Array.isArray(tool.features) ? tool.features : [];
const tags = Array.isArray(tool.tags) ? tool.tags : [];
const favoriteCount = await prisma.favorite.count({
where: { toolId: tool.id },
});
const displayViewCount = tool.viewCount + 1;
const features = (Array.isArray(tool.features) ? tool.features as string[] : []);
const tags = (Array.isArray(tool.tags) ? tool.tags as string[] : []);
return (
<div className="relative">
@@ -142,9 +115,9 @@ export default function ToolDetailPage() {
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<a href="/" className="transition-colors hover:text-foreground">首页</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<button onClick={() => router.push("/tools")} className="transition-colors hover:text-foreground">全部工具</button>
<a href="/tools" className="transition-colors hover:text-foreground">全部工具</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground truncate max-w-[200px]">{tool.name}</span>
</nav>
@@ -169,17 +142,17 @@ export default function ToolDetailPage() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{tool.name}</h1>
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
<FavoriteButton type="tool" id={tool.id} initialFavorited={favorited} />
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{tool.category && (
<button
onClick={() => router.push(`/categories/${tool.category!.slug}`)}
<a
href={`/categories/${tool.category.slug}`}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground transition-all duration-200 hover:border-primary/30 hover:text-foreground hover:bg-white hover:shadow-sm"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
{tool.category.name}
</button>
</a>
)}
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -192,14 +165,12 @@ export default function ToolDetailPage() {
)}
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount} 次浏览
{displayViewCount} 次浏览
</span>
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
{favoriteCount} 人收藏
</span>
{tool.favoriteCount !== undefined && (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
{tool.favoriteCount} 人收藏
</span>
)}
</div>
</div>
@@ -322,4 +293,4 @@ export default function ToolDetailPage() {
</div>
</div>
);
}
}
+134 -509
View File
@@ -1,528 +1,153 @@
"use client";
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
import { Suspense } from "react";
import type { Prisma } from "@prisma/client";
import ToolsContent from "./ToolsContent";
import { useState, useEffect, useCallback, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
export async function generateMetadata({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}): Promise<Metadata> {
const search =
typeof searchParams.search === "string" ? searchParams.search : "";
const categoryId =
typeof searchParams.categoryId === "string"
? searchParams.categoryId
: "";
interface Category {
id: number;
name: string;
slug: string;
children: Category[];
let title = "AI 工具库 - 发现优质AI工具";
let description = "发现最适合你的AI工具,提升工作效率与创造力。浏览免费、付费、订阅等多种定价模式的AI工具。";
if (search) {
title = `${search} - AI 工具搜索`;
description = `搜索 "${search}" 相关的AI工具,找到你需要的AI应用和服务。`;
}
if (categoryId) {
const category = await prisma.category.findUnique({
where: { id: parseInt(categoryId) || 0 },
select: { name: true },
});
if (category) {
title = `${category.name} - AI 工具库`;
description = `浏览${category.name}分类下的AI工具,找到最适合该领域的AI应用。`;
}
}
return {
title,
description,
openGraph: {
title,
description,
type: "website",
},
};
}
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
favorited?: boolean;
category: { id: number; name: string; slug: string } | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
freemium: "bg-blue-500/15 text-blue-400 border-blue-500/20",
paid: "bg-amber-500/15 text-amber-400 border-amber-500/20",
subscription: "bg-purple-500/15 text-purple-400 border-purple-500/20",
};
const pricingDot: Record<string, string> = {
free: "bg-emerald-400",
freemium: "bg-blue-400",
paid: "bg-amber-400",
subscription: "bg-purple-400",
};
const pricingOptions = [
{ value: "", label: "全部" },
{ value: "free", label: "免费" },
{ value: "freemium", label: "免费增值" },
{ value: "paid", label: "付费" },
{ value: "subscription", label: "订阅" },
];
const categoryIcons: Record<string, string> = {
"文本生成": "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7",
"图像生成": "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
"视频生成": "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z",
"音频处理": "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z",
"代码助手": "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
"办公效率": "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
"AI 角色": "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
"设计工具": "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
"数据分析": "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",
"内容检测": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
};
export default function ToolsPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center"><p className="text-muted-foreground">加载中...</p></div>}>
<ToolsContent />
</Suspense>
export default async function ToolsPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const page = Math.max(
1,
parseInt(
typeof searchParams.page === "string" ? searchParams.page : "1"
) || 1
);
}
function ToolsContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [categories, setCategories] = useState<Category[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const page = parseInt(searchParams.get("page") || "1");
const search = searchParams.get("search") || "";
const categoryId = searchParams.get("categoryId") || "";
const pricingModel = searchParams.get("pricingModel") || "";
const search =
typeof searchParams.search === "string" ? searchParams.search : "";
const categoryId =
typeof searchParams.categoryId === "string"
? searchParams.categoryId
: "";
const pricingModel =
typeof searchParams.pricingModel === "string"
? searchParams.pricingModel
: "";
const pageSize = 12;
const [searchInput, setSearchInput] = useState(search);
const [prismaCategories, toolData] = await Promise.all([
prisma.category.findMany({
where: { parentId: null },
include: { children: true },
orderBy: { sortOrder: "asc" },
}),
(async () => {
const where: Prisma.ToolWhereInput = {
status: "published",
};
useEffect(() => {
fetch("/api/categories")
.then((r) => r.json())
.then(setCategories)
.catch(() => {});
}, []);
const fetchTools = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
});
if (search) params.set("search", search);
if (categoryId) params.set("categoryId", categoryId);
if (pricingModel) params.set("pricingModel", pricingModel);
const res = await fetch(`/api/tools?${params}`);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [page, search, categoryId, pricingModel]);
useEffect(() => {
fetchTools();
}, [fetchTools]);
const updateParams = (updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
if (search) {
where.name = { contains: search };
}
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
};
const totalPages = Math.ceil(total / pageSize);
if (categoryId) {
const parsedId = parseInt(categoryId);
if (!isNaN(parsedId)) {
where.categoryId = parsedId;
}
}
const activeCategoryName = categoryId
? categories.find((c) => c.id.toString() === categoryId)?.name || ""
: "全部分类";
if (pricingModel) {
where.pricingModel = pricingModel as Prisma.EnumPricingModelFilter["equals"];
}
const activePricingName = pricingModel
? pricingOptions.find((p) => p.value === pricingModel)?.label || ""
: "";
const [tools, total] = await Promise.all([
prisma.tool.findMany({
where,
include: { category: { select: { id: true, name: true, slug: true } } },
orderBy: { viewCount: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.tool.count({ where }),
]);
const Sidebar = () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
分类筛选
</h3>
<nav className="space-y-0.5">
<button
onClick={() => updateParams({ categoryId: "" })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
!categoryId
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
!categoryId ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
}`}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
全部分类
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => updateParams({ categoryId: cat.id.toString() })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
categoryId === cat.id.toString()
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
categoryId === cat.id.toString() ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
}`}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={categoryIcons[cat.name] || "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"} />
</svg>
</div>
{cat.name}
{categoryId === cat.id.toString() && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</nav>
</div>
return { tools, total };
})(),
]);
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
const initialTools = toolData.tools.map((tool) => ({
id: tool.id,
name: tool.name,
slug: tool.slug,
description: tool.description,
logoUrl: tool.logoUrl,
websiteUrl: tool.websiteUrl,
pricingModel: tool.pricingModel,
viewCount: tool.viewCount,
favorited: false,
category: tool.category,
}));
<div>
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
定价模型
</h3>
<nav className="space-y-0.5">
{pricingOptions.map((opt) => (
<button
key={opt.value}
onClick={() => updateParams({ pricingModel: opt.value })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
pricingModel === opt.value
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
{opt.value ? (
<span className={`h-2 w-2 rounded-full transition-all duration-200 ${
pricingModel === opt.value ? "scale-125" : pricingDot[opt.value] || "bg-muted-foreground/40"
}`} style={{ backgroundColor: pricingModel === opt.value ? undefined : undefined }} />
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-muted/50 group-hover:bg-accent">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
)}
{opt.label}
{pricingModel === opt.value && opt.value && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</nav>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<div className="rounded-xl border border-border/50 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-xs text-muted-foreground leading-relaxed">
💡 点击分类或定价模型进行筛选,支持组合筛选
</p>
</div>
</div>
);
const categories = prismaCategories.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
children: cat.children.map((child) => ({
id: child.id,
name: child.name,
slug: child.slug,
children: [] as { id: number; name: string; slug: string; children: never[] }[],
})),
}));
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">全部工具</span>
</div>
<h1 className="mt-3 text-3xl font-bold tracking-tight">
探索<span className="gradient-text">AI</span>工具
</h1>
<p className="mt-2 text-muted-foreground">
发现最适合你的AI工具,提升工作效率与创造力
</p>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">加载中...</p>
</div>
<div className="flex gap-8">
<aside className="hidden w-60 shrink-0 md:block">
<div className="sticky top-24 rounded-xl border border-border/50 bg-white p-4 scrollbar-thin shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<Sidebar />
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-6 flex items-center gap-3">
<div className="relative flex-1">
<svg className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && updateParams({ search: searchInput })
}
placeholder="搜索AI工具..."
className="h-11 w-full rounded-xl border border-border/50 bg-muted/50 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
/>
{searchInput && (
<button
onClick={() => { setSearchInput(""); updateParams({ search: "" }); }}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
)}
</div>
<Button
size="default"
onClick={() => updateParams({ search: searchInput })}
className="h-11 gap-2 glow-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
搜索
</Button>
<button
className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/50 text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground md:hidden"
onClick={() => setMobileFilterOpen(!mobileFilterOpen)}
aria-label="筛选"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</button>
</div>
{mobileFilterOpen && (
<div className="mb-6 rounded-xl border border-border/50 bg-white p-4 md:hidden animate-slide-down shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<Sidebar />
</div>
)}
<div className="mb-5 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
{search ? `搜索 "${search}"` : activeCategoryName}
</span>
{activePricingName && (
<>
<span className="text-muted-foreground/30">·</span>
<span className="text-sm text-muted-foreground">{activePricingName}</span>
</>
)}
<span className="text-muted-foreground/30">·</span>
<span className="text-sm font-semibold text-foreground">{total}</span>
<span className="text-sm text-muted-foreground">个工具</span>
</div>
{(categoryId || pricingModel || search) && (
<button
onClick={() => { setSearchInput(""); router.push("/tools"); }}
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
清除筛选
</button>
)}
</div>
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
</div>
</CardContent>
</Card>
))}
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">暂无匹配的工具</p>
<p className="mt-1 text-sm text-muted-foreground/50">试试其他关键词或筛选条件</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => { setSearchInput(""); router.push("/tools"); }}>
清除筛选
</Button>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool, i) => (
<Card
key={tool.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-2 right-2 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
</div>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{tool.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
{tool.category && (
<span className="text-[10px] text-muted-foreground/60">
{tool.category.name}
</span>
)}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground/40">
<span className="flex items-center gap-1">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount}
</span>
</div>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", (page - 1).toString());
router.push(`/tools?${params.toString()}`);
}}
className="gap-1.5 rounded-lg"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
上一页
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", pageNum.toString());
router.push(`/tools?${params.toString()}`);
}}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
page === pageNum
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{pageNum}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", (page + 1).toString());
router.push(`/tools?${params.toString()}`);
}}
className="gap-1.5 rounded-lg"
>
下一页
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</Button>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
}
>
<ToolsContent
initialCategories={categories}
initialTools={initialTools}
initialTotal={toolData.total}
/>
</Suspense>
);
}
}
+180
View File
@@ -0,0 +1,180 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import Link from "next/link";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import { getLevelInfo } from "@/lib/level-config";
import { MessageSquare, TrendingUp, Star, Award, Calendar, Share2, Edit3, Check } from "lucide-react";
export const dynamic = "force-dynamic";
interface Props {
params: { id: string };
}
export default async function UserProfilePage({ params }: Props) {
const userId = parseInt(params.id);
if (isNaN(userId)) notFound();
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
forumTopics: {
take: 10,
orderBy: { createdAt: "desc" },
include: { category: { select: { name: true, slug: true, icon: true } } },
},
forumPosts: {
take: 10,
orderBy: { createdAt: "desc" },
include: { topic: { select: { id: true, title: true } } },
},
comments: {
take: 10,
orderBy: { createdAt: "desc" },
include: {
tool: { select: { name: true, slug: true } },
skill: { select: { name: true, slug: true } },
},
},
},
});
if (!user) notFound();
const session = await getServerSession(authOptions);
const isOwner = session?.user?.id === userId.toString();
const levelInfo = getLevelInfo(user.points);
const shareUrl = `${process.env.NEXTAUTH_URL || "https://www.zhuig.com"}/user/${userId}`;
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-4">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</Link>
</div>
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="h-32 bg-gradient-to-r from-violet-500 via-purple-500 to-indigo-500 relative">
<div className="absolute -bottom-12 left-6">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-24 w-24 rounded-full border-4 border-white object-cover shadow-lg" />
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white border-4 border-white shadow-lg text-3xl font-bold text-primary">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="absolute top-4 right-4 flex items-center gap-2">
{isOwner ? (
<Link href="/user" className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1">
<Edit3 className="w-3 h-3" /> 编辑资料
</Link>
) : (
<a href={shareUrl} className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1"
onClick={(e) => {
e.preventDefault();
navigator.clipboard?.writeText(shareUrl).then(() => alert("已复制分享链接!"));
}}>
<Share2 className="w-3 h-3" /> 分享
</a>
)}
</div>
</div>
<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>
<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")}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-6">
{[
{ label: "积分", value: user.points, icon: Award, color: "text-emerald-600 bg-emerald-50" },
{ label: "话题", value: user.forumTopics.length, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: user.forumPosts.length, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "评论", value: user.comments.length, icon: Star, color: "text-amber-600 bg-amber-50" },
].map((stat) => (
<div key={stat.label} className="text-center p-3 rounded-lg bg-muted/30">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
<div className="mt-6">
<LevelProgress level={user.level} points={user.points} />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 className="text-lg font-semibold mb-4">发布的话题</h2>
{user.forumTopics.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无话题</p>
) : (
<div className="space-y-3">
{user.forumTopics.map((topic) => (
<Link key={topic.id} href={`/community/topic/${topic.id}`} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<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>
</div>
</div>
</Link>
))}
</div>
)}
</div>
<div>
<h2 className="text-lg font-semibold mb-4">参与的评论</h2>
{user.comments.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无评论</p>
) : (
<div className="space-y-3">
{user.comments.map((comment) => {
const target = comment.tool || comment.skill;
return (
<Link key={comment.id} href={target ? `/${comment.tool ? "tools" : "skills"}/${target.slug}` : "#"} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-sm text-muted-foreground/80 truncate">{comment.content}</p>
{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>
</div>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+546
View File
@@ -0,0 +1,546 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useRouter, useParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface CollectionItem {
id: number;
toolId: number | null;
skillId: number | null;
addedAt: string;
tool?: {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
pricingModel: string;
category: { id: number; name: string; slug: string } | null;
} | null;
skill?: {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceType: string;
category: { id: number; name: string; slug: string } | null;
} | null;
}
interface Collection {
id: number;
name: string;
description: string | null;
_count: { items: number };
}
interface SearchResult {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
type: "tool" | "skill";
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
const sourceTypeLabel: Record<string, string> = {
github: "GitHub",
gitlab: "GitLab",
huggingface: "HuggingFace",
other: "其他",
};
const sourceTypeColor: Record<string, string> = {
github: "bg-slate-50 text-slate-700",
gitlab: "bg-orange-50 text-orange-700",
huggingface: "bg-yellow-50 text-yellow-700",
other: "bg-secondary text-muted-foreground",
};
export default function CollectionDetailPage() {
const { data: session, status } = useSession();
const router = useRouter();
const params = useParams();
const collectionId = params.id as string;
const [collection, setCollection] = useState<Collection | null>(null);
const [items, setItems] = useState<CollectionItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showAddModal, setShowAddModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [addingItems, setAddingItems] = useState<Set<number>>(new Set());
const fetchData = useCallback(async () => {
setLoading(true);
setError("");
try {
const [collRes, itemsRes] = await Promise.all([
fetch(`/api/user/collections/${collectionId}`),
fetch(`/api/user/collections/${collectionId}/items`),
]);
if (!collRes.ok) {
const e = await collRes.json();
throw new Error(e.error || "加载失败");
}
const collData = await collRes.json();
const itemsData = await itemsRes.json();
setCollection(collData);
setItems(itemsData.items || []);
} catch (err: any) {
setError(err.message || "加载收藏夹失败");
} finally {
setLoading(false);
}
}, [collectionId]);
useEffect(() => {
if (status === "loading") return;
if (status === "unauthenticated") {
setLoading(false);
return;
}
fetchData();
}, [status, fetchData]);
const handleRemoveItem = async (itemId: number) => {
try {
const res = await fetch(`/api/user/collections/${collectionId}/items/${itemId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("删除失败");
setItems((prev) => prev.filter((i) => i.id !== itemId));
if (collection) {
setCollection({ ...collection, _count: { items: collection._count.items - 1 } });
}
} catch {
alert("删除失败,请重试");
}
};
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (query.trim().length < 1) {
setSearchResults([]);
return;
}
setSearching(true);
try {
const [toolsRes, skillsRes] = await Promise.all([
fetch(`/api/tools?search=${encodeURIComponent(query)}&pageSize=8`),
fetch(`/api/skills?search=${encodeURIComponent(query)}&pageSize=8`),
]);
const toolsData = await toolsRes.json();
const skillsData = await skillsRes.json();
const existingToolIds = new Set(items.filter((i) => i.toolId).map((i) => i.toolId!));
const existingSkillIds = new Set(items.filter((i) => i.skillId).map((i) => i.skillId!));
const results: SearchResult[] = [
...(toolsData.tools || [])
.filter((t: any) => !existingToolIds.has(t.id))
.map((t: any) => ({ id: t.id, name: t.name, slug: t.slug, description: t.description, logoUrl: t.logoUrl, type: "tool" as const })),
...(skillsData.skills || [])
.filter((s: any) => !existingSkillIds.has(s.id))
.map((s: any) => ({ id: s.id, name: s.name, slug: s.slug, description: s.description, logoUrl: s.logoUrl, type: "skill" as const })),
];
setSearchResults(results);
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
};
const handleAddItem = async (result: SearchResult) => {
setAddingItems((prev) => new Set(prev).add(result.id));
try {
const body: any = {};
if (result.type === "tool") body.toolId = result.id;
else body.skillId = result.id;
const res = await fetch(`/api/user/collections/${collectionId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const e = await res.json();
throw new Error(e.error || "添加失败");
}
setSearchResults((prev) => prev.filter((r) => r.id !== result.id));
fetchData();
} catch (err: any) {
alert(err.message || "添加失败");
} finally {
setAddingItems((prev) => {
const next = new Set(prev);
next.delete(result.id);
return next;
});
}
};
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (status === "unauthenticated") {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<Card className="w-full max-w-md p-8 text-center rounded-2xl border border-border/60 bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<h2 className="text-xl font-bold">请先登录</h2>
<p className="mt-2 text-sm text-muted-foreground">
登录后即可查看收藏夹详情
</p>
<button
onClick={() => router.push("/login")}
className="mt-6 w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
去登录
</button>
</Card>
</div>
);
}
const itemCount = collection?._count.items ?? 0;
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg className="w-7 h-7 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-4 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
<button onClick={fetchData} className="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 transition-colors">重新加载</button>
</div>
) : loading ? (
<>
<div className="mb-8 animate-fade-in">
<div className="h-4 w-48 animate-pulse rounded bg-muted/40" />
<div className="mt-3 h-8 w-64 animate-pulse rounded bg-muted/40" />
<div className="mt-2 h-4 w-32 animate-pulse rounded bg-muted/30" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
</div>
</CardContent>
</Card>
))}
</div>
</>
) : !collection ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">收藏夹不存在</p>
<button onClick={() => router.push("/user/collections")} className="mt-4 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground">
返回收藏夹列表
</button>
</div>
) : (
<>
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">
首页
</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<button onClick={() => router.push("/user/collections")} className="transition-colors hover:text-foreground">
我的收藏夹
</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span className="text-foreground truncate max-w-[200px]">{collection.name}</span>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
<span className="gradient-text">{collection.name}</span>
</h1>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{itemCount}
</span>
</div>
<button
onClick={() => { setShowAddModal(true); setSearchQuery(""); setSearchResults([]); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
添加内容
</button>
</div>
{collection.description && (
<p className="mt-2 text-muted-foreground">{collection.description}</p>
)}
</div>
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4">
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" onClick={() => setShowAddModal(false)} />
<div className="relative w-full max-w-lg bg-white/95 backdrop-blur-md rounded-2xl border border-border/60 shadow-[0_20px_60px_rgba(0,0,0,0.15)] overflow-hidden animate-slide-up">
<div className="p-5 border-b border-border/40">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg">添加到收藏夹</h3>
<button
onClick={() => setShowAddModal(false)}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-muted/50 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative">
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="text"
placeholder="搜索 AI 工具或技能..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 bg-muted/30 rounded-xl border border-border/40 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
autoFocus
/>
</div>
</div>
<div className="max-h-[50vh] overflow-y-auto p-3">
{searching ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : searchResults.length === 0 && searchQuery.trim() ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mb-3 text-muted-foreground/30">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<p className="text-sm">未找到匹配的内容</p>
</div>
) : searchResults.length > 0 ? (
<div className="space-y-2">
{searchResults.map((result) => (
<div
key={`${result.type}-${result.id}`}
className="flex items-center gap-3 p-3 rounded-xl border border-border/40 bg-white hover:border-primary/20 hover:shadow-sm transition-all"
>
{result.logoUrl ? (
<img
src={result.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 shrink-0"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-sm font-semibold text-primary shrink-0">
{result.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{result.name}</p>
<p className="text-xs text-muted-foreground/60 truncate">{result.description || "暂无简介"}</p>
</div>
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${
result.type === "tool"
? "bg-primary/10 text-primary"
: "bg-violet-100 text-violet-700"
}`}>
{result.type === "tool" ? "工具" : "技能"}
</span>
<button
onClick={() => handleAddItem(result)}
disabled={addingItems.has(result.id)}
className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{addingItems.has(result.id) ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : "添加"}
</button>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
)}
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">收藏夹为空</p>
<p className="mt-1 text-sm text-muted-foreground/50">
添加 AI 工具或技能到这个收藏夹
</p>
<button
onClick={() => { setShowAddModal(true); setSearchQuery(""); setSearchResults([]); }}
className="mt-4 flex items-center gap-1.5 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
添加内容
</button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, i) => {
const isTool = !!item.tool;
const data = item.tool || item.skill;
if (!data) return null;
const href = isTool
? `/tools/${data.slug}`
: `/skills/${data.slug}`;
return (
<Card
key={item.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(href)}
>
<button
className="absolute top-2 right-2 z-10 p-1.5 rounded-lg text-muted-foreground/40 hover:text-red-500 hover:bg-red-50 transition-all"
onClick={(e) => {
e.stopPropagation();
handleRemoveItem(item.id);
}}
title="从收藏夹移除"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{data.logoUrl ? (
<img
src={data.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{data.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{data.name}
</p>
<div className="mt-1 flex items-center gap-2">
{isTool && item.tool ? (
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[item.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[item.tool.pricingModel] || item.tool.pricingModel}
</span>
) : !isTool && item.skill ? (
<span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${sourceTypeColor[item.skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
{sourceTypeLabel[item.skill.sourceType] || item.skill.sourceType}
</span>
) : null}
{data.category && (
<span className="text-[10px] text-muted-foreground/60">
{data.category.name}
</span>
)}
</div>
</div>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{data.description || "暂无简介"}
</p>
<div className="mt-3 flex items-center justify-between">
<span className={`rounded-md px-1.5 py-0.5 text-[10px] font-medium ${
isTool
? "bg-primary/10 text-primary"
: "bg-violet-100 text-violet-700"
}`}>
{isTool ? "工具" : "技能"}
</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)}
</div>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { FolderOpen, Plus, ChevronRight, Loader2, X } from "lucide-react";
interface Collection {
id: number;
name: string;
description: string | null;
createdAt: string;
_count: { items: number };
}
export default function CollectionsPage() {
const { data: session, status } = useSession();
const router = useRouter();
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
useEffect(() => {
if (status === "loading") return;
if (status === "unauthenticated") {
setLoading(false);
return;
}
fetchCollections();
}, [status]);
const fetchCollections = () => {
setLoading(true);
setError("");
fetch("/api/user/collections")
.then((r) => r.json())
.then((data) => setCollections(data.collections || []))
.catch(() => setError("加载收藏夹失败"))
.finally(() => setLoading(false));
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
setCreateError("");
try {
const res = await fetch("/api/user/collections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() || undefined }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "创建失败");
}
setShowCreate(false);
setNewName("");
setNewDesc("");
fetchCollections();
} catch (err: any) {
setCreateError(err.message);
} finally {
setCreating(false);
}
};
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (status === "unauthenticated") {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<Card className="w-full max-w-md p-8 text-center rounded-2xl border border-border/60 bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06)]">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
<FolderOpen className="w-7 h-7 text-primary" />
</div>
<h2 className="text-xl font-bold">请先登录</h2>
<p className="mt-2 text-sm text-muted-foreground">登录后即可管理收藏夹</p>
<button onClick={() => router.push("/login")} className="mt-6 w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
去登录
</button>
</Card>
</div>
);
}
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
</div>
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">我的收藏夹</span>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
我的<span className="gradient-text">收藏夹</span>
</h1>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{collections.length}
</span>
</div>
<button
onClick={() => { setShowCreate(true); setCreateError(""); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
<Plus className="w-4 h-4" />
新建收藏夹
</button>
</div>
</div>
{showCreate && (
<Card className="mb-6 border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
<CardContent className="p-5">
<form onSubmit={handleCreate} className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">新建收藏夹</h3>
<button type="button" onClick={() => setShowCreate(false)} className="p-1 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<input
type="text"
placeholder="收藏夹名称"
value={newName}
onChange={(e) => setNewName(e.target.value)}
maxLength={50}
required
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
autoFocus
/>
<input
type="text"
placeholder="描述(选填)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
/>
{createError && <p className="text-sm text-red-500">{createError}</p>}
<button
type="submit"
disabled={creating || !newName.trim()}
className="w-full py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{creating ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : "创建"}
</button>
</form>
</CardContent>
</Card>
)}
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg className="w-7 h-7 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-4 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
<button onClick={fetchCollections} className="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 transition-colors">重新加载</button>
</div>
) : loading ? (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white">
<CardContent className="p-5">
<div className="h-5 w-32 animate-pulse rounded bg-muted/40 mb-2" />
<div className="h-3 w-48 animate-pulse rounded bg-muted/30" />
</CardContent>
</Card>
))}
</div>
) : collections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<FolderOpen className="w-8 h-8 text-muted-foreground/40" />
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">暂无收藏夹</p>
<p className="mt-1 text-sm text-muted-foreground/50">创建收藏夹来整理你的AI工具和技能</p>
<button onClick={() => setShowCreate(true)} className="mt-4 flex items-center gap-1.5 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground">
<Plus className="w-4 h-4" />
新建收藏夹
</button>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{collections.map((c, i) => (
<Card
key={c.id}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)] hover:-translate-y-0.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/user/collections/${c.id}`)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate group-hover:text-primary transition-colors">{c.name}</h3>
{c.description && (
<p className="mt-1 text-sm text-muted-foreground truncate">{c.description}</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground/60">
<span>{c._count.items} 项</span>
<span>{new Date(c.createdAt).toLocaleDateString("zh-CN")} 创建</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-all mt-0.5" />
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}
+146 -8
View File
@@ -5,7 +5,10 @@ import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock } from "lucide-react";
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock, MessageCircle, TrendingUp, Award, Star } from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import { UserLevel } from "@prisma/client";
function getTimeAgo(date: Date): string {
const now = new Date();
@@ -29,8 +32,17 @@ export default function UserProfilePage() {
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState("");
const [currentName, setCurrentName] = useState("");
const [editingBio, setEditingBio] = useState(false);
const [bioValue, setBioValue] = useState("");
const [currentBio, setCurrentBio] = useState("");
const [saving, setSaving] = useState(false);
const [browsingHistory, setBrowsingHistory] = useState<any[]>([]);
const [createdAt, setCreatedAt] = useState<string | null>(null);
const [historyTotal, setHistoryTotal] = useState(0);
const [userPoints, setUserPoints] = useState(0);
const [userLevel, setUserLevel] = useState<UserLevel>(UserLevel.LV01);
const [pointsHistory, setPointsHistory] = useState<any[]>([]);
const [userStats, setUserStats] = useState<any>(null);
useEffect(() => {
if (status === "loading") return;
@@ -41,6 +53,15 @@ export default function UserProfilePage() {
setNameValue(session.user.name);
}
fetch("/api/user/profile")
.then((r) => r.json())
.then((data) => {
data.createdAt && setCreatedAt(data.createdAt);
data.bio && setCurrentBio(data.bio);
data.bio && setBioValue(data.bio);
})
.catch(() => {});
fetch("/api/user/favorites?type=all")
.then((r) => r.json())
.then((data) => setFavoriteTotal(data.total || 0))
@@ -48,7 +69,24 @@ export default function UserProfilePage() {
fetch("/api/user/history")
.then((r) => r.json())
.then((data) => setBrowsingHistory(data.history || []))
.then((data) => {
setBrowsingHistory(data.history || []);
setHistoryTotal(data.history?.length || 0);
})
.catch(() => {});
fetch("/api/user/points?type=stats")
.then((r) => r.json())
.then((data) => {
setUserPoints(data.points || 0);
setUserLevel(data.level?.level || UserLevel.LV01);
setUserStats(data);
})
.catch(() => {});
fetch("/api/user/points?type=history&pageSize=5")
.then((r) => r.json())
.then((data) => setPointsHistory(data.history || []))
.catch(() => {});
}, [status, session]);
@@ -91,18 +129,28 @@ export default function UserProfilePage() {
const res = await fetch("/api/user/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: nameValue }),
body: JSON.stringify({ name: nameValue, bio: bioValue }),
});
if (!res.ok) throw new Error();
setCurrentName(nameValue || "");
setCurrentBio(bioValue || "");
setEditingName(false);
setEditingBio(false);
} catch {
setNameValue(currentName);
setBioValue(currentBio);
} finally {
setSaving(false);
}
};
const handleCancelEdit = () => {
setEditingName(false);
setEditingBio(false);
setNameValue(currentName);
setBioValue(currentBio);
};
const shortcuts = [
{ icon: Heart, label: "我的收藏", desc: `${favoriteTotal} 个收藏`, href: "/user/favorites", color: "text-red-600 bg-red-50 border-red-200/60" },
{ icon: Wrench, label: "浏览工具", desc: "发现AI工具", href: "/tools", color: "text-primary bg-primary/10 border-primary/20" },
@@ -136,7 +184,7 @@ export default function UserProfilePage() {
</div>
<div className="flex-1 text-center sm:text-left min-w-0">
{editingName ? (
<div className="flex items-center gap-2 justify-center sm:justify-start">
<div className="flex items-center gap-2 justify-center sm:justify-start mb-2">
<input
type="text"
value={nameValue}
@@ -149,7 +197,7 @@ export default function UserProfilePage() {
<button onClick={handleSaveName} disabled={saving} className="p-1.5 rounded-lg text-emerald-600 hover:bg-emerald-50 transition-colors">
<Check className="w-4 h-4" />
</button>
<button onClick={() => { setEditingName(false); setNameValue(currentName); }} className="p-1.5 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<button onClick={handleCancelEdit} className="p-1.5 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</button>
</div>
@@ -171,17 +219,107 @@ export default function UserProfilePage() {
{session?.user?.email}
</p>
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start">
<span className="rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{((session?.user as any)?.role === "admin") ? "管理员" : "用户"}
</span>
<MemberBadge level={userLevel} points={userPoints} size="md" />
<span className="text-xs text-muted-foreground/60">
{favoriteTotal} 个收藏
</span>
{createdAt && (
<>
<span className="text-xs text-muted-foreground/40">·</span>
<span className="text-xs text-muted-foreground/60">
注册于 {new Date(createdAt).toLocaleDateString("zh-CN")}
</span>
</>
)}
</div>
<div className="mt-4 pt-4 border-t border-border/40">
{editingBio ? (
<div className="flex flex-col gap-2">
<textarea
value={bioValue}
onChange={(e) => setBioValue(e.target.value)}
maxLength={500}
rows={3}
className="px-3 py-2 bg-white rounded-lg border border-border/70 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors w-full text-foreground resize-none"
placeholder="介绍一下自己吧..."
autoFocus
/>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{bioValue.length}/500</span>
<div className="flex gap-2">
<button onClick={handleSaveName} disabled={saving} className="px-3 py-1 rounded-lg text-sm bg-primary text-white hover:bg-primary/90 transition-colors">
保存
</button>
<button onClick={handleCancelEdit} className="px-3 py-1 rounded-lg text-sm text-muted-foreground hover:bg-accent transition-colors">
取消
</button>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-2 justify-center sm:justify-start">
<p className="text-sm text-muted-foreground flex-1">
{currentBio || "这个人很懒,还没有写简介~"}
</p>
<button
onClick={() => setEditingBio(true)}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0"
title="编辑简介"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
</div>
</Card>
<div className="grid gap-4 sm:grid-cols-2 mb-6">
<LevelProgress level={userLevel} points={userPoints} />
<Card 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-3">积分明细</h3>
{pointsHistory.length === 0 ? (
<p className="text-xs text-muted-foreground/50 text-center py-4">暂无积分记录</p>
) : (
<div className="space-y-2">
{pointsHistory.map((ph: any) => (
<div key={ph.id} className="flex items-center justify-between text-xs">
<div>
<p className="font-medium text-foreground/80">{ph.reason}</p>
{ph.detail && <p className="text-muted-foreground/50 truncate max-w-[150px]">{ph.detail}</p>}
</div>
<div className="text-right">
<p className="font-semibold text-emerald-600">+{ph.points}</p>
<p className="text-muted-foreground/40">{new Date(ph.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
{userStats && (
<div className="grid gap-4 grid-cols-4 mb-6">
{[
{ label: "评论", value: userStats.commentCount || 0, icon: MessageCircle, color: "text-blue-600 bg-blue-50" },
{ label: "话题", value: userStats.topicCount || 0, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "评价", value: userStats.reviewCount || 0, icon: Star, color: "text-amber-600 bg-amber-50" },
{ label: "积分", value: userStats.points || 0, icon: Award, color: "text-emerald-600 bg-emerald-50" },
].map((stat) => (
<div key={stat.label} className="rounded-xl border border-border/40 bg-white p-4 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className={`mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-4 h-4" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-3 mb-6">
{shortcuts.map((s) => (
<Card
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { ThumbsUp, MessageSquare, ChevronDown, ChevronUp, Star } from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import { UserLevel } from "@prisma/client";
interface User {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
}
interface Reply {
id: number;
content: string;
likeCount: number;
createdAt: string;
liked: boolean;
user: User;
}
interface CommentData {
id: number;
content: string;
rating?: number;
likeCount: number;
isPinned: boolean;
createdAt: string;
updatedAt: string;
replyCount: number;
liked: boolean;
user: User;
replies?: Reply[];
}
interface CommentCardProps {
comment: CommentData;
targetType: "tool" | "skill";
targetSlug: string;
onReply: (parentId: number, replyToName: string) => void;
onLike: (commentId: number) => void;
}
function getTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
if (months < 12) return `${months}个月前`;
return date.toLocaleDateString("zh-CN");
}
export default function CommentCard({ comment, targetType, targetSlug, onReply, onLike }: CommentCardProps) {
const { data: session } = useSession();
const [showReplies, setShowReplies] = useState(false);
const [likeAnimating, setLikeAnimating] = useState(false);
const handleLike = () => {
setLikeAnimating(true);
onLike(comment.id);
setTimeout(() => setLikeAnimating(false), 300);
};
const initial = comment.user.name.charAt(0).toUpperCase();
return (
<div className={`rounded-xl border transition-all ${comment.isPinned ? "border-primary/30 bg-primary/[0.03]" : "border-border/40 bg-white hover:border-border/60"} shadow-[0_1px_2px_rgba(0,0,0,0.04)]`}>
{comment.isPinned && (
<div className="px-4 pt-2">
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a1 1 0 011 1v1.323l3.954 1.582 1.699-3.189a1 1 0 011.827 1.035L17.1 5.5H19a1 1 0 010 2h-2.095l-1.6 3.2a1 1 0 01-1.21.447L10 9.5V17a1 1 0 11-2 0v-7.5L3.805 7.5a1 1 0 01-1.21-.447L.995 3.85a1 1 0 011.827-1.035L4.427 5.5 8.364 3.924A1 1 0 019 3.09V3a1 1 0 011-1z" />
</svg>
置顶
</span>
</div>
)}
<div className="p-4">
<div className="flex items-start gap-3">
{comment.user.avatarUrl ? (
<img src={comment.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">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{comment.user.name}</span>
<MemberBadge level={comment.user.level} points={comment.user.points} size="sm" showPoints={false} />
{comment.rating && (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star key={s} className={`w-3 h-3 ${s <= comment.rating! ? "fill-amber-400 text-amber-400" : "text-muted-foreground/20"}`} />
))}
</div>
)}
<span className="text-[10px] text-muted-foreground/40">{getTimeAgo(new Date(comment.createdAt))}</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground/80 whitespace-pre-wrap">{comment.content}</p>
<div className="mt-3 flex items-center gap-4">
<button
onClick={handleLike}
className={`flex items-center gap-1 text-xs transition-colors ${comment.liked ? "text-primary" : "text-muted-foreground/50 hover:text-foreground"}`}
>
<ThumbsUp className={`w-3.5 h-3.5 transition-transform ${likeAnimating ? "scale-125" : ""}`} />
<span>{comment.likeCount || "点赞"}</span>
</button>
<button
onClick={() => onReply(comment.id, comment.user.name)}
className="flex items-center gap-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
>
<MessageSquare className="w-3.5 h-3.5" />
<span>回复</span>
</button>
</div>
{comment.replies && comment.replies.length > 0 && (
<div className="mt-3 space-y-2">
<button
onClick={() => setShowReplies(!showReplies)}
className="flex items-center gap-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
>
{showReplies ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
<span>{comment.replyCount > (comment.replies?.length || 0) ? `共${comment.replyCount}条回复,已加载${comment.replies?.length}条` : `共${comment.replies.length}条回复`}</span>
</button>
{showReplies && comment.replies.map((reply) => (
<div key={reply.id} className="flex items-start gap-2 pl-3 border-l-2 border-border/30 py-2">
{reply.user.avatarUrl ? (
<img src={reply.user.avatarUrl} alt="" className="h-6 w-6 rounded-full border border-border/30 object-cover" />
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted/50 text-[10px] font-medium text-muted-foreground">
{reply.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium">{reply.user.name}</span>
<span className="text-[10px] text-muted-foreground/40">{getTimeAgo(new Date(reply.createdAt))}</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground/70 whitespace-pre-wrap">{reply.content}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { LoginModal } from "@/components/common/LoginModal";
import { UserLevel } from "@prisma/client";
interface CommentData {
id: number;
content: string;
rating?: number;
likeCount: number;
liked: boolean;
isPinned: boolean;
updatedAt: string;
replyCount: number;
createdAt: string;
user: {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
};
replies?: CommentData[];
parentId?: number;
}
interface CommentFormProps {
targetType: "tool" | "skill";
targetSlug: string;
parentId?: number;
replyToName?: string;
onSuccess: (comment: CommentData) => void;
onCancel?: () => void;
}
function StarSelector({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
const [hover, setHover] = useState(0);
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => onChange(star === value ? 0 : star)}
onMouseEnter={() => setHover(star)}
onMouseLeave={() => setHover(0)}
className="transition-transform hover:scale-110"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
className={
star <= (hover || value)
? "fill-amber-400 text-amber-400"
: "fill-none text-muted-foreground/30"
}
>
<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"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
</button>
))}
</div>
);
}
export default function CommentForm({
targetType,
targetSlug,
parentId,
replyToName,
onSuccess,
onCancel,
}: CommentFormProps) {
const { data: session, status } = useSession();
const [content, setContent] = useState("");
const [rating, setRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [showLoginModal, setShowLoginModal] = useState(false);
const maxLength = 2000;
const isReply = !!parentId;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (status !== "authenticated") {
setShowLoginModal(true);
return;
}
const trimmed = content.trim();
if (!trimmed || trimmed.length < 1) {
setError("评论内容不能为空");
return;
}
if (trimmed.length > maxLength) {
setError(`评论内容不能超过${maxLength}个字符`);
return;
}
setSubmitting(true);
setError("");
try {
const body: Record<string, unknown> = {
content: trimmed,
};
if (targetType === "tool") {
body.toolSlug = targetSlug;
} else {
body.skillSlug = targetSlug;
}
if (parentId) {
body.parentId = parentId;
}
if (!isReply && rating > 0) {
body.rating = rating;
}
const res = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "提交失败");
}
const newComment: CommentData = await res.json();
setContent("");
setRating(0);
onSuccess(newComment);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
};
return (
<>
<form
onSubmit={handleSubmit}
className="space-y-3 animate-slide-up"
>
{isReply && replyToName && (
<p className="text-xs text-muted-foreground">
回复 <span className="font-medium text-foreground">@{replyToName}</span>
</p>
)}
<textarea
value={content}
onChange={(e) => {
setContent(e.target.value);
if (error) setError("");
}}
placeholder={isReply ? "写下你的回复..." : "分享你的想法..."}
rows={isReply ? 2 : 3}
maxLength={maxLength}
className="w-full resize-none rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors placeholder:text-muted-foreground/40"
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{!isReply && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">评分</span>
<StarSelector value={rating} onChange={setRating} />
</div>
)}
<span className="text-xs text-muted-foreground/50">
{content.length}/{maxLength}
</span>
</div>
<div className="flex items-center gap-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-border/60 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted/30"
>
取消
</button>
)}
<button
type="submit"
disabled={submitting || !content.trim()}
className="rounded-lg bg-primary text-white text-sm px-4 py-2 hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{submitting ? "提交中..." : isReply ? "回复" : "发表评论"}
</button>
</div>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
</form>
<LoginModal
open={showLoginModal}
onClose={() => setShowLoginModal(false)}
/>
</>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
import CommentCard from "@/components/common/CommentCard";
import CommentForm from "@/components/common/CommentForm";
import { UserLevel } from "@prisma/client";
interface CommentItem {
id: number;
content: string;
rating?: number;
likeCount: number;
isPinned: boolean;
createdAt: string;
updatedAt: string;
replyCount: number;
liked: boolean;
user: {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
};
replies?: {
id: number;
content: string;
likeCount: number;
createdAt: string;
liked: boolean;
user: {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
};
}[];
}
interface CommentThreadProps {
targetType: "tool" | "skill";
targetSlug: string;
targetId?: number;
}
export default function CommentThread({ targetType, targetSlug, targetId }: CommentThreadProps) {
const [comments, setComments] = useState<CommentItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [replyingTo, setReplyingTo] = useState<{ id: number; name: string } | null>(null);
const fetchComments = async () => {
setLoading(true);
try {
const idParam = targetType === "tool" ? "toolId" : "skillId";
const res = await fetch(`/api/comments?${idParam}=${targetId}&page=${page}&pageSize=10`);
const data = await res.json();
setComments(data.comments || []);
setTotalCount(data.totalCount || 0);
} catch (error) {
console.error("Failed to fetch comments:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (targetId) fetchComments();
}, [targetId, targetType, page]);
const handleNewComment = (comment: CommentItem) => {
setComments((prev) => [comment, ...prev]);
setTotalCount((prev) => prev + 1);
};
const handleLike = async (commentId: number) => {
try {
const res = await fetch(`/api/comments/${commentId}/like`, { method: "POST" });
if (res.ok) {
const data = await res.json();
setComments((prev) =>
prev.map((c) =>
c.id === commentId
? { ...c, liked: data.liked, likeCount: data.liked ? c.likeCount + 1 : c.likeCount - 1 }
: c
)
);
}
} catch (error) {
console.error("Failed to like comment:", error);
}
};
const handleReply = (parentId: number, replyToName: string) => {
setReplyingTo({ id: parentId, name: replyToName });
};
const handleReplySuccess = (reply: CommentItem) => {
setComments((prev) =>
prev.map((c) =>
c.id === replyingTo?.id
? { ...c, replies: [...(c.replies || []), reply], replyCount: c.replyCount + 1 }
: c
)
);
setReplyingTo(null);
};
if (!targetId) return null;
return (
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id}>
<CommentCard
comment={comment}
targetType={targetType}
targetSlug={targetSlug}
onReply={handleReply}
onLike={handleLike}
/>
{replyingTo?.id === comment.id && (
<div className="ml-8 mt-2 p-3 rounded-lg border border-border/40 bg-muted/20">
<CommentForm
targetType={targetType}
targetSlug={targetSlug}
parentId={replyingTo.id}
replyToName={replyingTo.name}
onSuccess={handleReplySuccess}
onCancel={() => setReplyingTo(null)}
/>
</div>
)}
</div>
))}
{totalCount > 10 && (
<div className="flex justify-center gap-2 pt-2">
{Array.from({ length: Math.ceil(totalCount / 10) }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
page === p
? "bg-primary text-white"
: "bg-white border border-border/40 text-muted-foreground hover:border-border/60"
}`}
>
{p}
</button>
))}
</div>
)}
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@
import { useState, useOptimistic } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Heart } from "lucide-react";
import { cn } from "@/lib/utils";
import { LoginModal } from "@/components/common/LoginModal";
@@ -17,6 +18,7 @@ export function FavoriteButton({ type, id, initialFavorited = false, className }
const { data: session, status } = useSession();
const [optimisticFav, addOptimisticFav] = useOptimistic(initialFavorited);
const [showLoginModal, setShowLoginModal] = useState(false);
const router = useRouter();
const handleClick = async () => {
if (status === "unauthenticated" || !session) {
@@ -33,8 +35,10 @@ export function FavoriteButton({ type, id, initialFavorited = false, className }
body: JSON.stringify({ [type === "tool" ? "toolId" : "skillId"]: id }),
});
if (!res.ok) throw new Error("操作失败");
router.refresh();
} catch {
addOptimisticFav(!newState);
router.refresh();
}
};
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useState, useEffect } from "react";
import { getLevelInfo } from "@/lib/level-config";
import { UserLevel } from "@prisma/client";
import MemberBadge from "@/components/common/MemberBadge";
import { TrendingUp, BarChart3, Activity } from "lucide-react";
interface LevelProgressProps {
level: UserLevel;
points: number;
}
export default function LevelProgress({ level, points }: LevelProgressProps) {
const info = getLevelInfo(points);
return (
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${info.bgColor}`}>
<TrendingUp className={`w-4 h-4 ${info.color}`} />
</div>
<MemberBadge level={info.level} points={info.points} size="md" />
</div>
<span className={`text-lg font-bold ${info.color}`}>{info.points}分</span>
</div>
{info.nextLevel && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">距离 {info.nextLevel.label} 还差</span>
<span className="font-medium text-foreground">{info.nextLevel.remaining}分</span>
</div>
<div className="h-2 rounded-full bg-muted/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${parseInt(info.nextLevel.level.replace("LV", "")) >= 15 ? "bg-amber-500" : parseInt(info.nextLevel.level.replace("LV", "")) >= 10 ? "bg-violet-500" : "bg-slate-400"}`}
style={{ width: `${info.progress}%` }}
/>
</div>
</div>
)}
{!info.nextLevel && (
<p className="text-sm text-center text-muted-foreground">🎉 已达到最高等级!</p>
)}
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
interface Props {
onSuccess: () => void;
socialLogin?: React.ReactNode;
}
export function LoginForm({ onSuccess, socialLogin }: Props) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
if (mode === "register") {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name: name || undefined }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "注册失败");
}
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) throw new Error("自动登录失败,请手动登录");
} else {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) throw new Error("邮箱或密码错误");
}
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="text-center">
<h2 className="text-xl font-bold">
{mode === "login" ? "欢迎回来" : "创建账号"}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{mode === "login" ? "登录以使用收藏等功能" : "注册后即可收藏喜欢的工具和技能"}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
{mode === "register" && (
<input
type="text"
placeholder="昵称(选填)"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
/>
)}
<input
type="email"
placeholder="邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
/>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 text-sm font-medium shadow-sm shadow-primary/20"
>
{loading ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
</form>
{socialLogin !== undefined && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/40" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-muted-foreground">或</span>
</div>
</div>
)}
{socialLogin}
<p className="text-sm text-center text-muted-foreground">
{mode === "login" ? "还没有账号?" : "已有账号?"}
<button
type="button"
onClick={() => {
setMode(mode === "login" ? "register" : "login");
setError("");
}}
className="ml-1 text-primary hover:underline font-medium"
>
{mode === "login" ? "去注册" : "去登录"}
</button>
</p>
</div>
);
}
+7 -128
View File
@@ -1,14 +1,11 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { LoginForm } from "@/components/common/LoginForm";
interface Props {
open: boolean;
@@ -16,139 +13,21 @@ interface Props {
}
export function LoginModal({ open, onClose }: Props) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const resetForm = () => {
setEmail("");
setPassword("");
setName("");
setError("");
setLoading(false);
};
const handleClose = () => {
resetForm();
setMode("login");
onClose();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
if (mode === "register") {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name: name || undefined }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "注册失败");
}
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) throw new Error("自动登录失败,请手动登录");
} else {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) throw new Error("邮箱或密码错误");
}
handleClose();
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-md border-border/60 bg-white shadow-[0_8px_32px_rgba(0,0,0,0.1)]">
<DialogHeader>
<DialogTitle className="text-center text-xl">
{mode === "login" ? "欢迎回来" : "创建账号"}
</DialogTitle>
</DialogHeader>
<p className="text-center text-sm text-muted-foreground -mt-2">
{mode === "login" ? "登录以使用收藏等功能" : "注册后即可收藏喜欢的工具和技能"}
</p>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
{mode === "register" && (
<input
type="text"
placeholder="昵称(选填)"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
/>
)}
<input
type="email"
placeholder="邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
/>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 text-sm font-medium shadow-sm shadow-primary/20"
>
{loading ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
</form>
<p className="text-sm text-center text-muted-foreground">
{mode === "login" ? "还没有账号?" : "已有账号?"}
<button
type="button"
onClick={() => {
setMode(mode === "login" ? "register" : "login");
setError("");
}}
className="ml-1 text-primary hover:underline font-medium"
>
{mode === "login" ? "去注册" : "去登录"}
</button>
</p>
<LoginForm
onSuccess={() => {
handleClose();
router.refresh();
}}
/>
</DialogContent>
</Dialog>
);
+28
View File
@@ -0,0 +1,28 @@
import { getLevelInfo } from "@/lib/level-config";
import { UserLevel } from "@prisma/client";
interface MemberBadgeProps {
level: UserLevel;
points: number;
size?: "sm" | "md" | "lg";
showPoints?: boolean;
}
export default function MemberBadge({ level, points, size = "sm", showPoints = true }: MemberBadgeProps) {
const levelInfo = getLevelInfo(points);
const sizes = {
sm: "px-2 py-0.5 text-[10px]",
md: "px-2.5 py-1 text-xs",
lg: "px-3 py-1.5 text-sm",
};
return (
<span
className={`inline-flex items-center gap-1 rounded-full border font-medium ${levelInfo.bgColor} ${levelInfo.color} ${levelInfo.borderColor} ${sizes[size]}`}
>
<span>{levelInfo.icon}</span>
<span>{levelInfo.label}</span>
{showPoints && <span className="opacity-60">{points}分</span>}
</span>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { Share2, Copy, CheckCircle, Link, MessageCircle } from "lucide-react";
interface ShareButtonProps {
url?: string;
title?: string;
}
export default function ShareButton({ url, title }: ShareButtonProps) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const shareUrl = url || (typeof window !== "undefined" ? window.location.href : "");
const shareTitle = title || "追光AI - AI工具与技能发现平台";
const canShare = typeof navigator !== "undefined" && "share" in navigator;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
const input = document.createElement("input");
input.value = shareUrl;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleNativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({ title: shareTitle, url: shareUrl });
} catch (e) {
}
} else {
handleCopy();
}
};
return (
<>
<button
onClick={() => setOpen(!open)}
className="p-2 rounded-full transition-colors hover:bg-muted/50"
title="分享"
>
<Share2 className="w-4 h-4 text-muted-foreground" />
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-8 z-50 w-72 rounded-xl border border-border/50 bg-white shadow-xl overflow-hidden">
<div className="px-4 py-3 border-b border-border/40">
<h3 className="text-sm font-semibold">分享</h3>
</div>
<div className="p-4 space-y-3">
<button
onClick={handleNativeShare}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary text-white text-sm hover:bg-primary/90 transition-colors"
>
<Share2 className="w-4 h-4" />
{canShare ? "分享到..." : "复制链接"}
</button>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => {
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareTitle)}&url=${encodeURIComponent(shareUrl)}`, "_blank");
}}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-sky-50 text-sky-700 border border-sky-200/60 text-xs hover:bg-sky-100 transition-colors"
>
<Link className="w-3.5 h-3.5" />
Twitter
</button>
<button
onClick={() => {
window.open(`https://connect.qq.com/share?title=${encodeURIComponent(shareTitle)}&url=${encodeURIComponent(shareUrl)}`, "_blank");
}}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-50 text-blue-700 border border-blue-200/60 text-xs hover:bg-blue-100 transition-colors"
>
<MessageCircle className="w-3.5 h-3.5" />
QQ
</button>
</div>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 border border-border/40">
<input
readOnly
value={shareUrl}
className="flex-1 bg-transparent text-xs text-muted-foreground outline-none truncate"
/>
<button
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 flex-shrink-0"
>
{copied ? (
<>
<CheckCircle className="w-3 h-3" />
已复制
</>
) : (
<>
<Copy className="w-3 h-3" />
复制
</>
)}
</button>
</div>
</div>
</div>
</>
)}
</>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { MessageSquare, TrendingUp } from "lucide-react";
interface ForumBoardData {
id: number;
name: string;
slug: string;
description?: string;
icon?: string;
topicCount: number;
}
interface ForumBoardProps {
board: ForumBoardData;
}
const ICON_MAP: Record<string, string> = {
"general": "💬",
"ai-tools": "🤖",
"skills": "⚡",
"tech": "💻",
"tutorials": "📚",
"jobs": "💼",
"feedback": "📢",
"random": "🎲",
};
export default function ForumBoard({ board }: ForumBoardProps) {
const icon = board.icon || ICON_MAP[board.slug] || "📌";
return (
<Link href={`/community/${board.slug}`}>
<div className="flex items-center gap-4 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)] group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/5 text-2xl">
{icon}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold group-hover:text-primary transition-colors">{board.name}</h3>
{board.description && (
<p className="mt-0.5 text-xs text-muted-foreground/60 truncate">{board.description}</p>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground/40 flex-shrink-0">
<div className="flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5" />
<span>{board.topicCount}</span>
</div>
</div>
<svg className="w-4 h-4 text-muted-foreground/30 group-hover:text-primary group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { LoginModal } from "@/components/common/LoginModal";
interface ForumEditorProps {
onSubmit: (content: string) => void;
placeholder?: string;
maxLength?: number;
submitText?: string;
disabled?: boolean;
}
export default function ForumEditor({
onSubmit,
placeholder = "写下你的想法...",
maxLength = 10000,
submitText = "发布",
disabled = false,
}: ForumEditorProps) {
const { data: session, status } = useSession();
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (status !== "authenticated") {
setShowLoginModal(true);
return;
}
const trimmed = content.trim();
if (!trimmed) {
setError("内容不能为空");
return;
}
if (trimmed.length > maxLength) {
setError(`内容不能超过${maxLength}个字符`);
return;
}
setSubmitting(true);
setError("");
try {
await onSubmit(trimmed);
setContent("");
} catch (err) {
setError(err instanceof Error ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
};
return (
<>
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<textarea
value={content}
onChange={(e) => {
setContent(e.target.value);
if (error) setError("");
}}
placeholder={placeholder}
rows={4}
maxLength={maxLength}
disabled={disabled}
className="w-full resize-none rounded-t-xl border-0 bg-transparent px-4 py-3 text-sm focus:outline-none focus:ring-0 placeholder:text-muted-foreground/40 disabled:opacity-50"
/>
<div className="flex items-center justify-between border-t border-border/40 px-4 py-2">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground/50">
{content.length}/{maxLength}
</span>
{error && (
<span className="text-xs text-destructive">{error}</span>
)}
</div>
<button
onClick={handleSubmit}
disabled={submitting || !content.trim() || disabled}
className="rounded-lg bg-primary text-white text-sm px-4 py-2 hover:bg-primary/90 disabled:opacity-50 transition-colors shadow-sm shadow-primary/20"
>
{submitting ? "发布中..." : submitText}
</button>
</div>
</div>
<LoginModal open={showLoginModal} onClose={() => setShowLoginModal(false)} />
</>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { MessageSquare, TrendingUp, Clock, Pin } from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import { UserLevel } from "@prisma/client";
interface TopicUser {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
}
interface TopicCategory {
name: string;
slug: string;
icon?: string;
}
interface Topic {
id: number;
title: string;
slug: string;
isPinned: boolean;
isLocked: boolean;
viewCount: number;
replyCount: number;
lastReplyAt: string | null;
createdAt: string;
category: TopicCategory;
user: TopicUser;
}
interface ForumTopicCardProps {
topic: Topic;
}
function getTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
return date.toLocaleDateString("zh-CN");
}
export default function ForumTopicCard({ topic }: ForumTopicCardProps) {
const initial = topic.user.name.charAt(0).toUpperCase();
return (
<Link href={`/community/topic/${topic.id}`}>
<div className="flex items-start gap-3 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)] group">
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-9 w-9 rounded-full border border-border/40 object-cover flex-shrink-0" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary flex-shrink-0">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
{topic.isPinned && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">
<Pin className="w-2.5 h-2.5" />
置顶
</span>
)}
{topic.isLocked && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-muted-foreground bg-muted/30 px-1.5 py-0.5 rounded-full">
已锁定
</span>
)}
<h3 className="text-sm font-medium group-hover:text-primary transition-colors truncate">
{topic.title}
</h3>
</div>
<div className="mt-1.5 flex items-center gap-3 flex-wrap text-xs text-muted-foreground/50">
<span className="inline-flex items-center gap-1">
<span className={`px-1.5 py-0.5 rounded bg-muted/30 text-[10px]`}>{topic.category.name}</span>
</span>
<span>{topic.user.name}</span>
<MemberBadge level={topic.user.level} points={topic.user.points} size="sm" showPoints={false} />
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{getTimeAgo(new Date(topic.createdAt))}
</span>
<span className="flex items-center gap-1 ml-auto">
<MessageSquare className="w-3 h-3" />
{topic.replyCount}
</span>
<span className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
{topic.viewCount}
</span>
</div>
{topic.lastReplyAt && (
<p className="mt-1 text-[10px] text-muted-foreground/30">
最后回复: {getTimeAgo(new Date(topic.lastReplyAt))}
</p>
)}
</div>
</div>
</Link>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useSession } from "next-auth/react";
import { Bell } from "lucide-react";
import { LoginModal } from "@/components/common/LoginModal";
interface Notif {
id: number;
type: string;
title: string;
content?: string;
link?: string;
isRead: boolean;
createdAt: string;
}
function getTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString("zh-CN");
}
export default function NotificationBell() {
const { data: session, status } = useSession();
const [open, setOpen] = useState(false);
const [notifications, setNotifications] = useState<Notif[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showLoginModal, setShowLoginModal] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const fetchNotifications = async () => {
try {
const res = await fetch("/api/notifications?pageSize=5");
if (res.ok) {
const data = await res.json();
setNotifications(data.notifications || []);
setUnreadCount(data.unreadCount || 0);
}
} catch (e) {
console.error("Failed to fetch notifications:", e);
}
};
useEffect(() => {
if (status === "authenticated") {
fetchNotifications();
}
}, [status]);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const handleClick = () => {
if (status !== "authenticated") {
setShowLoginModal(true);
return;
}
setOpen(!open);
if (!open && unreadCount > 0) {
fetch("/api/notifications", { method: "PUT" }).then(() => {
setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
});
}
};
const handleNotifClick = (notif: Notif) => {
setOpen(false);
if (notif.link) {
window.location.href = notif.link;
}
};
return (
<>
<div className="relative" ref={ref}>
<button
onClick={handleClick}
className="relative p-2 rounded-full transition-colors hover:bg-muted/50"
>
<Bell className="w-5 h-5 text-muted-foreground" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 h-4 w-4 rounded-full bg-red-500 text-[10px] font-bold text-white flex items-center justify-center">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 mt-2 w-80 rounded-xl border border-border/50 bg-white shadow-xl z-50 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40">
<h3 className="text-sm font-semibold">通知</h3>
</div>
{notifications.length === 0 ? (
<div className="py-8 text-center">
<p className="text-xs text-muted-foreground/50">暂无通知</p>
</div>
) : (
<div className="max-h-80 overflow-y-auto">
{notifications.map((notif) => (
<button
key={notif.id}
onClick={() => handleNotifClick(notif)}
className={`w-full text-left px-4 py-3 border-b border-border/30 transition-colors hover:bg-muted/20 ${
!notif.isRead ? "bg-primary/[0.02]" : ""
}`}
>
<p className={`text-xs ${!notif.isRead ? "font-medium" : ""}`}>{notif.title}</p>
{notif.content && (
<p className="mt-0.5 text-[10px] text-muted-foreground/50 truncate">{notif.content}</p>
)}
<p className="mt-1 text-[10px] text-muted-foreground/30">{getTimeAgo(new Date(notif.createdAt))}</p>
</button>
))}
</div>
)}
<div className="px-4 py-2 border-t border-border/40">
<button
onClick={() => {
setOpen(false);
window.location.href = "/user/notifications";
}}
className="w-full text-center text-xs text-primary hover:text-primary/80"
>
查看全部通知
</button>
</div>
</div>
)}
</div>
<LoginModal open={showLoginModal} onClose={() => setShowLoginModal(false)} />
</>
);
}
+16 -1
View File
@@ -4,14 +4,17 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { useSession, signOut } from "next-auth/react";
import { Heart, User, LogOut } from "lucide-react";
import { Heart, User, LogOut, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import NotificationBell from "@/components/forum/NotificationBell";
import MemberBadge from "@/components/common/MemberBadge";
const navLinks = [
{ label: "AI工具", href: "/", icon: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" },
{ label: "AI技能", href: "/skills", icon: "M13 10V3L4 14h7v7l9-11h-7z" },
{ label: "AI评测", href: "/reviews", icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" },
{ label: "AI日报", href: "/daily", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" },
{ 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" },
];
export default function Header() {
@@ -110,6 +113,7 @@ export default function Header() {
{status === "authenticated" && session?.user ? (
<div className="hidden items-center gap-3 sm:flex">
<NotificationBell />
<Link
href="/user/favorites"
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
@@ -126,6 +130,9 @@ export default function Header() {
{session.user.name || session.user.email}
</span>
</Link>
{session.user.level && (
<MemberBadge level={session.user.level} points={session.user.points || 0} size="sm" showPoints={false} />
)}
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
@@ -217,6 +224,14 @@ export default function Header() {
<div className="mt-2 border-t border-border/60 pt-2">
{status === "authenticated" && session?.user ? (
<>
<Link
href="/community"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<MessageCircle className="h-4 w-4" />
社区
</Link>
<Link
href="/user/favorites"
onClick={() => setMobileOpen(false)}
+44 -42
View File
@@ -2,15 +2,7 @@ import { getServerSession } from "next-auth";
import { authOptions } from "./auth";
import { NextResponse } from "next/server";
import { prisma } from "./prisma";
interface PermissionCache {
roleId: number;
permissionKeys: Set<string>;
timestamp: number;
}
let _permissionCache: PermissionCache | null = null;
const CACHE_TTL = 30_000;
import { cache } from "react";
export async function getUserPermissionKeys(userId: number): Promise<Set<string>> {
const user = await prisma.user.findUnique({
@@ -32,51 +24,61 @@ export async function getUserPermissionKeys(userId: number): Promise<Set<string>
return new Set(keys);
}
export async function requireAdmin() {
const cachedGetUserPermissionKeys = cache(getUserPermissionKeys);
interface AdminSession {
userId: number;
userRole: string;
isAdmin: boolean;
permissionKeys: Set<string> | null;
}
const getAdminSession = cache(async (): Promise<AdminSession | null> => {
const session = await getServerSession(authOptions);
if (!session?.user) {
if (!session?.user) return null;
const userId = (session.user as { id?: number; role?: string }).id;
const userRole = (session.user as { role?: string }).role || "";
if (userRole === "admin") {
return { userId: userId || 0, userRole, isAdmin: true, permissionKeys: null };
}
if (!userId) return null;
const perms = await getUserPermissionKeys(userId);
return {
userId,
userRole,
isAdmin: perms.size > 0,
permissionKeys: perms,
};
});
export async function requireAdmin() {
const adminSession = await getAdminSession();
if (!adminSession) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const userId = (session.user as { id?: number; role?: string }).id;
const userRole = (session.user as { role?: string }).role;
if (userRole === "admin") return null;
if (userRole !== "admin" && userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
assignedRole: {
include: {
permissions: { include: { permission: true } },
},
},
},
});
if (user?.assignedRole?.permissions.length) {
return null;
}
}
if (adminSession.isAdmin) return null;
return NextResponse.json({ error: "无管理员权限" }, { status: 403 });
}
export async function requirePermission(key: string) {
const response = await requireAdmin();
if (response) return response;
const adminSession = await getAdminSession();
const session = await getServerSession(authOptions);
const userId = (session!.user as { id?: number; role?: string }).id;
const userRole = (session!.user as { role?: string }).role;
if (userRole === "admin") return null;
if (userId) {
const perms = await getUserPermissionKeys(userId);
if (perms.has(key)) return null;
if (!adminSession) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
if (adminSession.userRole === "admin") return null;
if (adminSession.permissionKeys?.has(key)) return null;
return NextResponse.json({ error: `缺少权限: ${key}` }, { status: 403 });
}
+29
View File
@@ -4,6 +4,22 @@ import GithubProvider from "next-auth/providers/github";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_LOGIN_ATTEMPTS = 7;
const LOGIN_WINDOW_MS = 60_000;
function checkLoginRateLimit(email: string): boolean {
const now = Date.now();
const record = loginAttempts.get(email);
if (!record || now > record.resetAt) {
loginAttempts.set(email, { count: 1, resetAt: now + LOGIN_WINDOW_MS });
return true;
}
if (record.count >= MAX_LOGIN_ATTEMPTS) return false;
record.count++;
return true;
}
export const authOptions = {
providers: [
CredentialsProvider({
@@ -15,6 +31,10 @@ export const authOptions = {
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
if (!checkLoginRateLimit(credentials.email)) {
throw new Error("登录尝试过于频繁,请1分钟后再试");
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
@@ -27,11 +47,15 @@ export const authOptions = {
);
if (!isValid) return null;
loginAttempts.delete(credentials.email);
return {
id: user.id.toString(),
email: user.email,
name: user.name,
role: user.role,
level: user.level,
points: user.points,
};
},
}),
@@ -79,6 +103,8 @@ export const authOptions = {
if (user) {
token.id = user.id;
token.role = user.role;
token.level = user.level;
token.points = user.points;
}
return token;
},
@@ -86,6 +112,8 @@ export const authOptions = {
if (session.user) {
session.user.id = token.id;
session.user.role = token.role;
session.user.level = token.level;
session.user.points = token.points;
}
return session;
},
@@ -95,6 +123,7 @@ export const authOptions = {
},
session: {
strategy: "jwt" as const,
maxAge: 30 * 24 * 60 * 60,
},
secret: process.env.NEXTAUTH_SECRET,
};
+94
View File
@@ -0,0 +1,94 @@
import { UserLevel } from "@prisma/client";
const LEVEL_CONFIG = [
{ level: UserLevel.LV01, minPoints: 0, label: "LV1", icon: "⭐", color: "text-slate-600", bgColor: "bg-slate-50", borderColor: "border-slate-200/60" },
{ level: UserLevel.LV02, minPoints: 10, label: "LV2", icon: "⭐", color: "text-slate-600", bgColor: "bg-slate-50", borderColor: "border-slate-200/60" },
{ level: UserLevel.LV03, minPoints: 30, label: "LV3", icon: "⭐⭐", color: "text-amber-700", bgColor: "bg-amber-50", borderColor: "border-amber-200/60" },
{ level: UserLevel.LV04, minPoints: 60, label: "LV4", icon: "⭐⭐", color: "text-amber-700", bgColor: "bg-amber-50", borderColor: "border-amber-200/60" },
{ level: UserLevel.LV05, minPoints: 100, label: "LV5", icon: "⭐⭐⭐", color: "text-amber-700", bgColor: "bg-amber-50", borderColor: "border-amber-200/60" },
{ level: UserLevel.LV06, minPoints: 200, label: "LV6", icon: "⭐⭐⭐", color: "text-yellow-700", bgColor: "bg-yellow-50", borderColor: "border-yellow-200/60" },
{ level: UserLevel.LV07, minPoints: 350, label: "LV7", icon: "⭐⭐⭐⭐", color: "text-yellow-700", bgColor: "bg-yellow-50", borderColor: "border-yellow-200/60" },
{ level: UserLevel.LV08, minPoints: 600, label: "LV8", icon: "⭐⭐⭐⭐", color: "text-yellow-700", bgColor: "bg-yellow-50", borderColor: "border-yellow-200/60" },
{ level: UserLevel.LV09, minPoints: 1000, label: "LV9", icon: "⭐⭐⭐⭐⭐", color: "text-orange-600", bgColor: "bg-orange-50", borderColor: "border-orange-200/60" },
{ level: UserLevel.LV10, minPoints: 1500, label: "LV10", icon: "⭐⭐⭐⭐⭐", color: "text-orange-600", bgColor: "bg-orange-50", borderColor: "border-orange-200/60" },
{ level: UserLevel.LV11, minPoints: 2200, label: "LV11", icon: "🌟⭐", color: "text-rose-600", bgColor: "bg-rose-50", borderColor: "border-rose-200/60" },
{ level: UserLevel.LV12, minPoints: 3000, label: "LV12", icon: "🌟⭐", color: "text-rose-600", bgColor: "bg-rose-50", borderColor: "border-rose-200/60" },
{ level: UserLevel.LV13, minPoints: 4000, label: "LV13", icon: "🌟🌟⭐", color: "text-rose-700", bgColor: "bg-rose-50", borderColor: "border-rose-200/60" },
{ level: UserLevel.LV14, minPoints: 5200, label: "LV14", icon: "🌟🌟⭐", color: "text-violet-700", bgColor: "bg-violet-50", borderColor: "border-violet-200/60" },
{ level: UserLevel.LV15, minPoints: 6600, label: "LV15", icon: "🌟🌟🌟⭐", color: "text-violet-700", bgColor: "bg-violet-50", borderColor: "border-violet-200/60" },
{ level: UserLevel.LV16, minPoints: 8200, label: "LV16", icon: "🌟🌟🌟⭐", color: "text-purple-700", bgColor: "bg-purple-50", borderColor: "border-purple-200/60" },
{ level: UserLevel.LV17, minPoints: 10000, label: "LV17", icon: "🌟🌟🌟🌟⭐", color: "text-purple-700", bgColor: "bg-purple-50", borderColor: "border-purple-200/60" },
{ level: UserLevel.LV18, minPoints: 12500, label: "LV18", icon: "🌟🌟🌟🌟⭐", color: "text-purple-700", bgColor: "bg-purple-50", borderColor: "border-purple-200/60" },
{ level: UserLevel.LV19, minPoints: 15500, label: "LV19", icon: "🌟🌟🌟🌟🌟⭐", color: "text-indigo-700", bgColor: "bg-indigo-50", borderColor: "border-indigo-200/60" },
{ level: UserLevel.LV20, minPoints: 20000, label: "LV20", icon: "👑", color: "text-indigo-700", bgColor: "bg-indigo-50", borderColor: "border-indigo-200/60" },
];
export const POINTS_RULES = {
comment: 5,
comment_like: 2,
forum_topic: 10,
forum_post: 5,
forum_post_like: 2,
review: 8,
daily_login: 1,
share: 10,
};
export const DAILY_LIMITS = {
comment: 50,
forum_post: 30,
review: 20,
comment_like: 20,
forum_post_like: 20,
share: 50,
};
export function getLevelInfo(points: number) {
let current = LEVEL_CONFIG[0];
let next: typeof LEVEL_CONFIG[0] | null = null;
for (const cfg of LEVEL_CONFIG) {
if (points >= cfg.minPoints) {
current = cfg;
}
}
const idx = LEVEL_CONFIG.findIndex((c) => c.level === current.level);
if (idx < LEVEL_CONFIG.length - 1) {
next = LEVEL_CONFIG[idx + 1];
}
return {
level: current.level,
label: current.label,
icon: current.icon,
color: current.color,
bgColor: current.bgColor,
borderColor: current.borderColor,
points,
nextLevel: next ? {
level: next.level,
label: next.label,
icon: next.icon,
minPoints: next.minPoints,
remaining: next.minPoints - points,
} : null,
progress: next
? Math.min(((points - current.minPoints) / (next.minPoints - current.minPoints)) * 100, 100)
: 100,
};
}
export function getReasonLabel(reason: string): string {
const labels: Record<string, string> = {
comment: "发表评论",
comment_like: "评论被点赞",
forum_topic: "发布话题",
forum_post: "回复话题",
forum_post_like: "回复被点赞",
review: "发表评价",
daily_login: "每日登录",
share: "分享空间",
};
return labels[reason] || reason;
}
+112
View File
@@ -0,0 +1,112 @@
import { prisma } from "@/lib/prisma";
import { POINTS_RULES, DAILY_LIMITS, getLevelInfo, getReasonLabel } from "@/lib/level-config";
export { getLevelInfo, getReasonLabel };
async function getTodayEarned(userId: number, reason: string): Promise<number> {
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(startOfDay);
endOfDay.setDate(endOfDay.getDate() + 1);
const result = await prisma.pointsHistory.aggregate({
where: {
userId,
reason,
createdAt: {
gte: startOfDay,
lt: endOfDay,
},
},
_sum: {
points: true,
},
});
return result._sum.points || 0;
}
export async function rewardPoints(
userId: number,
reason: keyof typeof POINTS_RULES,
detail?: string,
refType?: string,
refId?: number
): Promise<{ success: boolean; points: number; message: string }> {
const points = POINTS_RULES[reason];
const dailyLimit = DAILY_LIMITS[reason as keyof typeof DAILY_LIMITS];
if (dailyLimit) {
const todayEarned = await getTodayEarned(userId, reason);
if (todayEarned >= dailyLimit) {
return {
success: false,
points: 0,
message: `今日${reason}积分已达上限(${dailyLimit})`,
};
}
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: { points: { increment: points } },
});
await tx.pointsHistory.create({
data: {
userId,
points,
reason,
detail: detail || null,
refType: refType || null,
refId: refId || null,
},
});
});
return { success: true, points, message: `+${points}积分` };
}
export async function getUserPointsHistory(
userId: number,
page = 1,
pageSize = 20
) {
const [history, total] = await Promise.all([
prisma.pointsHistory.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.pointsHistory.count({ where: { userId } }),
]);
return { history, total, page, pageSize };
}
export async function getUserStats(userId: number) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
points: true,
level: true,
comments: { select: { id: true } },
forumTopics: { select: { id: true } },
forumPosts: { select: { id: true } },
reviews: { select: { id: true } },
},
});
if (!user) return null;
return {
points: user.points,
level: getLevelInfo(user.points),
commentCount: user.comments.length,
topicCount: user.forumTopics.length,
postCount: user.forumPosts.length,
reviewCount: user.reviews.length,
};
}
+13
View File
@@ -0,0 +1,13 @@
const ipCounts = new Map<string, { count: number; resetAt: number }>();
export function rateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
const now = Date.now();
const record = ipCounts.get(ip);
if (!record || now > record.resetAt) {
ipCounts.set(ip, { count: 1, resetAt: now + windowMs });
return true;
}
if (record.count >= maxRequests) return false;
record.count++;
return true;
}
+112
View File
@@ -0,0 +1,112 @@
import { z } from "zod";
export const registerSchema = z.object({
email: z.string().email("邮箱格式不正确"),
password: z.string().min(6, "密码至少6位").max(128, "密码最多128位"),
name: z.string().max(100, "昵称最多100字符").optional(),
});
export const loginSchema = z.object({
email: z.string().email("邮箱格式不正确"),
password: z.string().min(1, "密码不能为空"),
});
export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
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("")),
logoUrl: z.string().optional(),
pricingModel: z.enum(["free", "freemium", "paid", "subscription"]).optional(),
pricingDetail: z.string().optional(),
features: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
})).min(1, "tools 数组不能为空"),
categoryId: z.number().int().positive("categoryId 必须为正整数"),
});
export const skillImportSchema = z.object({
skills: z.array(z.object({
name: z.string().min(1, "名称不能为空"),
description: z.string().optional(),
sourceUrl: z.string().optional(),
sourceType: z.string().optional(),
rating: z.number().min(0).max(5).optional(),
features: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
})).min(1, "skills 数组不能为空"),
categoryId: z.number().int().positive("categoryId 必须为正整数"),
});
export const newsImportSchema = z.object({
items: z.array(z.object({
title: z.string().min(1, "标题不能为空"),
summary: z.string().optional(),
detail: z.string().optional(),
category: z.string().optional(),
sourceUrl: z.string().optional(),
hotScore: z.number().min(0).max(100).optional(),
})).min(1, "items 数组不能为空"),
});
export const checkTypeSchema = z.object({
type: z.enum(["published", "pending"], { message: "type 必须为 published 或 pending" }),
});
export const profileUpdateSchema = z.object({
name: z.string().max(100, "昵称最多100字符").optional().nullable(),
});
export const favoriteSchema = z.object({
toolId: z.number().int().positive().optional(),
skillId: z.number().int().positive().optional(),
}).refine((data) => data.toolId || data.skillId, {
message: "必须提供 toolId 或 skillId",
});
export const historySchema = z.object({
toolId: z.number().int().positive().optional(),
skillId: z.number().int().positive().optional(),
}).refine((data) => data.toolId || data.skillId, {
message: "必须提供 toolId 或 skillId",
});
export const taskRunSchema = z.object({
taskKey: z.string().min(1, "taskKey 不能为空"),
});
export const roleCreateSchema = z.object({
name: z.string().min(1, "角色名称不能为空"),
slug: z.string().min(1, "角色标识不能为空"),
permissionIds: z.array(z.number().int().positive()).optional(),
});
export const categoryCreateSchema = z.object({
name: z.string().min(1, "名称不能为空"),
slug: z.string().optional(),
icon: z.string().optional(),
parentId: z.number().int().positive().optional().nullable(),
sortOrder: z.number().int().min(0).optional(),
});
export const skillCategoryCreateSchema = z.object({
name: z.string().min(1, "名称不能为空"),
slug: z.string().min(1, "Slug不能为空"),
icon: z.string().optional(),
parentId: z.number().int().positive().optional().nullable(),
sortOrder: z.number().int().min(0).optional(),
});
export const systemConfigUpdateSchema = z.record(z.string(), z.unknown());
export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
export type ToolImportInput = z.infer<typeof toolImportSchema>;
export type SkillImportInput = z.infer<typeof skillImportSchema>;
export type NewsImportInput = z.infer<typeof newsImportSchema>;
export type CheckTypeInput = z.infer<typeof checkTypeSchema>;
+8
View File
@@ -10,12 +10,20 @@ export default withAuth(
if (!token) {
return NextResponse.redirect(new URL("/admin/login", req.url));
}
const role = (token as { role?: string }).role;
if (role !== "admin" && role !== "vip") {
return NextResponse.redirect(new URL("/admin/login?error=unauthorized", req.url));
}
}
if (path.startsWith("/api/admin")) {
if (!token) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const role = (token as { role?: string }).role;
if (role !== "admin" && role !== "vip") {
return NextResponse.json({ error: "无管理员权限" }, { status: 403 });
}
}
},
{
+29
View File
@@ -0,0 +1,29 @@
import { DefaultSession } from "next-auth";
import { UserLevel } from "@prisma/client";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
level?: UserLevel;
points?: number;
} & DefaultSession["user"];
}
interface User {
id: string;
role: string;
level?: UserLevel;
points?: number;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
level?: UserLevel;
points?: number;
}
}