feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库
This commit is contained in:
@@ -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 对抗学习看板 · 管理后台",
|
||||
};
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Authorization: Bearer YOUR_API_KEY" "https://www.zhuig.com/api/v1/tools"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user