feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库

This commit is contained in:
ZhuiGuangAI Dev
2026-10-02 18:52:49 +08:00
parent b44601bb66
commit 8205ae709c
1185 changed files with 49841 additions and 12802 deletions
@@ -19,6 +19,7 @@ import {
TrendingUp,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LazyImage } from "@/components/ui/LazyImage";
interface SourceMetrics {
replyCount: number;
@@ -268,7 +269,7 @@ export default function BotAdversarialLearningClient() {
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-slate-50"
>
{l.botAvatar ? (
<img src={l.botAvatar} alt={l.botName} className="w-8 h-8 rounded-full object-cover" />
<LazyImage src={l.botAvatar} alt={l.botName} width={32} height={32} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-violet-100 text-violet-600 flex items-center justify-center text-sm font-medium">
{l.botName?.charAt(0) || "?"}
@@ -1,7 +1,8 @@
import { Suspense } from "react";
import BotAdversarialLearningClient from "./BotAdversarialLearningClient";
import dynamic from "next/dynamic";
const BotAdversarialLearningClient = dynamic(() => import("./BotAdversarialLearningClient"), { ssr: false });
export const dynamic = "force-dynamic";
export const metadata = {
title: "Bot 对抗学习看板 · 管理后台",
};
+3 -2
View File
@@ -1,7 +1,8 @@
import { Suspense } from "react";
import BotAnalyticsClient from "./BotAnalyticsClient";
import dynamic from "next/dynamic";
const BotAnalyticsClient = dynamic(() => import("./BotAnalyticsClient"), { ssr: false });
export const dynamic = "force-dynamic";
export const metadata = {
title: "Bot 表现分析 · 管理后台",
};
@@ -23,6 +23,7 @@ import {
Sigma,
} from "lucide-react";
import BotAvatar from "@/components/forum/BotAvatar";
import { LazyImage } from "@/components/ui/LazyImage";
import { cn } from "@/lib/utils";
interface VariantDaily {
@@ -330,7 +331,7 @@ export default function BotExperimentsClient() {
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-slate-50"
>
{a.botAvatar ? (
<img src={a.botAvatar} alt={a.botName} className="w-8 h-8 rounded-full object-cover" />
<LazyImage src={a.botAvatar} alt={a.botName} width={32} height={32} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-violet-100 text-violet-600 flex items-center justify-center text-sm font-medium">
{a.botName?.charAt(0) || "?"}
@@ -533,7 +534,7 @@ export default function BotExperimentsClient() {
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
{v.botAvatar ? (
<img src={v.botAvatar} alt={v.botName} className="w-5 h-5 rounded-full object-cover" />
<LazyImage src={v.botAvatar} alt={v.botName} width={20} height={20} className="w-5 h-5 rounded-full object-cover" />
) : (
<div className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 flex items-center justify-center text-[10px]">
{v.botName?.charAt(0) || "?"}
@@ -1,7 +1,8 @@
import { Suspense } from "react";
import BotExperimentsClient from "./BotExperimentsClient";
import dynamic from "next/dynamic";
const BotExperimentsClient = dynamic(() => import("./BotExperimentsClient"), { ssr: false });
export const dynamic = "force-dynamic";
export const metadata = {
title: "Bot Persona A/B 看板 · 管理后台",
};
@@ -0,0 +1,557 @@
"use client";
import { useEffect, useState } from "react";
import {
Bot,
FileText,
MessageSquare,
Users,
Heart,
Brain,
TrendingUp,
AlertCircle,
Activity,
Clock,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface BotQualityData {
period: { last24h: boolean; last7d: boolean };
content: {
topics24h: number;
topics7d: number;
replies24h: number;
replies7d: number;
avgRepliesPerTopic24h: string;
};
bots: {
total: number;
active24h: number;
activityRate: string;
};
quality: {
engagementRate: string;
topicsWithHumanReplies: number;
totalTopicsAnalyzed: number;
};
memory: { total: number };
timestamp: string;
}
function MetricCard({
icon: Icon,
label,
value,
sub,
color = "text-primary",
accent = "from-primary/5 to-card",
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string | number;
sub?: string;
color?: string;
accent?: string;
}) {
return (
<div
className={cn(
"rounded-xl border bg-gradient-to-br p-4 shadow-sm",
accent
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">{label}</span>
<Icon className={cn("h-4 w-4", color)} />
</div>
<div className={cn("text-2xl font-bold tabular-nums", color)}>
{value}
</div>
{sub && (
<div className="mt-1 text-[10px] text-muted-foreground/60">{sub}</div>
)}
</div>
);
}
function ProgressBar({
value,
max = 100,
color = "bg-primary",
label,
}: {
value: number;
max?: number;
color?: string;
label?: string;
}) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
return (
<div className="space-y-1">
{label && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{pct.toFixed(0)}%</span>
</div>
)}
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", color)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function relativeTime(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins}分钟前`;
if (hours < 24) return `${hours}小时前`;
return `${days}天前`;
}
export default function BotQualityClient() {
const [data, setData] = useState<BotQualityData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const fetchData = async () => {
try {
setLoading(true);
const res = await fetch("/api/admin/bot-quality");
if (!res.ok) throw new Error("Failed to fetch");
setData(await res.json());
setError("");
} catch (e) {
setError("加载失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 120000);
return () => clearInterval(interval);
}, []);
if (loading && !data) {
return (
<div className="p-6 lg:p-8 space-y-6">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-xl bg-muted animate-pulse" />
<div className="space-y-1.5">
<div className="h-5 w-40 bg-muted rounded animate-pulse" />
<div className="h-3.5 w-24 bg-muted rounded animate-pulse" />
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="rounded-xl border p-4 animate-pulse space-y-2"
>
<div className="h-3 w-16 bg-muted rounded" />
<div className="h-7 w-12 bg-muted rounded" />
<div className="h-2.5 w-20 bg-muted rounded" />
</div>
))}
</div>
</div>
);
}
if (error && !data) {
return (
<div className="p-6 lg:p-8 text-center py-16">
<AlertCircle className="h-10 w-10 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={fetchData}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm"
>
重试
</button>
</div>
);
}
if (!data) return null;
const avgReplies7d =
data.content.topics7d > 0
? (data.content.replies7d / data.content.topics7d).toFixed(1)
: "0";
return (
<div className="p-6 lg:p-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-100 to-teal-100">
<Activity className="h-5 w-5 text-emerald-600" />
</div>
Bot 内容质量监控
</h1>
<p className="mt-2 text-sm text-muted-foreground flex items-center gap-2">
<Clock className="h-3.5 w-3.5" />
数据更新于 {relativeTime(data.timestamp)}
</p>
</div>
</div>
{/* Row 1: Content Volume — 24h vs 7d */}
<section>
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<FileText className="h-4 w-4 text-sky-500" />
内容产出量
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<MetricCard
icon={FileText}
label="24h 话题"
value={data.content.topics24h}
sub={`7天: ${data.content.topics7d} 帖`}
color="text-sky-600"
accent="from-sky-50 to-white"
/>
<MetricCard
icon={MessageSquare}
label="24h 回复"
value={data.content.replies24h}
sub={`7天: ${data.content.replies7d} 回复`}
color="text-emerald-600"
accent="from-emerald-50 to-white"
/>
<MetricCard
icon={TrendingUp}
label="日均话题 (7d)"
value={Math.round(data.content.topics7d / 7)}
sub={`每帖均回复 ${data.content.avgRepliesPerTopic24h}`}
color="text-blue-600"
accent="from-blue-50 to-white"
/>
<MetricCard
icon={TrendingUp}
label="日均回复 (7d)"
value={Math.round(data.content.replies7d / 7)}
sub={`7天均每帖 ${avgReplies7d} 回复`}
color="text-teal-600"
accent="from-teal-50 to-white"
/>
</div>
</section>
{/* Row 2: Bot Activity & Quality */}
<section>
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Bot className="h-4 w-4 text-purple-500" />
Bot 活跃度 & 质量
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricCard
icon={Users}
label="总 Bot 数"
value={data.bots.total}
sub={`24h 活跃: ${data.bots.active24h} 个`}
color="text-purple-600"
accent="from-purple-50 to-white"
/>
<div className="rounded-xl border bg-gradient-to-br from-amber-50 to-white p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Bot 活跃率</span>
<Activity className="h-4 w-4 text-amber-500" />
</div>
<div className="text-2xl font-bold text-amber-600 tabular-nums">
{data.bots.activityRate}%
</div>
<div className="mt-3">
<ProgressBar
value={parseFloat(data.bots.activityRate)}
color="bg-amber-500"
/>
</div>
</div>
<div className="rounded-xl border bg-gradient-to-br from-rose-50 to-white p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">用户互动率</span>
<Heart className="h-4 w-4 text-rose-500" />
</div>
<div className="text-2xl font-bold text-rose-600 tabular-nums">
{data.quality.engagementRate}
</div>
<div className="mt-1 text-[10px] text-muted-foreground/60">
真人回复 {data.quality.topicsWithHumanReplies} / 总帖{" "}
{data.quality.totalTopicsAnalyzed}
</div>
<div className="mt-2">
<ProgressBar
value={parseFloat(data.quality.engagementRate)}
color="bg-rose-500"
/>
</div>
</div>
</div>
</section>
{/* Row 3: Detailed Breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Content Ratio */}
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
<Activity className="h-4 w-4 text-sky-500" />
内容结构分析
</h3>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span>话题/回复比 (24h)</span>
<span className="font-medium tabular-nums">
{data.content.topics24h} : {data.content.replies24h}
</span>
</div>
<div className="flex h-6 rounded-full overflow-hidden">
{data.content.topics24h + data.content.replies24h > 0 && (
<>
<div
className="bg-sky-400 transition-all flex items-center justify-center text-[9px] text-white font-medium"
style={{
width: `${
(data.content.topics24h /
(data.content.topics24h + data.content.replies24h)) *
100
}%`,
}}
>
{data.content.topics24h} 帖
</div>
<div
className="bg-emerald-400 transition-all flex items-center justify-center text-[9px] text-white font-medium"
style={{
width: `${
(data.content.replies24h /
(data.content.topics24h + data.content.replies24h)) *
100
}%`,
}}
>
{data.content.replies24h} 回复
</div>
</>
)}
</div>
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-muted-foreground/60">
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-sm bg-sky-400" /> 话题
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-sm bg-emerald-400" /> 回复
</span>
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span>话题/回复比 (7天)</span>
<span className="font-medium tabular-nums">
{data.content.topics7d} : {data.content.replies7d}
</span>
</div>
<div className="flex h-6 rounded-full overflow-hidden">
{data.content.topics7d + data.content.replies7d > 0 && (
<>
<div
className="bg-blue-400 transition-all flex items-center justify-center text-[9px] text-white font-medium"
style={{
width: `${
(data.content.topics7d /
(data.content.topics7d + data.content.replies7d)) *
100
}%`,
}}
>
{data.content.topics7d} 帖
</div>
<div
className="bg-teal-400 transition-all flex items-center justify-center text-[9px] text-white font-medium"
style={{
width: `${
(data.content.replies7d /
(data.content.topics7d + data.content.replies7d)) *
100
}%`,
}}
>
{data.content.replies7d} 回复
</div>
</>
)}
</div>
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-muted-foreground/60">
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-sm bg-blue-400" /> 话题
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-sm bg-teal-400" /> 回复
</span>
</div>
</div>
</div>
</div>
{/* Memory & Health */}
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
<Brain className="h-4 w-4 text-violet-500" />
Bot 记忆库 & 健康概要
</h3>
<div className="grid grid-cols-2 gap-3">
<MetricCard
icon={Brain}
label="记忆总量"
value={data.memory.total}
sub="条Bot记忆"
color="text-violet-600"
accent="from-violet-50 to-white"
/>
<MetricCard
icon={Heart}
label="真人互动率"
value={data.quality.engagementRate}
sub={`${data.quality.topicsWithHumanReplies} 帖有真人回复`}
color="text-rose-600"
accent="from-rose-50 to-white"
/>
</div>
<div className="mt-4 space-y-3">
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">平均记忆/Bot</span>
<span className="font-medium tabular-nums">
{data.bots.total > 0
? Math.round(data.memory.total / data.bots.total)
: 0}{" "}
条
</span>
</div>
<ProgressBar
value={
data.bots.total > 0
? Math.min(
(data.memory.total / data.bots.total / 100) * 100,
100
)
: 0
}
color="bg-violet-500"
/>
</div>
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">平均回复/帖</span>
<span className="font-medium tabular-nums">
{data.content.avgRepliesPerTopic24h}
</span>
</div>
<ProgressBar
value={Math.min(
parseFloat(data.content.avgRepliesPerTopic24h) * 20,
100
)}
color="bg-emerald-500"
/>
</div>
</div>
</div>
</div>
{/* Row 4: Summary */}
<div className="rounded-2xl border bg-gradient-to-r from-sky-50 via-white to-emerald-50 p-5 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">综合评估</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
<div>
<span className="text-muted-foreground">内容健康度: </span>
<span
className={cn(
"font-medium",
parseFloat(data.content.avgRepliesPerTopic24h) >= 3
? "text-emerald-600"
: parseFloat(data.content.avgRepliesPerTopic24h) >= 1
? "text-amber-600"
: "text-rose-600"
)}
>
{parseFloat(data.content.avgRepliesPerTopic24h) >= 3
? "优秀"
: parseFloat(data.content.avgRepliesPerTopic24h) >= 1
? "一般"
: "偏低"}
</span>
</div>
<div>
<span className="text-muted-foreground">活跃率: </span>
<span
className={cn(
"font-medium",
parseFloat(data.bots.activityRate) >= 50
? "text-emerald-600"
: parseFloat(data.bots.activityRate) >= 30
? "text-amber-600"
: "text-rose-600"
)}
>
{parseFloat(data.bots.activityRate) >= 50
? "优秀"
: parseFloat(data.bots.activityRate) >= 30
? "一般"
: "偏低"}
</span>
</div>
<div>
<span className="text-muted-foreground">互动率: </span>
<span
className={cn(
"font-medium",
parseFloat(data.quality.engagementRate) >= 15
? "text-emerald-600"
: parseFloat(data.quality.engagementRate) >= 5
? "text-amber-600"
: "text-rose-600"
)}
>
{parseFloat(data.quality.engagementRate) >= 15
? "优秀"
: parseFloat(data.quality.engagementRate) >= 5
? "一般"
: "偏低"}
</span>
</div>
<div>
<span className="text-muted-foreground">产出稳定性: </span>
<span
className={cn(
"font-medium",
data.content.topics24h > 0 && data.content.replies24h > 0
? "text-emerald-600"
: "text-rose-600"
)}
>
{data.content.topics24h > 0 && data.content.replies24h > 0
? "正常"
: "暂停"}
</span>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { Suspense } from "react";
import dynamic from "next/dynamic";
const BotQualityClient = dynamic(() => import("./BotQualityClient"), { ssr: false });
export const metadata = {
title: "Bot 内容质量 · 管理后台",
};
export default function BotQualityPage() {
return (
<Suspense
fallback={
<div className="p-6 lg:p-8 text-center text-sm text-muted-foreground">加载中...</div>
}
>
<BotQualityClient />
</Suspense>
);
}
+2 -1
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { LazyImage } from "@/components/ui/LazyImage";
interface ToolItem {
id: number;
@@ -331,7 +332,7 @@ export default function CheckToolsPage() {
{logoFetchedTools.map((r, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border border-violet-500/10 bg-white px-4 py-2.5">
{r.logoUrl && (
<img src={r.logoUrl} alt="" className="h-8 w-8 rounded border border-border/30 shrink-0" />
<LazyImage src={r.logoUrl} alt="" width={32} height={32} className="h-8 w-8 rounded border border-border/30 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">{r.name}</p>
@@ -51,7 +51,7 @@ export default function TopicsClient() {
}
const d = await res.json();
setTopics(d.topics || []);
setTotalCount(d.totalCount || 0);
setTotalCount(d.total || 0);
} catch {
setError("网络错误");
} finally {
+2 -1
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LazyImage } from "@/components/ui/LazyImage";
interface MenuItem {
label: string;
@@ -169,7 +170,7 @@ export default function AdminLayout({
<div className="flex min-h-screen">
<aside className="fixed left-0 top-0 z-30 flex h-screen w-[260px] flex-col border-r border-border/50 bg-white shadow-[1px_0_3px_rgba(0,0,0,0.04)]">
<div className="flex h-[68px] items-center gap-3 border-b border-border/50 px-5">
<img src="/logo.png" alt="追光AI" className="h-9 w-9 rounded-xl" />
<LazyImage src="/logo.png" alt="追光AI" width={36} height={36} className="h-9 w-9 rounded-xl" />
<div className="flex flex-col">
<span className="text-sm font-bold tracking-tight">
追光<span className="gradient-text">AI</span>
+2 -1
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense } from "react";
import { getCsrfToken } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { LazyImage } from "@/components/ui/LazyImage";
function LoginForm() {
const searchParams = useSearchParams();
@@ -65,7 +66,7 @@ function LoginForm() {
<div className="relative w-full max-w-sm animate-fade-in">
<div className="mb-8 text-center">
<div className="mx-auto mb-5 flex justify-center animate-float">
<img src="/logo.png" alt="追光AI" className="h-14 w-14" />
<LazyImage src="/logo.png" alt="追光AI" width={56} height={56} className="h-14 w-14" />
</div>
<h1 className="text-2xl font-bold tracking-tight">
追光<span className="gradient-text">AI</span>
+21
View File
@@ -0,0 +1,21 @@
import { Metadata } from "next";
import OverviewDashboard from "@/components/admin/OverviewDashboard";
export const metadata: Metadata = {
title: "管理总览 | 追光AI",
description: "追光AI 管理后台 — 系统健康、Bot活跃度、社区数据一站式总览",
};
export default function AdminPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">管理总览</h1>
<p className="text-sm text-muted-foreground mt-1">
系统健康监控 · Bot活跃度 · 社区数据 · 任务执行状态
</p>
</div>
<OverviewDashboard />
</div>
);
}
@@ -10,6 +10,8 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { LazyImage } from "@/components/ui/LazyImage";
interface PendingSkill {
id: number;
name: string;
@@ -543,7 +545,7 @@ export default function PendingSkillsPage() {
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{skill.logoUrl ? (
<img src={skill.logoUrl} referrerPolicy="no-referrer" alt="" className="h-6 w-6 rounded border border-border/50 object-contain p-0.5" />
<LazyImage src={skill.logoUrl} referrerPolicy="no-referrer" alt="" width={48} height={48} className="h-6 w-6 rounded border border-border/50 object-contain p-0.5" />
) : (
<div className="flex h-6 w-6 items-center justify-center rounded border border-border/50 bg-gradient-to-br from-violet-500/15 to-violet-500/5 text-[9px] font-semibold text-violet-600">
{skill.name.charAt(0)}
+5 -1
View File
@@ -10,6 +10,8 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { LazyImage } from "@/components/ui/LazyImage";
interface PendingTool {
id: number;
name: string;
@@ -707,10 +709,12 @@ export default function PendingPage() {
</td>
<td className="px-4 py-3">
{tool.logoUrl ? (
<img
<LazyImage
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
width={48}
height={48}
className="h-8 w-8 rounded-lg border border-border/50 bg-white object-contain p-0.5"
/>
) : (
+6 -9
View File
@@ -9,6 +9,8 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { SOURCE_TYPE_LABELS } from "@/lib/source-type";
import { LazyImage } from "@/components/ui/LazyImage";
interface Skill {
id: number;
@@ -141,13 +143,6 @@ export default function SkillsPage() {
return str.length > len ? str.substring(0, len) + "..." : str;
};
const sourceTypeLabels: Record<string, string> = {
github: "GitHub",
gitlab: "GitLab",
huggingface: "HuggingFace",
other: "其他",
};
const totalPages = Math.ceil(total / pageSize);
return (
@@ -291,10 +286,12 @@ export default function SkillsPage() {
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
{skill.logoUrl ? (
<img
<LazyImage
src={skill.logoUrl}
referrerPolicy="no-referrer"
alt=""
width={48}
height={48}
className="h-9 w-9 rounded-lg border border-border/50 bg-white object-contain p-0.5"
/>
) : (
@@ -320,7 +317,7 @@ export default function SkillsPage() {
</td>
<td className="px-5 py-3.5">
<span className="inline-flex items-center gap-1 rounded-lg border border-violet-200/60 bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-600">
{sourceTypeLabels[skill.sourceType] || skill.sourceType}
{SOURCE_TYPE_LABELS[skill.sourceType] || skill.sourceType}
</span>
</td>
<td className="px-5 py-3.5 text-muted-foreground">{skill.viewCount}</td>
+68 -5
View File
@@ -1,7 +1,8 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardContent } from "@/components/ui/card";
import TaskStatsSummary from "@/components/admin/TaskStatsSummary";
interface TaskItem {
taskKey: string;
@@ -31,11 +32,48 @@ const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> =
running: { label: "运行中", color: "text-blue-700", bg: "bg-blue-50" },
};
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
if (diffMs < 0) return "刚刚";
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}天前`;
if (hours > 0) return `${hours}小时前`;
if (minutes > 0) return `${minutes}分钟前`;
return "刚刚";
}
function getLastRun(logs: TaskLogItem[] | null, taskKey: string) {
if (!logs) return null;
return logs.find((l) => l.taskKey === taskKey) || null;
}
function getSuccessRate24h(logs: TaskLogItem[], taskKey: string): number {
const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
const taskLogs = logs.filter((l) => l.taskKey === taskKey && new Date(l.startedAt).getTime() >= dayAgo);
if (taskLogs.length === 0) return 0;
const completed = taskLogs.filter((l) => l.status === "completed").length;
return Math.round((completed / taskLogs.length) * 100);
}
function getSuccessRateColor(rate: number): string {
if (rate === 0) return "text-muted-foreground/50";
if (rate >= 90) return "text-emerald-600";
if (rate >= 70) return "text-amber-600";
return "text-red-600";
}
function getSuccessRateBg(rate: number): string {
if (rate === 0) return "bg-muted/20";
if (rate >= 90) return "bg-emerald-50";
if (rate >= 70) return "bg-amber-50";
return "bg-red-50";
}
export default function TasksPage() {
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [logs, setLogs] = useState<TaskLogItem[]>([]);
@@ -48,7 +86,7 @@ export default function TasksPage() {
try {
const [tRes, lRes] = await Promise.all([
fetch("/api/admin/tasks/configs"),
fetch("/api/admin/tasks/logs?pageSize=7"),
fetch("/api/admin/tasks/logs?pageSize=200"),
]);
if (tRes.ok) setTasks((await tRes.json()).tasks || []);
if (lRes.ok) setLogs((await lRes.json()).logs || []);
@@ -141,6 +179,21 @@ export default function TasksPage() {
);
}
const stats = useMemo(() => {
const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
const logs24h = logs.filter((l) => new Date(l.startedAt).getTime() >= dayAgo);
const totalRuns = logs24h.length;
const completed = logs24h.filter((l) => l.status === "completed").length;
const failed = logs24h.filter((l) => l.status === "failed").length;
const lastLog = logs.length > 0 ? logs[0] : null;
return {
totalTasks: tasks.length,
successRate24h: totalRuns > 0 ? Math.round((completed / totalRuns) * 100 * 10) / 10 : 0,
failedCount24h: failed,
lastRunAt: lastLog?.startedAt || null,
};
}, [tasks, logs]);
return (
<div className="p-6 lg:p-8">
<div className="mb-8">
@@ -158,6 +211,8 @@ export default function TasksPage() {
</p>
</div>
{!loading && <TaskStatsSummary stats={stats} />}
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">核心定时任务</span>
<span className="h-px flex-1 bg-border/40" />
@@ -203,7 +258,7 @@ export default function TasksPage() {
</div>
{lastLog && (
<div className="mt-1 text-[9px] text-muted-foreground/60">
{new Date(lastLog.startedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
{formatRelativeTime(lastLog.startedAt)}
</div>
)}
</CardContent>
@@ -233,7 +288,7 @@ export default function TasksPage() {
</div>
{lastLog && (
<div className="mt-1 text-[9px] text-muted-foreground/60">
{new Date(lastLog.startedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
{formatRelativeTime(lastLog.startedAt)}
</div>
)}
</CardContent>
@@ -246,6 +301,9 @@ export default function TasksPage() {
{tasks.map((task) => {
const lastLog = getLastRun(logs, task.taskKey);
const st = lastLog ? STATUS_MAP[lastLog.status] : null;
const successRate = getSuccessRate24h(logs, task.taskKey);
const rateColor = getSuccessRateColor(successRate);
const rateBg = getSuccessRateBg(successRate);
return (
<Card key={task.taskKey} className="border-border/60 bg-white shadow-sm hover:shadow-md transition-shadow">
@@ -260,13 +318,18 @@ export default function TasksPage() {
{st.label}
</span>
)}
{successRate > 0 && (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${rateBg} ${rateColor}`}>
24h: {successRate}%
</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>
<span>上次: {formatRelativeTime(lastLog.startedAt)}</span>
{lastLog.duration && <span>耗时: {(lastLog.duration / 1000).toFixed(1)}s</span>}
</>
)}
+60 -2
View File
@@ -10,6 +10,8 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { LazyImage } from "@/components/ui/LazyImage";
interface Tool {
id: number;
name: string;
@@ -137,6 +139,19 @@ export default function ToolsAdminPage() {
const [isNew, setIsNew] = useState(false);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [verifiedToolIds, setVerifiedToolIds] = useState<Set<number>>(new Set());
const fetchVerifiedTools = useCallback(async () => {
try {
const res = await fetch("/api/admin/tools/verify");
const data = await res.json();
if (data.verifiedTools) {
const ids = new Set<number>();
(data.verifiedTools as { toolId: number }[]).forEach((v) => ids.add(v.toolId));
setVerifiedToolIds(ids);
}
} catch { /* ignore */ }
}, []);
const fetchTools = useCallback(async () => {
setError("");
@@ -173,7 +188,8 @@ export default function ToolsAdminPage() {
useEffect(() => {
fetchTools();
fetchCategories();
}, [fetchTools, fetchCategories]);
fetchVerifiedTools();
}, [fetchTools, fetchCategories, fetchVerifiedTools]);
const openEdit = (tool: Tool) => {
setIsNew(false);
@@ -247,6 +263,30 @@ export default function ToolsAdminPage() {
}
};
const handleVerifyToggle = async (toolId: number) => {
try {
const res = await fetch("/api/admin/tools/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toolId }),
});
const data = await res.json();
if (data.success) {
setVerifiedToolIds((prev) => {
const next = new Set(prev);
if (data.verified) {
next.add(toolId);
} else {
next.delete(toolId);
}
return next;
});
}
} catch {
console.error("Verify toggle failed");
}
};
const publishedCount = tools.filter((t) => t.status === "published").length;
const draftCount = tools.filter((t) => t.status === "draft").length;
const totalViews = tools.reduce((sum, t) => sum + t.viewCount, 0);
@@ -403,10 +443,12 @@ export default function ToolsAdminPage() {
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
{tool.logoUrl ? (
<img
<LazyImage
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
width={48}
height={48}
className="h-9 w-9 rounded-lg border border-border/50 bg-white object-contain p-0.5"
/>
) : (
@@ -441,6 +483,22 @@ export default function ToolsAdminPage() {
</td>
<td className="px-5 py-3.5">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleVerifyToggle(tool.id)}
className={`gap-1.5 rounded-lg text-xs ${
verifiedToolIds.has(tool.id)
? "border-blue-300/60 bg-blue-50 text-blue-600 hover:bg-blue-100"
: "border-border/50"
}`}
title={verifiedToolIds.has(tool.id) ? "取消验证" : "标记为已验证"}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 13l4 4L19 7" />
</svg>
{verifiedToolIds.has(tool.id) ? "已验证" : "验证"}
</Button>
<Button
size="sm"
variant="outline"
+52 -35
View File
@@ -12,14 +12,24 @@ interface SkillItem {
categoryId: number | null;
}
interface StarChange {
id: number;
name: string;
sourceType: string;
oldRating: number;
newRating: number;
}
type TabType = "published" | "pending";
const BATCH_SIZE = 20; // 与后端分批接口对齐:单批 20 个,前端循环调用,避免整体请求超过 Nginx 300s 超时
export default function UpdateSkillsPage() {
const [activeTab, setActiveTab] = useState<TabType>("published");
const [skills, setSkills] = useState<SkillItem[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [updateResults, setUpdateResults] = useState<{ name: string; oldStars: number; newStars: number }[]>([]);
const [updateResults, setUpdateResults] = useState<StarChange[]>([]);
const [progress, setProgress] = useState(0);
const [progressTotal, setProgressTotal] = useState(0);
@@ -59,24 +69,31 @@ export default function UpdateSkillsPage() {
setProgress(0);
setProgressTotal(0);
try {
const res = await fetch("/api/admin/update-stars", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: activeTab }),
});
if (!res.ok) {
const errData = await res.json();
alert(errData.error || "更新失败");
setUpdating(false);
return;
}
const data = await res.json();
const allResults: { id: number; name: string; oldStars: number; newStars: number }[] = data.results || [];
setProgressTotal(allResults.length);
for (let i = 0; i < allResults.length; i++) {
await new Promise((resolve) => setTimeout(resolve, 20));
setUpdateResults((prev) => [...prev, allResults[i]]);
setProgress(i + 1);
const accumulated: StarChange[] = [];
let offset = 0;
let total = 0;
// 分批循环:每批 BATCH_SIZE 个,后端按 id 升序分页,前端逐批累加结果
for (let batchNo = 0; batchNo < 200; batchNo++) {
const res = await fetch("/api/admin/update-stars", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: activeTab, offset, limit: BATCH_SIZE }),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
alert(errData.error || "更新失败");
break;
}
const data = await res.json();
total = typeof data.total === "number" ? data.total : total;
const batch: StarChange[] = data.results || [];
accumulated.push(...batch);
setUpdateResults([...accumulated]);
setProgressTotal(total);
offset = typeof data.nextOffset === "number" ? data.nextOffset : offset + (data.processed || BATCH_SIZE);
setProgress(Math.min(offset, total));
if (!data.hasMore) break;
}
} catch {
alert("更新请求失败");
@@ -85,9 +102,9 @@ export default function UpdateSkillsPage() {
}
};
const increasedCount = updateResults.filter((r) => r.newStars > r.oldStars).length;
const decreasedCount = updateResults.filter((r) => r.newStars < r.oldStars).length;
const unchangedCount = updateResults.filter((r) => r.newStars === r.oldStars).length;
const increasedCount = updateResults.filter((r) => r.newRating > r.oldRating).length;
const decreasedCount = updateResults.filter((r) => r.newRating < r.oldRating).length;
const unchangedCount = updateResults.filter((r) => r.newRating === r.oldRating).length;
return (
<div className="animate-fade-in">
@@ -158,7 +175,7 @@ export default function UpdateSkillsPage() {
{updating ? (
<><svg className="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>更新中...</>
) : (
<><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>快速更新Star</>
<><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>快速更新星级</>
)}
</Button>
</div>
@@ -166,7 +183,7 @@ export default function UpdateSkillsPage() {
{updating && progressTotal > 0 && (
<div className="mb-6 rounded-2xl border border-border/50 bg-white p-4">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-muted-foreground">正在更新Star数...</span>
<span className="text-muted-foreground">正在更新星级...(每批 {BATCH_SIZE} 个)</span>
<span className="font-medium text-foreground">{progress} / {progressTotal}</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted/30">
@@ -184,11 +201,11 @@ export default function UpdateSkillsPage() {
</div>
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<p className="text-2xl font-bold text-emerald-600">{increasedCount}</p>
<p className="text-xs text-emerald-600/60">Star上升</p>
<p className="text-xs text-emerald-600/60">星级上升</p>
</div>
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
<p className="text-2xl font-bold text-red-600">{decreasedCount}</p>
<p className="text-xs text-red-600/60">Star下降</p>
<p className="text-xs text-red-600/60">星级下降</p>
</div>
<div className="rounded-xl border border-border/50 bg-white p-4">
<p className="text-2xl font-bold text-muted-foreground">{unchangedCount}</p>
@@ -201,29 +218,29 @@ export default function UpdateSkillsPage() {
<thead>
<tr className="border-b border-border/50">
<th className="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">技能名</th>
<th className="px-5 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">旧Star</th>
<th className="px-5 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">新Star</th>
<th className="px-5 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">旧星级</th>
<th className="px-5 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">新星级</th>
<th className="px-5 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">变化</th>
</tr>
</thead>
<tbody>
{updateResults.map((result) => {
const diff = result.newStars - result.oldStars;
const diff = result.newRating - result.oldRating;
const change = diff > 0 ? "up" : diff < 0 ? "down" : "same";
return (
<tr key={result.name} className="border-b border-border/40 hover:bg-muted/30 transition-all">
<tr key={result.id} className="border-b border-border/40 hover:bg-muted/30 transition-all">
<td className="px-5 py-3"><span className="font-medium text-foreground">{result.name}</span></td>
<td className="px-5 py-3 text-right"><span className="text-muted-foreground">{result.oldStars.toLocaleString()}</span></td>
<td className="px-5 py-3 text-right"><span className="font-medium text-foreground">{result.newStars.toLocaleString()}</span></td>
<td className="px-5 py-3 text-right"><span className="text-muted-foreground">{result.oldRating.toFixed(1)}</span></td>
<td className="px-5 py-3 text-right"><span className="font-medium text-foreground">{result.newRating.toFixed(1)}</span></td>
<td className="px-5 py-3 text-center">
{change === "up" && (
<span className="inline-flex items-center gap-1 rounded-lg border border-emerald-200/60 bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-600">
↑ +{diff.toLocaleString()}
↑ +{diff.toFixed(1)}
</span>
)}
{change === "down" && (
<span className="inline-flex items-center gap-1 rounded-lg border border-red-200/60 bg-red-50 px-2 py-0.5 text-[10px] font-medium text-red-600">
↓ {diff.toLocaleString()}
↓ {diff.toFixed(1)}
</span>
)}
{change === "same" && (
@@ -247,7 +264,7 @@ export default function UpdateSkillsPage() {
</svg>
</div>
<p className="mt-5 text-sm font-medium text-muted-foreground">尚未开始更新</p>
<p className="mt-1 text-xs text-muted-foreground/60">点击「快速更新Star」按钮自动同步所有技能的星级评分</p>
<p className="mt-1 text-xs text-muted-foreground/60">点击「快速更新星级」按钮自动同步所有技能的星级评分(分批更新,每批 20 个)</p>
</div>
)}
</div>
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect } from "react";
export default function DevelopersError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Developers error:", error);
}, [error]);
return (
<div className="container py-20 text-center">
<h2 className="text-xl font-bold mb-4">加载失败</h2>
<p className="text-muted-foreground mb-6">{error.message}</p>
<button
onClick={() => reset()}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
重试
</button>
</div>
);
}
+393
View File
@@ -0,0 +1,393 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "开发者平台 | 追光AI",
description: "追光AI 开放 API 平台 - 免费获取 AI 工具、技能和搜索数据,用于构建 AI 导航、工具推荐等应用。",
};
const endpoints = [
{
method: "GET",
path: "/api/v1/tools",
description: "获取工具列表,支持分页、搜索和分类筛选",
},
{
method: "GET",
path: "/api/v1/tools/{slug}",
description: "获取单个工具详细信息",
},
{
method: "GET",
path: "/api/v1/skills",
description: "获取技能列表,支持分页、搜索和分类筛选",
},
{
method: "GET",
path: "/api/v1/skills/{slug}",
description: "获取单个技能详细信息",
},
{
method: "GET",
path: "/api/v1/search",
description: "搜索工具、技能和社区话题",
},
{
method: "GET",
path: "/api/v1/collections",
description: "获取公开工具清单列表",
},
{
method: "GET",
path: "/api/v1/collections/{publicSlug}",
description: "获取单个公开清单详情",
},
];
const curlExamples: Record<string, string> = {
"GET /api/v1/tools": `curl "https://www.zhuig.com/api/v1/tools?page=1&pageSize=10&search=AI&category=text-generation"`,
"GET /api/v1/tools/{slug}": `curl "https://www.zhuig.com/api/v1/tools/chatgpt"`,
"GET /api/v1/skills": `curl "https://www.zhuig.com/api/v1/skills?page=1&pageSize=10"`,
"GET /api/v1/skills/{slug}": `curl "https://www.zhuig.com/api/v1/skills/react-hook-form"`,
"GET /api/v1/search": `curl "https://www.zhuig.com/api/v1/search?q=AI&limit=5"`,
"GET /api/v1/collections": `curl "https://www.zhuig.com/api/v1/collections?page=1&pageSize=20"`,
"GET /api/v1/collections/{publicSlug}": `curl "https://www.zhuig.com/api/v1/collections/my-ai-tools"`,
};
const responseExamples: Record<string, string> = {
"GET /api/v1/tools": `{
"data": [
{
"id": 1,
"name": "ChatGPT",
"slug": "chatgpt",
"description": "OpenAI 开发的对话式 AI 助手",
"logoUrl": "https://example.com/logo.png",
"websiteUrl": "https://chat.openai.com",
"pricingModel": "freemium",
"category": { "id": 1, "name": "文本生成", "slug": "text-generation" },
"tags": ["AI", "对话"],
"avgRating": 4.5,
"viewCount": 1200
}
],
"pagination": {
"page": 1,
"pageSize": 10,
"total": 100,
"totalPages": 10
}
}`,
"GET /api/v1/tools/{slug}": `{
"data": {
"id": 1,
"name": "ChatGPT",
"slug": "chatgpt",
"description": "OpenAI 开发的对话式 AI 助手",
"logoUrl": "https://example.com/logo.png",
"websiteUrl": "https://chat.openai.com",
"pricingModel": "freemium",
"features": ["文本生成", "代码辅助"],
"screenshots": [{ "url": "https://example.com/shot1.png", "alt": "主界面" }],
"tags": ["AI", "对话"],
"viewCount": 1200,
"avgRating": 4.5,
"category": { "id": 1, "name": "文本生成", "slug": "text-generation" },
"_count": { "favorites": 45 }
}
}`,
"GET /api/v1/skills": `{
"data": [
{
"id": 1,
"name": "React Hook Form",
"slug": "react-hook-form",
"description": "高性能 React 表单库",
"logoUrl": "https://example.com/logo.png",
"sourceUrl": "https://github.com/react-hook-form/react-hook-form",
"sourceType": "github",
"githubStars": 42000,
"category": { "id": 1, "name": "代码生成", "slug": "code-generation" },
"tags": ["React", "Form"],
"avgRating": 4.8,
"viewCount": 800
}
],
"pagination": {
"page": 1,
"pageSize": 10,
"total": 50,
"totalPages": 5
}
}`,
"GET /api/v1/search": `{
"results": [
{
"type": "tool",
"id": 1,
"title": "ChatGPT",
"slug": "chatgpt",
"description": "OpenAI 开发的对话式 AI 助手",
"logoUrl": "https://example.com/logo.png",
"category": "文本生成"
},
{
"type": "skill",
"id": 1,
"title": "React Hook Form",
"slug": "react-hook-form",
"description": "高性能 React 表单库",
"logoUrl": "https://example.com/logo.png",
"category": "代码生成"
},
{
"type": "topic",
"id": 1,
"title": "2024年最值得关注的AI工具",
"slug": "best-ai-tools-2024",
"category": "AI工具推荐"
}
],
"took": 120
}`,
};
export default function DevelopersPage() {
return (
<div className="space-y-12">
<section>
<h1 className="text-3xl font-bold tracking-tight">开发者平台</h1>
<p className="mt-3 text-muted-foreground leading-relaxed max-w-2xl">
追光AI 开放 API 为开发者免费提供 AI 工具、技能和搜索数据。
你可以通过这些 API 构建 AI 导航站、工具推荐应用、Chrome 插件等。
</p>
</section>
<section>
<h2 className="text-xl font-semibold mb-3">基础 URL</h2>
<div className="rounded-lg border border-border/60 bg-muted/30 px-4 py-3 font-mono text-sm">
https://www.zhuig.com/api/v1
</div>
</section>
<section id="auth">
<h2 className="text-xl font-semibold mb-3">认证</h2>
<div className="rounded-xl border border-border/60 p-5 space-y-4 text-sm text-muted-foreground">
<p>
所有 API 端点均可匿名调用,但受 IP 速率限制。
使用 API Key 可以获得更高的速率限制和请求额度。
</p>
<div>
<h3 className="text-sm font-medium text-foreground mb-2">获取 API Key</h3>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>登录 <Link href="/login" className="text-primary hover:underline">追光AI</Link></li>
<li>前往 <Link href="/user" className="text-primary hover:underline">个人中心</Link> → API Key 管理</li>
<li>点击「创建 API Key」,选择套餐等级</li>
<li>复制生成的 API Key(仅显示一次,请妥善保存)</li>
</ol>
</div>
<div>
<h3 className="text-sm font-medium text-foreground mb-2">使用方式</h3>
<p className="mb-2">在请求头中添加 Authorization 字段:</p>
<div className="rounded-lg border border-border/60 bg-gray-900 px-4 py-3 font-mono text-xs text-gray-100 overflow-x-auto">
Authorization: Bearer zg_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div>
</div>
<div>
<h3 className="text-sm font-medium text-foreground mb-2">cURL 示例(带认证)</h3>
<div className="rounded-lg border border-border/60 bg-gray-900 px-4 py-3 font-mono text-xs text-gray-100 overflow-x-auto">
curl -H &quot;Authorization: Bearer YOUR_API_KEY&quot; &quot;https://www.zhuig.com/api/v1/tools&quot;
</div>
</div>
</div>
</section>
<section id="endpoints">
<h2 className="text-xl font-semibold mb-4">API 端点</h2>
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/40 bg-muted/30">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">方法</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">路径</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">描述</th>
</tr>
</thead>
<tbody>
{endpoints.map((ep) => (
<tr key={ep.path} className="border-b border-border/40 last:border-b-0">
<td className="px-4 py-3">
<span className="inline-flex rounded bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
{ep.method}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs">{ep.path}</td>
<td className="px-4 py-3 text-muted-foreground">{ep.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="space-y-8">
<h2 className="text-xl font-semibold">接口详情</h2>
{endpoints.map((ep) => (
<div key={ep.path} className="space-y-3">
<h3 className="text-lg font-medium">
<span className="inline-flex rounded bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 mr-2">
{ep.method}
</span>
<code className="text-base">{ep.path}</code>
</h3>
<p className="text-sm text-muted-foreground">{ep.description}</p>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">cURL 示例</p>
<div className="rounded-lg border border-border/60 bg-gray-900 px-4 py-3 font-mono text-xs text-gray-100 overflow-x-auto">
{curlExamples[`${ep.method} ${ep.path}`]}
</div>
</div>
{responseExamples[`${ep.method} ${ep.path}`] && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">响应示例</p>
<div className="rounded-lg border border-border/60 bg-gray-900 px-4 py-3 font-mono text-xs text-gray-100 overflow-x-auto whitespace-pre">
{responseExamples[`${ep.method} ${ep.path}`]}
</div>
</div>
)}
</div>
))}
</section>
<section id="rate-limits">
<h2 className="text-xl font-semibold mb-3">速率限制</h2>
<div className="rounded-xl border border-border/60 p-5 space-y-4 text-sm text-muted-foreground">
<div>
<h3 className="text-sm font-medium text-foreground mb-2">匿名访问</h3>
<p>• 每个 IP 每分钟最多 <strong className="text-foreground">60</strong> 次请求</p>
<p>• 搜索接口每分钟最多 <strong className="text-foreground">30</strong> 次请求</p>
</div>
<div>
<h3 className="text-sm font-medium text-foreground mb-2">API Key 认证</h3>
<div className="overflow-x-auto rounded-lg border border-border/40">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/40 bg-muted/30">
<th className="px-4 py-2 text-left font-medium text-muted-foreground">套餐</th>
<th className="px-4 py-2 text-left font-medium text-muted-foreground">每小时限额</th>
<th className="px-4 py-2 text-left font-medium text-muted-foreground">Key 数量</th>
<th className="px-4 py-2 text-left font-medium text-muted-foreground">价格</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/40">
<td className="px-4 py-2.5">
<span className="inline-flex rounded border border-slate-200 bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
免费版
</span>
</td>
<td className="px-4 py-2.5">100 次/时</td>
<td className="px-4 py-2.5">最多 3 个</td>
<td className="px-4 py-2.5 text-emerald-600 font-medium">免费</td>
</tr>
<tr className="border-b border-border/40">
<td className="px-4 py-2.5">
<span className="inline-flex rounded border border-blue-200 bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
专业版
</span>
</td>
<td className="px-4 py-2.5">1,000 次/时</td>
<td className="px-4 py-2.5">最多 3 个</td>
<td className="px-4 py-2.5">即将推出</td>
</tr>
<tr>
<td className="px-4 py-2.5">
<span className="inline-flex rounded border border-purple-200 bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-700">
企业版
</span>
</td>
<td className="px-4 py-2.5">10,000 次/时</td>
<td className="px-4 py-2.5">按需定制</td>
<td className="px-4 py-2.5">联系我们</td>
</tr>
</tbody>
</table>
</div>
</div>
<p>• 超出限制将返回 <strong className="text-foreground">429 Too Many Requests</strong></p>
<p>• 如需更高额度,请联系我们</p>
</div>
</section>
<section id="examples">
<h2 className="text-xl font-semibold mb-4">代码示例</h2>
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-2">JavaScript (fetch)</h3>
<div className="rounded-lg border border-border/60 bg-gray-900 px-4 py-3 font-mono text-xs text-gray-100 overflow-x-auto whitespace-pre">{`// 获取工具列表
const response = await fetch(
'https://www.zhuig.com/api/v1/tools?page=1&pageSize=10'
);
const data = await response.json();
console.log(data.data); // 工具数组
console.log(data.pagination); // 分页信息
// 搜索
const searchResult = await fetch(
'https://www.zhuig.com/api/v1/search?q=AI&limit=5'
).then(res => res.json());
console.log(\`搜索耗时: \${searchResult.took}ms\`);
searchResult.results.forEach(r => {
console.log(\`[\${r.type}] \${r.title}\`);
});`}</div>
</div>
<div>
<h3 className="text-sm font-medium mb-2">Python (requests)</h3>
<div className="rounded-lg border border-border/60 bg-gray-900 px-4 py-3 font-mono text-xs text-gray-100 overflow-x-auto whitespace-pre">{`import requests
# 获取工具列表
response = requests.get(
"https://www.zhuig.com/api/v1/tools",
params={"page": 1, "pageSize": 10, "search": "AI"}
)
data = response.json()
for tool in data["data"]:
print(f"{tool['name']} - {tool['pricingModel']}")
# 获取工具详情
tool = requests.get(
"https://www.zhuig.com/api/v1/tools/chatgpt"
).json()["data"]
print(f"名称: {tool['name']}")
print(f"评分: {tool['avgRating']}")
print(f"收藏数: {tool['_count']['favorites']}")
# 搜索
search = requests.get(
"https://www.zhuig.com/api/v1/search",
params={"q": "AI", "limit": 5}
).json()
print(f"搜索耗时: {search['took']}ms")
for result in search["results"]:
print(f"[{result['type']}] {result['title']}")`}</div>
</div>
</div>
</section>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
export default function DevelopersLayout({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
<div className="flex gap-8">
<aside className="hidden w-48 shrink-0 lg:block">
<nav className="sticky top-24 space-y-1">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
开发者平台
</h3>
<Link
href="/developers"
className="block rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
概览
</Link>
<Link
href="/developers#endpoints"
className="block rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
API 端点
</Link>
<Link
href="/developers#rate-limits"
className="block rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
速率限制
</Link>
<Link
href="/developers#examples"
className="block rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
代码示例
</Link>
</nav>
</aside>
<main className="min-w-0 flex-1" id="main-content" tabIndex={-1}>
{children}
</main>
</div>
</div>
);
}
+104 -26
View File
@@ -2,11 +2,14 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
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";
import StatsSummary from "@/components/common/StatsSummary";
import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants";
import Image from "next/image";
interface Tool {
id: number;
@@ -19,6 +22,18 @@ interface Tool {
viewCount: number;
}
interface TrendingTool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
category: { name: string; slug: string } | null;
}
interface FlatCategory {
id: number;
name: string;
@@ -41,6 +56,7 @@ interface HomePageClientProps {
parentCategories: ParentCategory[];
totalTools: number;
totalSkills: number;
trendingTools: TrendingTool[];
}
export default function HomePageClient({
@@ -48,6 +64,7 @@ export default function HomePageClient({
parentCategories,
totalTools,
totalSkills,
trendingTools,
}: HomePageClientProps) {
const router = useRouter();
const [activeSection, setActiveSection] = useState<string>("");
@@ -240,6 +257,61 @@ export default function HomePageClient({
subSlogan="发现、探索、使用 — 让人工智能触手可及"
/>
<StatsSummary />
{trendingTools.length > 0 && (
<section className="mb-10">
<div className="mb-4 flex items-center gap-3">
<div className="flex items-center gap-2 rounded-lg bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200/60 px-3 py-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="text-sm font-semibold text-amber-700">近期热门</span>
</div>
<span className="text-xs text-muted-foreground">最近加入 · 最受关注</span>
</div>
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{trendingTools.map((tool) => (
<Link
key={tool.id}
href={`/tools/${tool.slug}`}
className="group relative flex h-full min-h-[150px] flex-col overflow-hidden rounded-xl border border-border/60 bg-white p-4 transition-all duration-300 hover:border-amber-200 hover:shadow-lg hover:shadow-amber-500/5 hover:-translate-y-0.5"
>
<div className="absolute top-0 right-0 w-16 h-16 bg-gradient-to-bl from-amber-100/40 to-transparent rounded-bl-2xl" />
<div className="relative flex flex-1 flex-col">
<div className="flex items-center gap-2.5 mb-2.5">
{tool.logoUrl && (
<Image src={tool.logoUrl} referrerPolicy="no-referrer" alt="" width={32} height={32} className="h-8 w-8 rounded-lg border border-border/40 object-contain p-0.5 bg-white" onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
const fallback = target.nextElementSibling as HTMLElement | null;
if (fallback) fallback.classList.remove("hidden");
}} />
)}
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/40 text-[10px] font-bold text-muted-foreground ${tool.logoUrl ? "hidden" : ""}`}>
{tool.name.slice(0, 2)}
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold truncate group-hover:text-primary transition-colors">{tool.name}</h3>
{tool.category && (
<span className="text-[10px] text-muted-foreground/60">{tool.category.name}</span>
)}
</div>
</div>
<p className="flex-1 text-xs text-muted-foreground/70 line-clamp-2 leading-relaxed">{tool.description}</p>
<div className="mt-2.5 flex items-center gap-2">
<span className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium border ${PRICING_COLOR[tool.pricingModel] || "border-slate-200 bg-slate-50 text-slate-600"}`}>
{PRICING_LABEL[tool.pricingModel] || tool.pricingModel}
</span>
<span className="text-[10px] text-muted-foreground/50">{tool.viewCount} 次浏览</span>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
if (allCatsInGroup.length === 0) return null;
@@ -307,26 +379,36 @@ export default function HomePageClient({
{visibleTools.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleTools.map((tool) => (
<Card
<Link
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}`)}
href={`/tools/${tool.slug}`}
className="group relative flex h-full min-h-[120px] flex-col"
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<Card className="flex h-full flex-col 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)]">
<div className="absolute top-1.5 right-1.5 z-10" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); } }} onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<CardContent className="flex flex-1 flex-col 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'); }}
/>
<div className="relative h-10 w-10 shrink-0">
<Image
src={tool.logoUrl}
alt={tool.name}
fill
sizes="40px"
className="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) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = parent.nextElementSibling as HTMLElement;
if (fallback) fallback.classList.remove('hidden');
}
}}
/>
</div>
) : 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)}
@@ -342,11 +424,12 @@ export default function HomePageClient({
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
<p className="mt-2.5 flex-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
@@ -357,19 +440,14 @@ export default function HomePageClient({
{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}`);
}}
<Link
href={`/categories/${activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
查看全部 {displayTools.length} 个 →
</Button>
</Link>
</div>
)}
</section>
+63
View File
@@ -0,0 +1,63 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { cachedResponse } from "@/lib/cache";
import { serverError } from "@/lib/api-error";
export async function GET() {
try {
const data = await cachedResponse("admin:bot-quality", 300, async () => {
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 3600000);
const weekAgo = new Date(now.getTime() - 7 * 24 * 3600000);
const [topics24h, topics7d, replies24h, replies7d,
totalBots, activeBots24h, botMemoryTotal,
botTopicsWithReplies, botTopicsTotal] = await Promise.all([
prisma.forumTopic.count({ where: { user: { isBot: true }, createdAt: { gte: dayAgo } } }),
prisma.forumTopic.count({ where: { user: { isBot: true }, createdAt: { gte: weekAgo } } }),
prisma.forumPost.count({ where: { user: { isBot: true }, createdAt: { gte: dayAgo } } }),
prisma.forumPost.count({ where: { user: { isBot: true }, createdAt: { gte: weekAgo } } }),
prisma.user.count({ where: { isBot: true } }),
prisma.user.count({ where: { isBot: true, forumTopics: { some: { createdAt: { gte: dayAgo } } } } }),
prisma.botMemory.count(),
prisma.forumTopic.count({
where: {
user: { isBot: true },
createdAt: { gte: weekAgo },
posts: { some: { user: { isBot: false } } }
}
}),
prisma.forumTopic.count({ where: { user: { isBot: true }, createdAt: { gte: weekAgo } } }),
]);
const engagementRate = botTopicsTotal > 0
? (botTopicsWithReplies / botTopicsTotal * 100).toFixed(1)
: "0";
return {
period: { last24h: true, last7d: true },
content: {
topics24h, topics7d, replies24h, replies7d,
avgRepliesPerTopic24h: topics24h > 0 ? (replies24h / topics24h).toFixed(1) : "0",
},
bots: {
total: totalBots,
active24h: activeBots24h,
activityRate: totalBots > 0 ? ((activeBots24h / totalBots) * 100).toFixed(1) : "0",
},
quality: {
engagementRate: `${engagementRate}%`,
topicsWithHumanReplies: botTopicsWithReplies,
totalTopicsAnalyzed: botTopicsTotal,
},
memory: { total: botMemoryTotal },
timestamp: now.toISOString(),
};
});
return NextResponse.json(data);
} catch (error) {
return serverError(error);
}
}
@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
// Bot 对抗学习看板数据
// 返回:最近 N 周的所有 bot adversarial learning 记录,含 source post 摘要、LLM 洞察、关联度
@@ -124,8 +128,9 @@ export async function GET(req: NextRequest) {
};
return NextResponse.json({ summary, learnings: enriched });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "对抗学习数据获取失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { spawn } from "child_process";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
// 手动触发对抗学习调度
// POST /api/admin/bots/adversarial-learning/run
@@ -65,8 +69,9 @@ export async function POST(req: NextRequest) {
stdout: stdout.slice(-2000),
stderr: stderr.slice(-1000),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "运行失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
// Bot Persona A/B 测试看板数据
// 返回:所有变体、最近 7 天每日聚合、实验状态
@@ -247,9 +251,8 @@ export async function GET(req: NextRequest) {
experiments,
analysis,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "A/B 数据获取失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -260,3 +263,5 @@ function normalCdf(x: number): number {
const p = d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
return 1 - p;
}
@@ -3,6 +3,10 @@ import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { spawn } from "child_process";
import { resolve, dirname } from "path";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
// 手动触发 A/B 调度
// POST /api/admin/bots/experiments/run
@@ -65,8 +69,9 @@ export async function POST(req: NextRequest) {
stdout: stdout.slice(-2000),
stderr: stderr.slice(-1000),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "运行失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
// 变体管理:更新权重、启用/停用、重置样本、删除
// PATCH /api/admin/bots/experiments/variants
@@ -34,9 +38,8 @@ export async function PATCH(req: NextRequest) {
data: update,
});
return NextResponse.json({ ok: true, variant: v });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "更新失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -51,8 +54,9 @@ export async function DELETE(req: NextRequest) {
where: { id: Number(variantId) },
});
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "删除失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
// Bot 表现统计:拉近 N 天的每日统计 + 总览指标
export async function GET(req: NextRequest) {
@@ -222,8 +226,9 @@ export async function GET(req: NextRequest) {
perBotTrend,
botById: Object.fromEntries(botUsers.map((u) => [u.id, u.name])),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "Bot 统计失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function PUT(
request: Request,
@@ -28,9 +32,8 @@ export async function PUT(
});
return NextResponse.json({ category });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -63,8 +66,9 @@ export async function DELETE(
await prisma.category.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
const authError = await requireAdmin();
@@ -27,9 +31,8 @@ export async function GET() {
createdAt: c.createdAt,
}))
);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -51,8 +54,9 @@ export async function POST(request: Request) {
});
return NextResponse.json({ category }, { status: 201 });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function DELETE(
request: Request,
@@ -28,8 +32,9 @@ export async function DELETE(
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -4,6 +4,10 @@ import { requireAdmin } from "@/lib/admin-auth";
import { fetchLogoWithMethod } from "@/lib/logo-fetcher";
import { downloadLogo } from "@/lib/logo-storage";
import { checkTypeSchema } from "@/lib/validation";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
interface CheckResult {
id: number;
@@ -162,8 +166,9 @@ export async function POST(request: Request) {
}
return NextResponse.json({ results });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+12 -9
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request, { params }: { params: { id: string } }) {
const authError = await requireAdmin();
@@ -14,9 +18,8 @@ export async function GET(request: Request, { params }: { params: { id: string }
});
if (!report) return NextResponse.json({ error: "日报不存在" }, { status: 404 });
return NextResponse.json({ report });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -63,9 +66,8 @@ export async function PUT(request: Request, { params }: { params: { id: string }
});
return NextResponse.json({ report });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -77,8 +79,9 @@ export async function DELETE(request: Request, { params }: { params: { id: strin
const id = parseInt(params.id);
await prisma.dailyReport.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { extractJSON } from "@/lib/utils";
import OpenAI from "openai";
import { badRequest, notFound, serverError } from "@/lib/api-error";
// 必须 force-dynamic,否则 build 期的 page data collection 会触发模块求值
export const dynamic = "force-dynamic";
@@ -13,7 +14,7 @@ function getOpenAI(): OpenAI {
if (!_openai) {
_openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
}
return _openai;
@@ -81,8 +82,7 @@ export async function POST(request: Request) {
}
return NextResponse.json({ success: true, data: parsed });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "生成失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
}
+10 -6
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authError = await requireAdmin();
@@ -26,9 +30,8 @@ export async function GET(request: Request) {
]);
return NextResponse.json({ reports, total, page, pageSize });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -69,8 +72,9 @@ export async function POST(request: Request) {
});
return NextResponse.json({ report });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { newsImportSchema } from "@/lib/validation";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
function getTodayDate() {
const now = new Date();
@@ -72,9 +76,8 @@ export async function POST(request: Request) {
})),
},
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -110,8 +113,9 @@ export async function GET() {
},
date: today,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { skillImportSchema } from "@/lib/validation";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -86,8 +90,9 @@ export async function POST(request: Request) {
skipped: results.filter((r) => r.status !== "已导入").length,
details: results,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/prisma";
import { discoverSkills } from "@/lib/skill-discoverer";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -18,8 +22,9 @@ export async function POST(request: Request) {
});
return NextResponse.json(result);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { toolImportSchema } from "@/lib/validation";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -88,8 +92,9 @@ export async function POST(request: Request) {
skipped: results.filter((r) => r.status !== "已导入").length,
details: results,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
@@ -78,3 +79,4 @@ export async function POST(
return NextResponse.json({ success: true, action });
}
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
@@ -124,3 +125,4 @@ export async function POST(
return NextResponse.json({ success: true, resolution });
}
+2
View File
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
@@ -87,3 +88,4 @@ export async function GET(req: Request) {
pageSize,
});
}
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
@@ -120,3 +121,4 @@ export async function POST(
return NextResponse.json({ success: true, action });
}
+2
View File
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getAllUserPermissions } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
@@ -57,3 +58,4 @@ export async function GET(req: Request) {
return NextResponse.json({ topics, totalCount, page, pageSize });
}
+8 -3
View File
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { batchDownloadLogos } from "@/lib/logo-storage";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -79,8 +83,9 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, total: skills.length, migrated: toUpdate.length, failed });
}
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { cachedResponse } from "@/lib/cache";
import { serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
const authError = await requireAdmin();
if (authError) return authError;
try {
const data = await cachedResponse("admin:overview", 120, async () => {
const [
toolCount,
skillCount,
publishedToolCount,
publishedSkillCount,
userCount,
botCount,
totalTopics,
totalPosts,
recentTaskLogs,
failedTasks24h,
pendingFeedback,
pendingTools,
pendingSkills,
totalApiKeys,
activeApiKeys,
lastBotActivity,
botMemoryCount,
collectionCount,
publicCollectionCount,
] = await Promise.all([
prisma.tool.count(),
prisma.skill.count(),
prisma.tool.count({ where: { status: "published" } }),
prisma.skill.count({ where: { status: "published" } }),
prisma.user.count({ where: { isBot: false } }),
prisma.user.count({ where: { isBot: true } }),
prisma.forumTopic.count({ where: { isHidden: false } }),
prisma.forumPost.count({ where: { isHidden: false } }),
prisma.taskLog.findMany({
orderBy: { startedAt: "desc" },
take: 10,
select: { taskKey: true, taskName: true, status: true, startedAt: true, error: true },
}),
prisma.taskLog.count({
where: {
status: "failed",
startedAt: { gte: new Date(Date.now() - 24 * 3600000) },
},
}),
prisma.feedback.count({ where: { status: "pending" } }),
prisma.pendingTool.count(),
prisma.pendingSkill.count(),
prisma.apiKey.count(),
prisma.apiKey.count({ where: { isActive: true } }),
prisma.forumTopic.findFirst({
where: { user: { isBot: true } },
orderBy: { createdAt: "desc" },
select: { createdAt: true, title: true },
}),
prisma.botMemory.count(),
prisma.collection.count(),
prisma.collection.count({ where: { isPublic: true } }),
]);
return {
tools: { total: toolCount, published: publishedToolCount, pending: pendingTools },
skills: { total: skillCount, published: publishedSkillCount, pending: pendingSkills },
community: { totalTopics, totalPosts },
users: { total: userCount, bots: botCount },
tasks: { recent: recentTaskLogs, failed24h: failedTasks24h },
feedback: { pending: pendingFeedback },
apiKeys: { total: totalApiKeys, active: activeApiKeys },
bot: { lastActivity: lastBotActivity, memoryCount: botMemoryCount },
collections: { total: collectionCount, public: publicCollectionCount },
updatedAt: new Date().toISOString(),
};
});
return NextResponse.json(data);
} catch (error) {
return serverError(error);
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request, { params }: { params: { id: string } }) {
const authError = await requireAdmin();
@@ -61,8 +65,9 @@ export async function POST(request: Request, { params }: { params: { id: string
});
return NextResponse.json({ skill: skillRef.current });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function PUT(request: Request, { params }: { params: { id: string } }) {
const authError = await requireAdmin();
@@ -11,9 +15,8 @@ export async function PUT(request: Request, { params }: { params: { id: string }
const body = await request.json();
const skill = await prisma.pendingSkill.update({ where: { id }, data: body });
return NextResponse.json({ skill });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -25,8 +28,9 @@ export async function DELETE(_request: Request, { params }: { params: { id: stri
const id = parseInt(params.id);
await prisma.pendingSkill.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -125,8 +129,9 @@ export async function POST(request: Request) {
skipped: results.filter((r) => r.status !== "已发布").length,
details: results,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -24,8 +28,9 @@ export async function POST(request: Request) {
rejected: result.count,
skipped: ids.length - result.count,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -91,8 +95,9 @@ export async function POST(request: Request) {
skipped: results.filter((r) => r.status !== "已导入").length,
details: results,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST() {
const authError = await requireAdmin();
@@ -97,8 +101,9 @@ export async function POST() {
skipped: details.filter((d) => d.status !== "已发布").length,
details,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authError = await requireAdmin();
@@ -38,8 +42,9 @@ export async function GET(request: Request) {
]);
return NextResponse.json({ skills, total, page, pageSize });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
function generateSlug(name: string): string {
return name
@@ -79,8 +83,9 @@ export async function POST(
toolId,
message: "发布成功",
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -3,6 +3,10 @@ import { prisma } from "@/lib/prisma";
import { fetchLogo } from "@/lib/logo-fetcher";
import { downloadLogo } from "@/lib/logo-storage";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function PUT(
request: Request,
@@ -69,9 +73,8 @@ export async function PUT(
});
return NextResponse.json({ tool: updated });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -86,8 +89,9 @@ export async function DELETE(
const id = parseInt(params.id);
await prisma.pendingTool.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -131,8 +135,9 @@ export async function POST(request: Request) {
skipped: results.filter((r) => r.status !== "已发布").length,
details: results,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -24,8 +28,9 @@ export async function POST(request: Request) {
rejected: result.count,
skipped: ids.length - result.count,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -98,8 +102,9 @@ export async function POST(request: Request) {
skipped: results.filter((r) => r.status !== "已导入").length,
details: results,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -3,6 +3,10 @@ import { prisma } from "@/lib/prisma";
import { fetchLogo } from "@/lib/logo-fetcher";
import { downloadLogo } from "@/lib/logo-storage";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
const CONCURRENCY_LIMIT = 5;
@@ -142,8 +146,9 @@ export async function POST(request: Request) {
skipped,
details,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authError = await requireAdmin();
@@ -22,8 +26,9 @@ export async function GET(request: Request) {
]);
return NextResponse.json({ tools, total, page, pageSize });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -4
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
const authCheck = await requireAdmin();
@@ -12,8 +16,8 @@ export async function GET() {
});
return NextResponse.json(permissions);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import OpenAI from "openai";
import { badRequest, notFound, serverError } from "@/lib/api-error";
// 必须 force-dynamic,避免 build 期触发 OpenAI SDK 构造抛错
export const dynamic = "force-dynamic";
@@ -12,7 +13,7 @@ function getOpenAI(): OpenAI {
if (!_openai) {
_openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
}
return _openai;
@@ -161,3 +162,4 @@ export async function POST(
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+12 -9
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
@@ -38,9 +42,8 @@ export async function GET(
};
return NextResponse.json(serialized);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -136,9 +139,8 @@ export async function PUT(
}
return NextResponse.json({ error: "未知操作" }, { status: 400 });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -165,8 +167,9 @@ export async function DELETE(
});
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authCheck = await requireAdmin();
@@ -86,8 +90,9 @@ export async function GET(request: Request) {
}));
return NextResponse.json({ reviews: serialized, total, page, pageSize, stats });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -7
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function PUT(
request: Request,
@@ -48,9 +52,8 @@ export async function PUT(
userCount: updated!._count.users,
permissionIds: updated!.permissions.map((rp: { permissionId: number }) => rp.permissionId),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -81,8 +84,8 @@ export async function DELETE(
await prisma.role.delete({ where: { id: roleId } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
}
+10 -7
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
const authCheck = await requireAdmin();
@@ -29,9 +33,8 @@ export async function GET() {
}));
return NextResponse.json(formatted);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -75,8 +78,8 @@ export async function POST(request: Request) {
isSystem: role.isSystem,
permissionIds: role.permissions.map((rp: { permissionId: number }) => rp.permissionId),
}, { status: 201 });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
}
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function PUT(request: Request, { params }: { params: { id: string } }) {
const authError = await requireAdmin();
@@ -18,9 +22,8 @@ export async function PUT(request: Request, { params }: { params: { id: string }
const category = await prisma.skillCategory.update({ where: { id }, data });
return NextResponse.json({ category });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -58,8 +61,9 @@ export async function DELETE(_request: Request, { params }: { params: { id: stri
await prisma.skillCategory.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
const authError = await requireAdmin();
@@ -27,9 +31,8 @@ export async function GET() {
createdAt: c.createdAt,
}))
);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -56,8 +59,9 @@ export async function POST(request: Request) {
});
return NextResponse.json({ category });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function PUT(request: Request, { params }: { params: { id: string } }) {
const authError = await requireAdmin();
@@ -28,9 +32,8 @@ export async function PUT(request: Request, { params }: { params: { id: string }
const skill = await prisma.skill.update({ where: { id }, data });
return NextResponse.json({ skill });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -44,8 +47,9 @@ export async function DELETE(_request: Request, { params }: { params: { id: stri
await prisma.skill.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authError = await requireAdmin();
@@ -22,8 +26,9 @@ export async function GET(request: Request) {
]);
return NextResponse.json({ skills, total, page, pageSize });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
const authError = await requireAdmin();
@@ -13,9 +17,8 @@ export async function GET() {
result[c.key] = c.value;
}
return NextResponse.json(result);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -37,8 +40,9 @@ export async function PUT(request: Request) {
}
return NextResponse.json({ success: true, updated: results });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -7
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
try {
@@ -37,9 +41,8 @@ export async function GET() {
});
return NextResponse.json({ tasks: merged });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -95,8 +98,8 @@ export async function PUT(request: Request) {
}
return NextResponse.json({ task: result });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
}
+8 -4
View File
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
try {
@@ -32,8 +36,8 @@ export async function GET(request: Request) {
page,
pageSize,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
}
+8 -4
View File
@@ -5,6 +5,10 @@ import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/prisma";
import { taskRunSchema } from "@/lib/validation";
import path from "path";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
const execAsync = promisify(exec);
@@ -110,8 +114,8 @@ export async function POST(request: Request) {
}
return NextResponse.json(result);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
}
+10 -6
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function PUT(
request: Request,
@@ -37,9 +41,8 @@ export async function PUT(
});
return NextResponse.json({ tool });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -56,8 +59,9 @@ export async function DELETE(
await prisma.tool.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+10 -6
View File
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authError = await requireAdmin();
@@ -46,9 +50,8 @@ export async function GET(request: Request) {
page,
pageSize,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
@@ -91,8 +94,9 @@ export async function POST(request: Request) {
});
return NextResponse.json({ tool }, { status: 201 });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
const VERIFIED_TOOLS_KEY = "verified_tools";
interface VerifiedToolEntry {
toolId: number;
slug: string;
verifiedAt: string;
verifiedBy: string;
}
async function getVerifiedTools(): Promise<VerifiedToolEntry[]> {
const config = await prisma.systemConfig.findUnique({
where: { key: VERIFIED_TOOLS_KEY },
});
if (!config?.value) return [];
try {
const parsed = JSON.parse(JSON.stringify(config.value));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
async function saveVerifiedTools(entries: VerifiedToolEntry[]): Promise<void> {
const jsonValue = JSON.parse(JSON.stringify(entries));
await prisma.systemConfig.upsert({
where: { key: VERIFIED_TOOLS_KEY },
update: { value: jsonValue },
create: { key: VERIFIED_TOOLS_KEY, value: jsonValue },
});
}
export async function POST(request: Request) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await request.json();
const { toolId } = body as { toolId: number };
if (!toolId) {
return NextResponse.json({ error: "请提供toolId" }, { status: 400 });
}
const tool = await prisma.tool.findUnique({
where: { id: toolId },
select: { id: true, slug: true, name: true },
});
if (!tool) {
return NextResponse.json({ error: "工具不存在" }, { status: 404 });
}
const session = await getServerSession(authOptions);
const verifiedTools = await getVerifiedTools();
const existingIndex = verifiedTools.findIndex((v) => v.toolId === toolId);
if (existingIndex >= 0) {
// Unverify
verifiedTools.splice(existingIndex, 1);
await saveVerifiedTools(verifiedTools);
return NextResponse.json({
success: true,
verified: false,
message: `已取消 "${tool.name}" 的验证标识`,
});
} else {
// Verify
verifiedTools.push({
toolId,
slug: tool.slug,
verifiedAt: new Date().toISOString(),
verifiedBy: session?.user?.email || "admin",
});
await saveVerifiedTools(verifiedTools);
return NextResponse.json({
success: true,
verified: true,
message: `已添加 "${tool.name}" 的验证标识`,
});
}
} catch (error) {
return serverError(error);
}
}
export async function GET() {
const authError = await requireAdmin();
if (authError) return authError;
try {
const verifiedTools = await getVerifiedTools();
return NextResponse.json({ verifiedTools });
} catch (error) {
return serverError(error);
}
}
+36 -16
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
interface UpdateResult {
id: number;
@@ -83,7 +87,11 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { type } = body as { type: "published" | "pending" };
const { type, offset: rawOffset, limit: rawLimit } = body as {
type: "published" | "pending";
offset?: number;
limit?: number;
};
if (!type || !["published", "pending"].includes(type)) {
return NextResponse.json(
@@ -92,18 +100,20 @@ export async function POST(request: Request) {
);
}
const skills =
// 分批执行:单个批次控制在几十秒内,避免超过 Nginx 300s 代理超时导致 504
const offset = Math.max(0, Number(rawOffset) || 0);
const limit = Math.min(50, Math.max(1, Number(rawLimit) || 20));
// 必须按 id 升序,保证 offset 分页稳定(否则每批取到的集合会漂移)
const where = { sourceUrl: { not: "" } };
const select = { id: true, name: true, sourceUrl: true, sourceType: true, rating: true };
const [skills, total] = await Promise.all([
type === "published"
? await prisma.skill.findMany({
where: { sourceUrl: { not: "" } },
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true, githubStars: true },
take: 500,
})
: await prisma.pendingSkill.findMany({
where: { sourceUrl: { not: "" } },
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true },
take: 500,
});
? prisma.skill.findMany({ where, select, orderBy: { id: "asc" }, skip: offset, take: limit })
: prisma.pendingSkill.findMany({ where, select, orderBy: { id: "asc" }, skip: offset, take: limit }),
type === "published" ? prisma.skill.count({ where }) : prisma.pendingSkill.count({ where }),
]);
const results: UpdateResult[] = [];
@@ -153,9 +163,19 @@ export async function POST(request: Request) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
return NextResponse.json({ results, total: skills.length, updated: results.length });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
const nextOffset = offset + skills.length;
return NextResponse.json({
results,
total,
offset,
limit,
processed: skills.length,
hasMore: nextOffset < total,
nextOffset,
});
} catch (error) {
return serverError(error);
}
}
+8 -4
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { requireAdmin, getAllUserPermissions } from "@/lib/admin-auth";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authCheck = await requireAdmin();
@@ -62,8 +66,8 @@ export async function GET(request: Request) {
roleName: perms.roleName,
permissions: Array.from(perms.keys).map((k: string) => ({ key: k })),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
}
+6
View File
@@ -1,3 +1,9 @@
import { GET, POST } from "@/lib/auth";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export { GET, POST };
+8 -3
View File
@@ -3,6 +3,10 @@ import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { registerSchema } from "@/lib/validation";
import { rateLimit } from "@/lib/rate-limit";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
@@ -38,8 +42,9 @@ export async function POST(request: Request) {
{ 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 });
} catch (error) {
return serverError(error);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
@@ -37,4 +38,4 @@ export async function GET(
}
: null,
});
}
}
+8 -3
View File
@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
// 返回按"周"统计的 bot 表现排行
// 维度:发帖数、回复数、获赞数、被@次数(按 TOPIC_REPLY 通知的 fromBotId 计算)
@@ -130,8 +134,9 @@ export async function GET(req: NextRequest) {
days,
generatedAt: new Date().toISOString(),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "Bot 排行获取失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+8 -3
View File
@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { readFileSync } from "fs";
import { resolve } from "path";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
const BOT_DATA_PATH = resolve(process.cwd(), "data", "bot-characters.json");
@@ -184,8 +188,9 @@ export async function GET(req: NextRequest) {
total: enriched.length,
forums: Object.entries(FORUM_NAMES).map(([slug, name]) => ({ slug, name })),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取Bot列表失败" }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+12 -4
View File
@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { cachedResponse } from "@/lib/cache";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
interface CategoryNode {
id: number;
@@ -35,14 +40,17 @@ async function buildTree(): Promise<CategoryNode[]> {
export async function GET() {
try {
const tree = await buildTree();
const tree = await cachedResponse("categories:all", 600, async () => {
return await buildTree();
});
return NextResponse.json(tree, {
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+6
View File
@@ -3,6 +3,10 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
@@ -65,3 +69,5 @@ export async function POST(req: Request, { params }: { params: { id: string } })
return NextResponse.json({ liked: true });
}
+192 -174
View File
@@ -3,206 +3,224 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
import { rateLimit } from "@/lib/rate-limit";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
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);
try {
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 });
}
if (!toolId && !skillId) {
return badRequest("必须提供 toolId 或 skillId");
}
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 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 } },
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,
},
orderBy: { createdAt: "asc" },
take: 5,
_count: { select: { replies: true } },
},
_count: { select: { replies: true } },
},
orderBy: [
{ isPinned: "desc" },
{ createdAt: "desc" },
],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.comment.count({ where }),
]);
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 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,
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: r.user.id,
name: r.user.name || r.user.email.split("@")[0],
avatarUrl: r.user.avatarUrl,
level: r.user.level,
points: r.user.points,
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,
},
})),
}));
const res = NextResponse.json({ comments: formatted, totalCount, page, pageSize });
res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120");
return res;
const res = NextResponse.json({ comments: formatted, totalCount, page, pageSize });
res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120");
return res;
} catch (error) {
return serverError(error);
}
}
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 { toolSlug, skillSlug, content, rating, parentId } = body;
const userId = parseInt(session.user.id);
// Rate limit: 5 content creations per 60s per user/IP
const rateLimitResult = rateLimit(`content-create:${session.user.id}`, 5, 60);
if (!rateLimitResult) {
return NextResponse.json({ error: "操作过于频繁,请稍后再试" }, { status: 429 });
}
if (!content || !content.trim()) {
return NextResponse.json({ error: "评论内容不能为空" }, { status: 400 });
}
if (content.trim().length > 2000) {
return NextResponse.json({ error: "评论内容不能超过2000个字符" }, { status: 400 });
}
const body = await req.json();
const { toolSlug, skillSlug, content, rating, parentId } = body;
const userId = parseInt(session.user.id);
let toolId: number | undefined;
let skillId: number | undefined;
if (!content || !content.trim()) {
return badRequest("评论内容不能为空");
}
if (content.trim().length > 2000) {
return badRequest("评论内容不能超过2000个字符");
}
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 });
}
let toolId: number | undefined;
let skillId: number | undefined;
if (!parentId && rating && (rating < 1 || rating > 5)) {
return NextResponse.json({ error: "评分必须为1-5" }, { status: 400 });
}
if (toolSlug) {
const tool = await prisma.tool.findUnique({ where: { slug: toolSlug } });
if (!tool) return notFound("工具不存在");
toolId = tool.id;
} else if (skillSlug) {
const skill = await prisma.skill.findUnique({ where: { slug: skillSlug } });
if (!skill) return notFound("技能不存在");
skillId = skill.id;
} else {
return badRequest("必须提供 toolSlug 或 skillSlug");
}
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 && rating && (rating < 1 || rating > 5)) {
return badRequest("评分必须为1-5");
}
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) {
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 } },
});
}
await rewardPoints(userId, "comment", `评论${toolSlug || skillSlug}`, toolSlug ? "tool" : "skill", toolId || skillId);
}
const parentComment = parentId
? await tx.comment.findUnique({ where: { id: parentId }, select: { userId: true } })
: null;
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,
},
};
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);
return NextResponse.json(formatted, { status: 201 });
} catch (error) {
return serverError(error);
}
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 });
}
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { cachedResponse } from "@/lib/cache";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "20", 10), 1), 50);
const cacheKey = `community:checkin:leaderboard:${limit}`;
return NextResponse.json(
await cachedResponse(cacheKey, 300, async () => {
return await fetchCheckInLeaderboard(limit);
})
);
} catch (error) {
return serverError(error);
}
}
async function fetchCheckInLeaderboard(limit: number) {
// Get the most recent check-in for each user, ordered by streak desc
const recentCheckIns = await prisma.forumCheckIn.findMany({
orderBy: { checkInDate: "desc" },
distinct: ["userId"],
select: { userId: true, streak: true },
});
// Sort by current streak desc, take top N
const topByStreak = recentCheckIns
.sort((a, b) => b.streak - a.streak)
.slice(0, limit);
const userIds = topByStreak.map((c) => c.userId);
const [users, totalCheckIns] = await Promise.all([
prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true, avatarUrl: true },
}),
prisma.forumCheckIn.groupBy({
by: ["userId"],
where: { userId: { in: userIds } },
_count: { id: true },
}),
]);
const userMap = new Map(users.map((u) => [u.id, u]));
const totalMap = new Map(totalCheckIns.map((t) => [t.userId, t._count.id]));
// Get longest streaks for each user
const longestStreaks = await prisma.forumCheckIn.groupBy({
by: ["userId"],
where: { userId: { in: userIds } },
_max: { streak: true },
});
const longestMap = new Map(longestStreaks.map((l) => [l.userId, l._max.streak || 0]));
const ranked = topByStreak.map((entry, idx) => {
const user = userMap.get(entry.userId);
return {
rank: idx + 1,
userId: entry.userId,
name: user?.name || user?.email?.split("@")[0] || "匿名用户",
avatar: user?.avatarUrl || null,
currentStreak: entry.streak,
longestStreak: longestMap.get(entry.userId) || entry.streak,
totalCheckIns: totalMap.get(entry.userId) || 0,
};
});
return {
users: ranked,
generatedAt: new Date().toISOString(),
};
}
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { cachedResponse } from "@/lib/cache";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const cacheKey = "community:hot";
return NextResponse.json(
await cachedResponse(cacheKey, 300, async () => {
const [hotTopics, topTools, topSkills] = await Promise.all([
prisma.forumTopic.findMany({
where: { isHidden: false },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true } },
_count: { select: { posts: true } },
},
orderBy: [{ isPinned: "desc" }, { replyCount: "desc" }, { createdAt: "desc" }],
take: 10,
}),
prisma.tool.findMany({
where: { status: "published" },
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
description: true,
viewCount: true,
avgRating: true,
category: { select: { name: true, slug: true } },
},
orderBy: { viewCount: "desc" },
take: 10,
}),
prisma.skill.findMany({
where: { status: "published" },
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
description: true,
viewCount: true,
githubStars: true,
category: { select: { name: true, slug: true } },
},
orderBy: { githubStars: "desc" },
take: 10,
}),
]);
return {
hotTopics: hotTopics.map((t) => ({
id: t.id,
title: t.title,
slug: t.slug,
postCount: t._count.posts,
viewCount: t.viewCount,
likeCount: t.likeCount,
category: t.category,
user: {
id: t.user.id,
name: t.user.name || t.user.email?.split("@")[0] || "匿名",
avatarUrl: t.user.avatarUrl,
},
createdAt: t.createdAt.toISOString(),
})),
topTools: topTools.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
logoUrl: t.logoUrl,
description: t.description?.substring(0, 100) || "",
viewCount: t.viewCount,
avgRating: t.avgRating,
category: t.category,
})),
topSkills: topSkills.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
logoUrl: s.logoUrl,
description: s.description?.substring(0, 100) || "",
viewCount: s.viewCount,
githubStars: s.githubStars || 0,
category: s.category,
})),
generatedAt: new Date().toISOString(),
};
})
);
} catch (error) {
return serverError(error);
}
}
+112
View File
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { cachedResponse } from "@/lib/cache";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
type Period = "weekly" | "monthly" | "alltime";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const period = (searchParams.get("period") || "weekly") as Period;
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "20", 10), 1), 50);
const cacheKey = `community:leaderboard:${period}:${limit}`;
return NextResponse.json(
await cachedResponse(cacheKey, 300, async () => {
return await fetchLeaderboard(period, limit);
})
);
} catch (error) {
return serverError(error);
}
}
async function fetchLeaderboard(period: Period, limit: number) {
const startDate = period === "weekly"
? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
: period === "monthly"
? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
: new Date(0);
const postFilter = period !== "alltime"
? { createdAt: { gte: startDate }, isHidden: false }
: { isHidden: false };
// Aggregate total likes received per user from their forum posts
const grouped = await prisma.forumPost.groupBy({
by: ["userId"],
where: postFilter,
_sum: { likeCount: true },
_count: { id: true },
orderBy: { _sum: { likeCount: "desc" } },
take: limit,
});
if (grouped.length === 0) {
return { users: [], period, generatedAt: new Date().toISOString() };
}
const userIds = grouped.map((g) => g.userId);
const [users, checkIns, userAchievements] = await Promise.all([
prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true, avatarUrl: true, points: true, level: true },
}),
prisma.forumCheckIn.findMany({
where: { userId: { in: userIds } },
orderBy: { checkInDate: "desc" },
distinct: ["userId"],
select: { userId: true, streak: true },
}),
prisma.userAchievement.findMany({
where: { userId: { in: userIds }, unlockedAt: { not: null } },
include: { achievement: { select: { key: true, name: true, icon: true, tier: true } } },
orderBy: { unlockedAt: "desc" },
}),
]);
const userMap = new Map(users.map((u) => [u.id, u]));
const streakMap = new Map(checkIns.map((c) => [c.userId, c.streak]));
const badgeMap = new Map<number, Array<{ key: string; name: string; icon: string; tier: string }>>();
for (const ua of userAchievements) {
const list = badgeMap.get(ua.userId) || [];
list.push({
key: ua.achievement.key,
name: ua.achievement.name,
icon: ua.achievement.icon,
tier: ua.achievement.tier,
});
badgeMap.set(ua.userId, list);
}
const ranked = grouped
.map((g, idx) => {
const user = userMap.get(g.userId);
return {
rank: idx + 1,
userId: g.userId,
name: user?.name || user?.email?.split("@")[0] || "匿名用户",
avatar: user?.avatarUrl || null,
totalLikes: g._sum.likeCount || 0,
postCount: g._count.id,
checkInStreak: streakMap.get(g.userId) || 0,
level: user?.level || "LV01",
points: user?.points || 0,
badges: badgeMap.get(g.userId) || [],
};
})
.sort((a, b) => b.totalLikes - a.totalLikes)
.map((item, idx) => ({ ...item, rank: idx + 1 }));
return {
users: ranked,
period,
generatedAt: new Date().toISOString(),
};
}
+8 -3
View File
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
try {
@@ -46,8 +50,9 @@ export async function GET(request: Request) {
page,
pageSize,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (error) {
return serverError(error);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
import { badRequest, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
const VALID_TYPES = ["suggestion", "bug", "praise", "other"];
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function POST(req: Request) {
try {
const body = await req.json();
const { type, content, contact, pageUrl, userId } = body;
if (!content || !content.trim()) {
return badRequest("反馈内容不能为空");
}
if (content.trim().length > 2000) {
return badRequest("反馈内容不能超过2000个字符");
}
if (type && !VALID_TYPES.includes(type)) {
return badRequest("无效的反馈类型");
}
if (contact && !EMAIL_REGEX.test(contact)) {
return badRequest("邮箱格式不正确");
}
const feedback = await prisma.feedback.create({
data: {
type: type || "suggestion",
content: content.trim(),
contact: contact?.trim() || null,
pageUrl: pageUrl || null,
userId: userId || null,
},
});
return NextResponse.json({ success: true, id: feedback.id });
} catch (error) {
return serverError(error);
}
}
+2
View File
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { getUserAchievements, evaluateAchievements } from "@/lib/achievements";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
@@ -28,3 +29,4 @@ export async function GET(req: Request) {
const data = await getUserAchievements(userId);
return NextResponse.json(data);
}
+6
View File
@@ -2,6 +2,10 @@ import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await getServerSession(authOptions);
@@ -29,3 +33,5 @@ export async function GET() {
const valid = bookmarks.filter((b) => b.topic && !b.topic.isHidden);
return NextResponse.json({ bookmarks: valid });
}
@@ -2,6 +2,10 @@ import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
@@ -99,3 +103,5 @@ export async function DELETE(req: Request, { params }: { params: { id: string }
return NextResponse.json({ error: "操作失败,请稍后重试" }, { status: 500 });
}
}
+21 -12
View File
@@ -1,27 +1,36 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
import { cachedResponse } from "@/lib/cache";
import { badRequest, notFound, serverError } from "@/lib/api-error";
// 强制 SSR(build 期不预渲染,避免无 DATABASE_URL 时 Prisma 报错)
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET() {
const categories = await prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
try {
const categories = await cachedResponse("forum:categories:all", 600, async () => {
return await prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
},
},
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
});
const res = NextResponse.json({ categories });
res.headers.set("Cache-Control", "no-store, max-age=0");
return res;
const res = NextResponse.json({ categories });
res.headers.set("Cache-Control", "no-store, max-age=0");
return res;
} catch (error) {
return serverError(error);
}
}
+2
View File
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { triggerAchievementCheck } from "@/lib/achievements";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
@@ -172,3 +173,4 @@ export async function POST() {
pointsEarned: result.pointsEarned,
});
}
+6
View File
@@ -2,6 +2,10 @@ import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-error";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await getServerSession(authOptions);
@@ -84,3 +88,5 @@ export async function DELETE() {
return NextResponse.json({ success: true });
}
@@ -0,0 +1,2 @@
export const dynamic = "force-dynamic";

Some files were not shown because too many files have changed in this diff Show More