fix: 定时任务修复+管理后台Task7支持 (Task4外键修复, Task7管理员邮箱动态查询, Task5限流防护, Task7加入SCRIPT_MAP)

This commit is contained in:
ZhuiGuangAI Dev
2026-05-30 18:03:48 +08:00
parent 4d5e7266b5
commit 10d1acb305
56 changed files with 2779 additions and 135 deletions
@@ -13,6 +13,12 @@ export async function POST(request: Request) {
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "ids数组不能为空" }, { status: 400 });
}
if (ids.length > 500) {
return NextResponse.json({ error: "一次最多处理500条" }, { status: 400 });
}
const defaultCat = await prisma.skillCategory.findFirst({ select: { id: true } });
const defaultCategoryId = defaultCat?.id ?? 0;
const pendings = await prisma.pendingSkill.findMany({
where: { id: { in: ids } },
@@ -92,7 +98,7 @@ export async function POST(request: Request) {
logoUrl: pending.logoUrl,
sourceUrl: pending.sourceUrl || "",
sourceType: pending.sourceType || "github",
categoryId: pending.categoryId || 1,
categoryId: pending.categoryId || defaultCategoryId,
rating: pending.rating || 0,
features: pending.features ?? undefined,
tags: pending.tags ?? undefined,
@@ -7,6 +7,9 @@ export async function POST() {
if (authError) return authError;
try {
const defaultCat = await prisma.skillCategory.findFirst({ select: { id: true } });
const defaultCategoryId = defaultCat?.id ?? 0;
const pendingSkills = await prisma.pendingSkill.findMany({
where: { status: "pending" },
});
@@ -62,7 +65,7 @@ export async function POST() {
logoUrl: pending.logoUrl,
sourceUrl: pending.sourceUrl || "",
sourceType: pending.sourceType || "github",
categoryId: pending.categoryId || 1,
categoryId: pending.categoryId || defaultCategoryId,
rating: pending.rating || 0,
features: pending.features ?? undefined,
tags: pending.tags ?? undefined,
@@ -13,6 +13,12 @@ export async function POST(request: Request) {
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "ids数组不能为空" }, { status: 400 });
}
if (ids.length > 500) {
return NextResponse.json({ error: "一次最多处理500条" }, { status: 400 });
}
const defaultCat = await prisma.category.findFirst({ select: { id: true } });
const defaultCategoryId = defaultCat?.id ?? 0;
const pendings = await prisma.pendingTool.findMany({
where: { id: { in: ids } },
@@ -97,7 +103,7 @@ export async function POST(request: Request) {
description: pending.description || "",
websiteUrl: pending.websiteUrl || "",
logoUrl: pending.logoUrl,
categoryId: pending.categoryId || 1,
categoryId: pending.categoryId || defaultCategoryId,
pricingModel: (pending.pricingModel || "freemium") as "free" | "freemium" | "paid" | "subscription",
pricingDetail: pending.pricingDetail,
features: pending.features ?? undefined,
+1
View File
@@ -10,6 +10,7 @@ export async function GET() {
{ taskKey: "task5-update-stars", taskName: "更新星值", cronExpr: "0 1 * * *", enabled: true, desc: "更新所有GitHub技能的最新Stars数,计算日/周/月变化趋势" },
{ taskKey: "task6-review-hot", taskName: "评测热门", cronExpr: "0 4 * * *", enabled: true, desc: "对未评测的最热门2个AI技能进行五维深度评测并自动发布" },
{ taskKey: "daily-news", taskName: "AI日报生成", cronExpr: "30 7 * * *", enabled: true, desc: "搜索真实新闻素材,AI整理格式生成当日AI日报" },
{ taskKey: "task7-news-to-community", taskName: "新闻分发到社区", cronExpr: "0 8 * * *", enabled: true, desc: "将AI日报条目自动分发到社区对应板块创建话题" },
];
const configs = await prisma.taskConfig.findMany();
+1
View File
@@ -14,6 +14,7 @@ const SCRIPT_MAP: Record<string, string> = {
"task5-update-stars": "scripts/task5-update-stars.mjs",
"task6-review-hot": "scripts/task6-review-hot.mjs",
"daily-news": "scripts/daily-news.mjs",
"task7-news-to-community": "scripts/task7-news-to-community.mjs",
};
const PROJECT_ROOT = process.env.SCRIPTS_ROOT || process.cwd();
+13 -1
View File
@@ -53,9 +53,21 @@ export async function GET(request: Request) {
}
export async function POST(request: Request) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await request.json();
if (!body.name || !body.websiteUrl || !body.categoryId) {
return NextResponse.json({ error: "名称、网址、分类ID不能为空" }, { status: 400 });
}
const existingCategory = await prisma.category.findUnique({ where: { id: body.categoryId } });
if (!existingCategory) {
return NextResponse.json({ error: "分类ID不存在" }, { status: 400 });
}
let slug = body.slug || body.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "tool";
const existing = await prisma.tool.findUnique({ where: { slug } });
if (existing) {
@@ -66,7 +78,7 @@ export async function POST(request: Request) {
data: {
name: body.name,
slug,
categoryId: body.categoryId || 1,
categoryId: body.categoryId,
description: body.description || "",
logoUrl: body.logoUrl || null,
websiteUrl: body.websiteUrl || "",
+40
View File
@@ -0,0 +1,40 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET(
_req: Request,
{ params }: { params: { id: string } }
) {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "无效的用户ID" }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { id: userId },
include: { botConfig: true },
});
if (!user || !user.isBot) {
return NextResponse.json({ error: "未找到该Bot" }, { status: 404 });
}
return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
isBot: user.isBot,
level: user.level,
points: user.points,
botConfig: user.botConfig
? {
displayName: user.botConfig.displayName,
personality: user.botConfig.personality,
primaryForums: user.botConfig.primaryForums,
}
: null,
});
}
+37
View File
@@ -4,6 +4,19 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { getLevelInfo } from "@/lib/level-config";
const INDUSTRY_FORUMS = [
{ name: "电商零售", slug: "ecommerce", icon: "🛒" },
{ name: "AI智能科技", slug: "ai-tech", icon: "🤖" },
{ name: "房地产", slug: "real-estate", icon: "🏠" },
{ name: "金融投资", slug: "finance", icon: "📈" },
{ name: "餐饮食品", slug: "food", icon: "🍜" },
{ name: "自媒体内容", slug: "content", icon: "🎬" },
{ name: "本地生活", slug: "local-life", icon: "🏪" },
{ name: "大健康", slug: "health", icon: "💊" },
{ name: "教育培训", slug: "education", icon: "📚" },
{ name: "外贸跨境", slug: "cross-border", icon: "🌏" },
];
interface UserRanking {
id: number;
name: string | null;
@@ -77,6 +90,30 @@ export function CommunitySidebar() {
return (
<div className="sidebar-bg rounded-xl p-3 sticky top-[68px] max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin space-y-4">
{/* Industry Forums */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-indigo-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0H5m0 0H3m6-8h1m4 0h1m-6 4h1m4 0h1" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">行业论坛</span>
</div>
<div className="space-y-0.5">
{INDUSTRY_FORUMS.map((forum) => (
<Link
key={forum.slug}
href={`/community/${forum.slug}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[11px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-sky-600 hover:shadow-sm"
>
<span className="text-sm shrink-0">{forum.icon}</span>
<span className="flex-1 truncate text-left">{forum.name}</span>
</Link>
))}
</div>
</div>
<div className="h-px bg-border/40" />
{/* Monthly Active Users */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
+27 -2
View File
@@ -15,6 +15,11 @@ export default async function CategoryPage({ params, searchParams }: Props) {
if (!category) notFound();
const childCategories = await prisma.forumCategory.findMany({
where: { parentId: category.id },
select: { id: true, name: true, slug: true },
});
const sort = searchParams.sort || "latest";
const page = parseInt(searchParams.page || "1", 10);
const pageSize = 20;
@@ -30,7 +35,7 @@ export default async function CategoryPage({ params, searchParams }: Props) {
where: { categoryId: category.id },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
_count: { select: { posts: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
@@ -70,6 +75,26 @@ export default async function CategoryPage({ params, searchParams }: Props) {
))}
</div>
{childCategories.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-6">
<a
href={`/community/${category.slug}`}
className="px-3 py-1.5 rounded-lg text-sm bg-primary text-white"
>
全部
</a>
{childCategories.map((child) => (
<a
key={child.id}
href={`/community/${child.slug}`}
className="px-3 py-1.5 rounded-lg text-sm bg-white border border-border/40 text-muted-foreground hover:border-border/60 hover:text-foreground transition-colors"
>
{child.name}
</a>
))}
</div>
)}
<div className="space-y-3">
{topics.map((topic) => {
const initial = topic.user.name?.charAt(0).toUpperCase() || "?";
@@ -99,7 +124,7 @@ export default async function CategoryPage({ params, searchParams }: Props) {
</h3>
</div>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.user.name || topic.user.email?.split("@")[0]}</span>
<span>{topic.user.name || topic.user.email?.split("@")[0]}{topic.user.isBot && <span className="ml-1 px-1 py-0.5 text-[9px] font-medium bg-sky-100 text-sky-600 rounded">AI</span>}</span>
<span>{topic.createdAt?.toLocaleDateString("zh-CN")}</span>
<span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span>
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
interface ReplyFormProps {
topicId: number;
isLocked: boolean;
}
export function ReplyForm({ topicId, isLocked }: ReplyFormProps) {
const [submitting, setSubmitting] = useState(false);
if (isLocked) {
return (
<div className="mt-8 text-center py-6 rounded-xl border border-border/40 bg-muted/20">
<p className="text-sm text-muted-foreground">此话题已锁定,无法回复</p>
</div>
);
}
return (
<div className="mt-8">
<form
onSubmit={async (e) => {
e.preventDefault();
setSubmitting(true);
const formData = new FormData(e.currentTarget);
const content = formData.get("content") as string;
if (!content.trim()) {
setSubmitting(false);
return;
}
const res = await fetch(`/api/forum/topics/${topicId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
setSubmitting(false);
if (res.ok) {
window.location.reload();
}
}}
className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
>
<h3 className="text-sm font-semibold mb-3">发表回复</h3>
<textarea
name="content"
placeholder="写下你的回复..."
rows={4}
maxLength={5000}
className="w-full resize-none rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors placeholder:text-muted-foreground/40 mb-3"
disabled={submitting}
/>
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary text-white text-sm px-5 py-2 hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20 disabled:opacity-50"
>
{submitting ? "提交中..." : "发表回复"}
</button>
</div>
</form>
</div>
);
}
+6 -50
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { ReplyForm } from "./ReplyForm";
export const dynamic = "force-dynamic";
@@ -17,7 +18,7 @@ export default async function TopicPage({ params }: Props) {
where: { id: topicId },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
_count: { select: { posts: true } },
},
});
@@ -32,7 +33,7 @@ export default async function TopicPage({ params }: Props) {
const posts = await prisma.forumPost.findMany({
where: { topicId },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
},
orderBy: { createdAt: "asc" },
take: 50,
@@ -79,7 +80,7 @@ export default async function TopicPage({ params }: Props) {
</div>
)}
<div>
<p className="text-sm font-medium">{topic.user.name || topic.user.email?.split("@")[0]}</p>
<p className="text-sm font-medium">{topic.user.name || topic.user.email?.split("@")[0]}{topic.user.isBot && <span className="ml-1.5 px-1.5 py-0.5 text-[9px] font-medium bg-gradient-to-r from-sky-100 to-purple-100 text-sky-700 rounded-full border border-sky-200/50">🤖 AI</span>}</p>
<p className="text-xs text-muted-foreground/50">
{topic.createdAt?.toLocaleDateString("zh-CN")} {topic.createdAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p>
@@ -113,7 +114,7 @@ export default async function TopicPage({ params }: Props) {
</div>
)}
<div>
<p className="text-sm font-medium">{post.user.name || post.user.email?.split("@")[0]}</p>
<p className="text-sm font-medium">{post.user.name || post.user.email?.split("@")[0]}{post.user.isBot && <span className="ml-1.5 px-1.5 py-0.5 text-[9px] font-medium bg-gradient-to-r from-sky-100 to-purple-100 text-sky-700 rounded-full border border-sky-200/50">🤖 AI</span>}</p>
<p className="text-xs text-muted-foreground/50">
{post.createdAt?.toLocaleDateString("zh-CN")} {post.createdAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p>
@@ -133,52 +134,7 @@ export default async function TopicPage({ params }: Props) {
</div>
</div>
{!topic.isLocked && (
<div className="mt-8">
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const content = formData.get("content") as string;
if (!content.trim()) return;
const res = await fetch(`/api/forum/topics/${topicId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (res.ok) {
window.location.reload();
}
}}
className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
>
<h3 className="text-sm font-semibold mb-3">发表回复</h3>
<textarea
name="content"
placeholder="写下你的回复..."
rows={4}
maxLength={5000}
className="w-full resize-none rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors placeholder:text-muted-foreground/40 mb-3"
/>
<div className="flex justify-end">
<button
type="submit"
className="rounded-lg bg-primary text-white text-sm px-5 py-2 hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
发表回复
</button>
</div>
</form>
</div>
)}
{topic.isLocked && (
<div className="mt-8 text-center py-6 rounded-xl border border-border/40 bg-muted/20">
<p className="text-sm text-muted-foreground">此话题已锁定,无法回复</p>
</div>
)}
<ReplyForm topicId={topicId} isLocked={topic.isLocked} />
</div>
</div>
);
+1 -1
View File
@@ -251,7 +251,7 @@ export default function SkillsPageClient({
<HeroBanner
title="追光"
highlight="Skills"
colorScheme="crimson"
colorScheme="orange"
stats={[
{ label: "AI技能", value: totalSkills, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
+30 -15
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useRef, useCallback } from "react";
type ColorScheme = "cosmos" | "aurora" | "dawn" | "forest" | "crimson";
type ColorScheme = "cosmos" | "aurora" | "dawn" | "forest" | "crimson" | "orange";
interface HeroBannerProps {
title: string;
@@ -127,12 +127,27 @@ const SCHEMES: Record<ColorScheme, SchemeConfig> = {
{ position: "75% 60%", color: "rgba(251,113,133,0.12)" },
],
},
orange: {
bgGradient: "linear-gradient(135deg, #92400e 0%, #b45309 25%, #d97706 55%, #f59e0b 100%)",
highlight: "#fde68a",
star: { r: 253, g: 230, b: 138 },
shoot: { r: 252, g: 211, b: 77 },
topBorder: "linear-gradient(90deg, transparent, rgba(251,191,36,0.5), rgba(245,158,11,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(251,191,36,0.4), rgba(245,158,11,0.3), transparent)",
statBg: "rgba(253,211,77,0.15)",
statColor: "#fef3c7",
statBorder: "1px solid rgba(245,158,11,0.25)",
overlays: [
{ position: "40% 40%", color: "rgba(245,158,11,0.18)" },
{ position: "75% 60%", color: "rgba(253,211,77,0.12)" },
],
},
};
const SCHEME_KEYS: ColorScheme[] = ["cosmos", "aurora", "dawn", "forest", "crimson"];
const STAR_COUNTS: Record<ColorScheme, number> = {
cosmos: 120, aurora: 80, dawn: 100, forest: 90, crimson: 85,
cosmos: 120, aurora: 80, dawn: 100, forest: 90, crimson: 85, orange: 80,
};
const SHOOTING_INTERVAL = 5000;
@@ -370,27 +385,27 @@ export function HeroBanner({
<div className="relative w-36 h-36">
{colorScheme === "cosmos" && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<circle cx="72" cy="72" r="60" stroke="rgba(147,197,253,0.25)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="45" stroke="rgba(191,219,254,0.20)" strokeWidth="0.5" fill="none" strokeDasharray="4 6"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="30" stroke="rgba(147,197,253,0.15)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="20s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="8" fill="rgba(147,197,253,0.25)" /><circle cx="72" cy="72" r="3" fill="rgba(191,219,254,0.8)" />
<circle cx="72" cy="12" r="2" fill="rgba(191,219,254,0.6)"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="27" r="1.5" fill="rgba(147,197,253,0.5)"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="60" stroke="rgba(147,197,253,0.14)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="45" stroke="rgba(191,219,254,0.10)" strokeWidth="0.5" fill="none" strokeDasharray="4 6"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="30" stroke="rgba(147,197,253,0.08)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="20s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="8" fill="rgba(147,197,253,0.14)" /><circle cx="72" cy="72" r="3" fill="rgba(191,219,254,0.45)" />
<circle cx="72" cy="12" r="2" fill="rgba(191,219,254,0.30)"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="27" r="1.5" fill="rgba(147,197,253,0.25)"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /></circle>
</svg>
)}
{colorScheme === "dawn" && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<defs><linearGradient id="dawn-sun" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#fbbf24" stopOpacity="0.4" /><stop offset="50%" stopColor="#f59e0b" stopOpacity="0.3" /><stop offset="100%" stopColor="#d97706" stopOpacity="0.2" /></linearGradient></defs>
<circle cx="72" cy="72" r="60" stroke="rgba(251,191,36,0.20)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="45" stroke="rgba(245,158,11,0.15)" strokeWidth="0.5" fill="none" strokeDasharray="6 4"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="28s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="30" stroke="rgba(251,191,36,0.12)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="22s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="18" fill="url(#dawn-sun)" /><circle cx="72" cy="72" r="10" fill="rgba(251,191,36,0.25)" /><circle cx="72" cy="72" r="4" fill="rgba(253,230,138,0.7)" />
<defs><linearGradient id="dawn-sun" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#fbbf24" stopOpacity="0.22" /><stop offset="50%" stopColor="#f59e0b" stopOpacity="0.15" /><stop offset="100%" stopColor="#d97706" stopOpacity="0.10" /></linearGradient></defs>
<circle cx="72" cy="72" r="60" stroke="rgba(251,191,36,0.12)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="45" stroke="rgba(245,158,11,0.08)" strokeWidth="0.5" fill="none" strokeDasharray="6 4"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="28s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="30" stroke="rgba(251,191,36,0.06)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="22s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="18" fill="url(#dawn-sun)" /><circle cx="72" cy="72" r="10" fill="rgba(251,191,36,0.12)" /><circle cx="72" cy="72" r="4" fill="rgba(253,230,138,0.35)" />
{[0,45,90,135,180,225,270,315].map(deg => (
<line key={deg} x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform={`rotate(${deg} 72 72)`}><animateTransform attributeName="transform" type="rotate" from={`${deg} 72 72`} to={`${deg+360} 72 72`} dur="35s" repeatCount="indefinite" /></line>
<line key={deg} x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.15)" strokeWidth="1" strokeLinecap="round" transform={`rotate(${deg} 72 72)`}><animateTransform attributeName="transform" type="rotate" from={`${deg} 72 72`} to={`${deg+360} 72 72`} dur="35s" repeatCount="indefinite" /></line>
))}
</svg>
)}
{(colorScheme === "aurora" || colorScheme === "forest" || colorScheme === "crimson") && (
{(colorScheme === "aurora" || colorScheme === "forest" || colorScheme === "crimson" || colorScheme === "orange") && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<defs><linearGradient id="orb-grad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor={scheme.highlight} stopOpacity="0.3" /><stop offset="50%" stopColor={scheme.highlight} stopOpacity="0.15" /><stop offset="100%" stopColor={scheme.highlight} stopOpacity="0.25" /></linearGradient></defs>
<circle cx="72" cy="72" r="55" stroke="url(#orb-grad)" strokeWidth="1" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="40s" repeatCount="indefinite" /></circle>
+3 -3
View File
@@ -26,7 +26,7 @@ export const toolImportSchema = z.object({
pricingDetail: z.string().optional(),
features: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
})).min(1, "tools 数组不能为空"),
})).min(1, "tools 数组不能为空").max(100, "一次最多导入100条"),
categoryId: z.number().int().positive("categoryId 必须为正整数"),
});
@@ -39,7 +39,7 @@ export const skillImportSchema = z.object({
rating: z.number().min(0).max(5).optional(),
features: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
})).min(1, "skills 数组不能为空"),
})).min(1, "skills 数组不能为空").max(100, "一次最多导入100条"),
categoryId: z.number().int().positive("categoryId 必须为正整数"),
});
@@ -51,7 +51,7 @@ export const newsImportSchema = z.object({
category: z.string().optional(),
sourceUrl: z.string().optional(),
hotScore: z.number().min(0).max(100).optional(),
})).min(1, "items 数组不能为空"),
})).min(1, "items 数组不能为空").max(50, "一次最多导入50条"),
});
export const checkTypeSchema = z.object({