v0.11.1: 权限体系重构 + AI管线优化 + 内容整理
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface CategoryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
parentId: number | null;
|
||||
sortOrder: number;
|
||||
toolCount: number;
|
||||
childCount: number;
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
slug: "",
|
||||
icon: "",
|
||||
parentId: "" as string,
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
export default function CategoriesAdminPage() {
|
||||
const [categories, setCategories] = useState<CategoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [editCat, setEditCat] = useState<CategoryItem | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/categories");
|
||||
const data = await res.json();
|
||||
setCategories(data);
|
||||
} catch {
|
||||
console.error("Fetch categories failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
const openEdit = (cat: CategoryItem) => {
|
||||
setIsNew(false);
|
||||
setEditCat(cat);
|
||||
setForm({
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
icon: cat.icon || "",
|
||||
parentId: cat.parentId?.toString() || "",
|
||||
sortOrder: cat.sortOrder,
|
||||
});
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setIsNew(true);
|
||||
setEditCat({} as CategoryItem);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
slug: form.slug || undefined,
|
||||
icon: form.icon || null,
|
||||
parentId: form.parentId ? parseInt(form.parentId) : null,
|
||||
sortOrder: form.sortOrder,
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
const res = await fetch("/api/admin/categories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "创建失败");
|
||||
return;
|
||||
}
|
||||
} else if (editCat?.id) {
|
||||
const res = await fetch(`/api/admin/categories/${editCat.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "更新失败");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEditCat(null);
|
||||
fetchCategories();
|
||||
} catch {
|
||||
setError("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (cat: CategoryItem) => {
|
||||
if (!confirm(`确定删除分类"${cat.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/categories/${cat.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
alert(data.error || "删除失败");
|
||||
return;
|
||||
}
|
||||
setCategories((prev) => prev.filter((c) => c.id !== cat.id));
|
||||
} catch {
|
||||
console.error("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const topLevelCategories = categories.filter((c) => !c.parentId);
|
||||
const totalTools = categories.reduce((sum, c) => sum + c.toolCount, 0);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">分类管理</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
管理AI工具的分类结构
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{topLevelCategories.length}</p>
|
||||
<p className="text-xs text-muted-foreground">顶级分类</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-purple-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-purple-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{categories.length}</p>
|
||||
<p className="text-xs text-muted-foreground">全部分类</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{totalTools}</p>
|
||||
<p className="text-xs text-muted-foreground">分类下工具</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">共 {categories.length} 个分类</span>
|
||||
<Button onClick={openNew} className="gap-2 glow-sm rounded-xl">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加分类
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无分类</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">点击上方按钮添加第一个分类</p>
|
||||
<Button variant="outline" size="sm" className="mt-4 rounded-xl" onClick={openNew}>
|
||||
添加分类
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="px-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-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">Slug</th>
|
||||
<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-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">子分类</th>
|
||||
<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">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topLevelCategories.map((cat) => (
|
||||
<>
|
||||
<tr key={cat.id} className="border-b border-border/40 transition-all duration-200 hover:bg-muted/30">
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{cat.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<code className="rounded-md border border-border/40 bg-white px-2 py-0.5 text-xs text-muted-foreground">{cat.slug}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-muted-foreground">{cat.sortOrder}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{cat.childCount > 0 ? (
|
||||
<span className="inline-flex rounded-lg border border-purple-200/60 bg-purple-50 px-2 py-0.5 text-[10px] font-medium text-purple-600">{cat.childCount}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="inline-flex rounded-lg border border-primary/20 bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">{cat.toolCount}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(cat)} className="gap-1.5 rounded-lg border-border/50 text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDelete(cat)} className="gap-1.5 rounded-lg text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{categories.filter((c) => c.parentId === cat.id).map((child) => (
|
||||
<tr key={child.id} className="border-b border-border/50 bg-muted/20 transition-all duration-200 hover:bg-white">
|
||||
<td className="px-5 py-3 pl-12">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-border">└</span>
|
||||
<span className="text-muted-foreground">{child.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<code className="rounded-md border border-border/40 bg-white px-2 py-0.5 text-xs text-muted-foreground">{child.slug}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{child.sortOrder}</td>
|
||||
<td className="px-5 py-3">
|
||||
{child.childCount > 0 ? (
|
||||
<span className="inline-flex rounded-lg border border-purple-200/60 bg-purple-50 px-2 py-0.5 text-[10px] font-medium text-purple-600">{child.childCount}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex rounded-lg border border-primary/20 bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">{child.toolCount}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(child)} className="gap-1.5 rounded-lg border-border/50 text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDelete(child)} className="gap-1.5 rounded-lg text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={!!editCat} onOpenChange={(open) => { if (!open) { setEditCat(null); setError(""); } }}>
|
||||
<DialogContent className="border-border/50 bg-white text-foreground sm:max-w-md rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">{isNew ? "添加分类" : "编辑分类"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">名称</label>
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200" placeholder="输入分类名称" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">Slug</label>
|
||||
<input value={form.slug} onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))} placeholder="自动生成" className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">图标</label>
|
||||
<input value={form.icon} onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))} placeholder="可选" className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">排序值</label>
|
||||
<input type="number" value={form.sortOrder} onChange={(e) => setForm((f) => ({ ...f, sortOrder: parseInt(e.target.value) || 0 }))} className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">父分类</label>
|
||||
<select value={form.parentId} onChange={(e) => setForm((f) => ({ ...f, parentId: e.target.value }))} className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200">
|
||||
<option value="">无(顶级分类)</option>
|
||||
{topLevelCategories.filter((c) => c.id !== editCat?.id).map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="mt-6 gap-2">
|
||||
<Button variant="outline" onClick={() => { setEditCat(null); setError(""); }} disabled={saving} className="rounded-xl">取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="glow-sm rounded-xl">{saving ? "保存中..." : "保存"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ToolItem {
|
||||
id: number;
|
||||
name: string;
|
||||
websiteUrl: string | null;
|
||||
logoUrl: string | null;
|
||||
categoryId: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
type TabType = "published" | "pending";
|
||||
|
||||
interface CheckResult {
|
||||
name: string;
|
||||
accessible: boolean;
|
||||
reason: string;
|
||||
suggestion: string;
|
||||
logoFetched: boolean;
|
||||
logoMethod: string;
|
||||
logoUrl: string;
|
||||
}
|
||||
|
||||
export default function CheckToolsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("pending");
|
||||
const [tools, setTools] = useState<ToolItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResults, setCheckResults] = useState<CheckResult[]>([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressTotal, setProgressTotal] = useState(0);
|
||||
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const noUrlCount = tools.filter((t) => !t.websiteUrl).length;
|
||||
const noLogoCount = tools.filter((t) => !t.logoUrl).length;
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const endpoint = activeTab === "published" ? "/api/admin/tools?pageSize=9999" : "/api/admin/pending?pageSize=9999";
|
||||
const res = await fetch(endpoint);
|
||||
const data = await res.json();
|
||||
const list = (data.tools || []).map((t: { id: number; name: string; websiteUrl: string | null; logoUrl: string | null; categoryId: number | null; status?: string }) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
websiteUrl: t.websiteUrl,
|
||||
logoUrl: t.logoUrl,
|
||||
categoryId: t.categoryId,
|
||||
status: activeTab,
|
||||
}));
|
||||
setTools(list);
|
||||
} catch {
|
||||
console.error("Failed to fetch tools");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
}, [fetchTools]);
|
||||
|
||||
const handleQuickCheck = async () => {
|
||||
setChecking(true);
|
||||
setCheckResults([]);
|
||||
setProgress(0);
|
||||
setProgressTotal(0);
|
||||
try {
|
||||
const res = await fetch("/api/admin/check-tools", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: activeTab }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
alert(errData.error || "检测失败");
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const allResults: { id: number; name: string; url: string; accessible: boolean; logoFetched: boolean; logoMethod: string; logoUrl: string }[] = data.results || [];
|
||||
setProgressTotal(allResults.length);
|
||||
const mapped = allResults.map((r) => ({
|
||||
name: r.name,
|
||||
accessible: r.accessible,
|
||||
reason: r.accessible ? "" : "网站不可访问",
|
||||
suggestion: r.accessible ? "" : "建议删除",
|
||||
logoFetched: r.logoFetched,
|
||||
logoMethod: r.logoMethod,
|
||||
logoUrl: r.logoUrl,
|
||||
}));
|
||||
for (let i = 0; i < mapped.length; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
setCheckResults((prev) => [...prev, mapped[i]]);
|
||||
setProgress(i + 1);
|
||||
}
|
||||
fetchTools();
|
||||
} catch {
|
||||
alert("检测请求失败");
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定删除该工具?")) return;
|
||||
setDeletingIds((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
const endpoint = activeTab === "published" ? `/api/admin/tools/${id}` : `/api/admin/pending/${id}`;
|
||||
const res = await fetch(endpoint, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setTools((prev) => prev.filter((t) => t.id !== id));
|
||||
} else {
|
||||
alert(data.error || "删除失败");
|
||||
}
|
||||
} catch {
|
||||
alert("删除请求失败");
|
||||
} finally {
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const failedTools = checkResults.filter((r) => !r.accessible);
|
||||
const accessibleTools = checkResults.filter((r) => r.accessible);
|
||||
const logoFetchedTools = checkResults.filter((r) => r.logoFetched);
|
||||
const methodStats = new Map<string, number>();
|
||||
for (const r of logoFetchedTools) {
|
||||
if (r.logoMethod) {
|
||||
methodStats.set(r.logoMethod, (methodStats.get(r.logoMethod) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const sortedMethods = Array.from(methodStats.entries()).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">检测AI工具</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
一键检测所有工具的网站可访问性,同时自动抓取缺失Logo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-4">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">1</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">选择范围</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground leading-relaxed">选择检测已发布或待审核工具</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">2</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">检测+抓取</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground leading-relaxed">自动检测可访问性,同时抓取缺失Logo</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">3</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Logo方法</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground leading-relaxed">记录每种抓取方法的成功次数</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">4</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">处理失效</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground leading-relaxed">一键删除所有不可访问的失效工具</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-1 rounded-xl border border-border/50 bg-white p-1">
|
||||
<button
|
||||
onClick={() => { setActiveTab("published"); setCheckResults([]); }}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ${activeTab === "published" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground hover:bg-white"}`}
|
||||
>
|
||||
已发布工具
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab("pending"); setCheckResults([]); }}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ${activeTab === "pending" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground hover:bg-white"}`}
|
||||
>
|
||||
待审核工具
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<span>共 <strong className="text-foreground">{tools.length}</strong> 个</span>
|
||||
<span>无网址 <strong className="text-amber-600">{noUrlCount}</strong></span>
|
||||
<span>无Logo <strong className="text-violet-600">{noLogoCount}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleQuickCheck} disabled={checking || loading} className="gap-1.5 rounded-xl">
|
||||
{checking ? (
|
||||
<><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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>快速检测+抓取Logo</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{checking && 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">正在检测并抓取Logo...</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">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-primary to-primary/70 transition-all duration-300" style={{ width: `${progressTotal > 0 ? (progress / progressTotal) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResults.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-border/50 bg-white p-4">
|
||||
<p className="text-2xl font-bold text-foreground">{checkResults.length}</p>
|
||||
<p className="text-xs text-muted-foreground">已检测</p>
|
||||
</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">{accessibleTools.length}</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">{failedTools.length}</p>
|
||||
<p className="text-xs text-red-600/60">失效</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-violet-500/20 bg-violet-500/5 p-4">
|
||||
<p className="text-2xl font-bold text-violet-600">{logoFetchedTools.length}</p>
|
||||
<p className="text-xs text-violet-600/60">Logo新抓取</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedMethods.length > 0 && (
|
||||
<div className="rounded-2xl border border-violet-500/20 bg-violet-500/5 p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-violet-300">Logo抓取方法统计</h3>
|
||||
<span className="text-[10px] text-violet-600/50">记录每种方法的成功次数,优化抓取策略</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedMethods.map(([method, count]) => (
|
||||
<div key={method} className="flex items-center justify-between rounded-lg border border-violet-500/10 bg-white px-3 py-2">
|
||||
<span className="text-xs font-medium text-foreground">{method}</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-violet-100 px-2 py-0.5 text-[10px] font-bold text-violet-700">
|
||||
{count}次
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failedTools.length > 0 && (
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-red-300">失效工具({failedTools.length}个)</h3>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!confirm(`确定批量删除 ${failedTools.length} 个失效工具?`)) return;
|
||||
for (const ft of failedTools) {
|
||||
const tool = tools.find((t) => t.name === ft.name);
|
||||
if (tool) await handleDelete(tool.id);
|
||||
}
|
||||
}}
|
||||
className="gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
一键删除失效
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{failedTools.map((r, i) => {
|
||||
const tool = tools.find((t) => t.name === r.name);
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-red-500/10 bg-white px-4 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{r.name}</p>
|
||||
<p className="text-[11px] text-red-600/60">{r.reason || "不可访问"} · {r.suggestion || "建议删除"}</p>
|
||||
</div>
|
||||
{tool && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(tool.id)}
|
||||
disabled={deletingIds.has(tool.id)}
|
||||
className="ml-3 gap-1 rounded-lg text-xs shrink-0"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logoFetchedTools.length > 0 && (
|
||||
<div className="rounded-2xl border border-violet-500/20 bg-violet-500/5 p-5">
|
||||
<h3 className="text-sm font-semibold text-violet-300 mb-3">Logo新抓取成功({logoFetchedTools.length}个)</h3>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{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" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{r.name}</p>
|
||||
<p className="text-[11px] text-violet-600/70">
|
||||
抓取方法:<span className="font-medium text-violet-700">{r.logoMethod}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accessibleTools.length > 0 && (
|
||||
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-5">
|
||||
<h3 className="text-sm font-semibold text-emerald-300 mb-3">可访问工具({accessibleTools.length}个)</h3>
|
||||
<div className="max-h-40 overflow-y-auto scrollbar-thin">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{accessibleTools.map((r, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 rounded-lg border border-emerald-500/10 bg-white px-2.5 py-1 text-[11px] text-emerald-600/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
{r.name}
|
||||
{r.logoFetched && (
|
||||
<span className="text-violet-600/60">+Logo</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResults.length === 0 && !checking && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">尚未开始检测</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">点击「快速检测+抓取Logo」按钮,自动检测所有工具的网站可访问性并抓取缺失Logo</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DailyReportItem {
|
||||
id?: number;
|
||||
sortOrder: number;
|
||||
category: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
detail?: string;
|
||||
sourceUrl?: string;
|
||||
imageUrl?: string;
|
||||
hotScore?: number;
|
||||
}
|
||||
|
||||
interface DailyReport {
|
||||
id: number;
|
||||
date: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
hotCount: number;
|
||||
status: string;
|
||||
items: DailyReportItem[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "model", label: "模型前沿", color: "bg-blue-100 text-blue-700" },
|
||||
{ key: "product", label: "产品落地", color: "bg-green-100 text-green-700" },
|
||||
{ key: "opensource", label: "开源生态", color: "bg-purple-100 text-purple-700" },
|
||||
{ key: "business", label: "商业产业", color: "bg-amber-100 text-amber-700" },
|
||||
{ key: "policy", label: "政策治理", color: "bg-red-100 text-red-700" },
|
||||
{ key: "insight", label: "深度洞察", color: "bg-cyan-100 text-cyan-700" },
|
||||
];
|
||||
|
||||
const AI_PROMPT_TEMPLATE = `你是AI行业资深分析师。请为{date}生成一份AI日报,包含6-8条当日最重要的AI行业热点新闻。
|
||||
|
||||
要求:
|
||||
1. 每条新闻必须有真实的新闻事件基础,不能编造
|
||||
2. 每条新闻必须覆盖不同分类维度,确保多样性
|
||||
3. 标题简洁有力(20字以内),摘要精炼(50字以内),详情深入(100-200字)
|
||||
4. 优先关注中国AI行业动态,兼顾全球重要事件
|
||||
5. 分类标注规则(必须使用以下key值):
|
||||
- model:模型前沿(LLM/多模态新模型发布、架构创新、训练方法突破)
|
||||
- product:产品落地(AI产品发布/更新、C端应用上线、B端解决方案、新功能)
|
||||
- opensource:开源生态(开源项目动态、框架更新、开源社区大事件)
|
||||
- business:商业产业(投融资、收购并购、财报营收、公司战略、行业趋势)
|
||||
- policy:政策治理(AI法规政策、监管动态、伦理治理、国际AI治理)
|
||||
- insight:深度洞察(行业领袖观点、深度分析报告、学术研究前沿)
|
||||
|
||||
返回纯JSON格式:
|
||||
{
|
||||
"title": "AI日报:{日期} 核心要点概览",
|
||||
"summary": "今日AI行业整体趋势概述(100字以内)",
|
||||
"items": [
|
||||
{
|
||||
"category": "model",
|
||||
"title": "新闻标题",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细内容描述",
|
||||
"sourceUrl": "来源链接(如有)",
|
||||
"hotScore": 85
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
hotScore热度评分规则(0-100):
|
||||
- 统计该新闻在以下信息源中被报道的次数:36氪、量子位、机器之心、新智元、HuggingFace Blog、TechCrunch、The Verge等主流AI/科技媒体
|
||||
- 被报道次数越多,热度越高:1个源=20分,2个源=40分,3个源=60分,4个源=80分,5个及以上=100分
|
||||
- 重大突破性新闻(如GPT-5发布级别)基础分+30
|
||||
- 中国本土独家新闻(仅国内媒体报道)基础分+10
|
||||
- 确保每条新闻都有hotScore值`;
|
||||
|
||||
export default function DailyReportsAdminPage() {
|
||||
const [reports, setReports] = useState<DailyReport[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<DailyReport | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [formDate, setFormDate] = useState("");
|
||||
const [formTitle, setFormTitle] = useState("");
|
||||
const [formSummary, setFormSummary] = useState("");
|
||||
const [formItems, setFormItems] = useState<DailyReportItem[]>([]);
|
||||
|
||||
const [importJson, setImportJson] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [aiGenerating, setAiGenerating] = useState(false);
|
||||
|
||||
const fetchReports = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/daily-reports?page=${page}&pageSize=20`);
|
||||
const data = await res.json();
|
||||
setReports(data.reports || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
console.error("Failed to fetch reports");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
}, [fetchReports]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormDate(new Date().toISOString().split("T")[0]);
|
||||
setFormTitle("");
|
||||
setFormSummary("");
|
||||
setFormItems([]);
|
||||
setEditing(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
resetForm();
|
||||
setFormDate(new Date().toISOString().split("T")[0]);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const startEdit = (report: DailyReport) => {
|
||||
setEditing(report);
|
||||
setFormDate(report.date.split("T")[0]);
|
||||
setFormTitle(report.title);
|
||||
setFormSummary(report.summary || "");
|
||||
setFormItems(report.items.map((item) => ({ ...item })));
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setFormItems([
|
||||
...formItems,
|
||||
{
|
||||
sortOrder: formItems.length,
|
||||
category: "model",
|
||||
title: "",
|
||||
summary: "",
|
||||
detail: "",
|
||||
sourceUrl: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: string, value: string | boolean | number) => {
|
||||
const updated = [...formItems];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setFormItems(updated);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setFormItems(formItems.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formDate || !formTitle) {
|
||||
alert("日期和标题不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
date: formDate,
|
||||
title: formTitle,
|
||||
summary: formSummary || undefined,
|
||||
items: formItems.filter((item) => item.title.trim()),
|
||||
};
|
||||
|
||||
const url = editing ? `/api/admin/daily-reports/${editing.id}` : "/api/admin/daily-reports";
|
||||
const method = editing ? "PUT" : "POST";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.report) {
|
||||
resetForm();
|
||||
fetchReports();
|
||||
} else {
|
||||
alert("保存失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
alert("保存请求失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定删除该日报?")) return;
|
||||
try {
|
||||
await fetch(`/api/admin/daily-reports/${id}`, { method: "DELETE" });
|
||||
fetchReports();
|
||||
} catch {
|
||||
alert("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (id: number) => {
|
||||
try {
|
||||
await fetch(`/api/admin/daily-reports/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "published" }),
|
||||
});
|
||||
fetchReports();
|
||||
} catch {
|
||||
alert("发布失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importJson.trim()) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(importJson.trim());
|
||||
} catch {
|
||||
const match = importJson.trim().match(/\{[\s\S]*\}/);
|
||||
if (match) parsed = JSON.parse(match[0]);
|
||||
else throw new Error("无法解析JSON");
|
||||
}
|
||||
|
||||
fillFormFromData(parsed);
|
||||
setImportJson("");
|
||||
} catch (err) {
|
||||
alert("JSON解析失败: " + (err instanceof Error ? err.message : "格式不正确"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fillFormFromData = (data: { title?: string; summary?: string; items?: Record<string, unknown>[] }) => {
|
||||
setFormDate(new Date().toISOString().split("T")[0]);
|
||||
setFormTitle(data.title || `AI日报:${new Date().toLocaleDateString("zh-CN")}`);
|
||||
setFormSummary(data.summary || "");
|
||||
setFormItems(
|
||||
(data.items || []).map((item: Record<string, unknown>, index: number) => ({
|
||||
sortOrder: index,
|
||||
category: (item.category as string) || "model",
|
||||
title: (item.title as string) || "",
|
||||
summary: (item.summary as string) || "",
|
||||
detail: (item.detail as string) || "",
|
||||
sourceUrl: (item.sourceUrl as string) || "",
|
||||
hotScore: (item.hotScore as number) || undefined,
|
||||
}))
|
||||
);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleAiGenerate = async () => {
|
||||
setAiGenerating(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/daily-reports/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ date: new Date().toLocaleDateString("zh-CN") }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
fillFormFromData(data.data);
|
||||
} else {
|
||||
alert("AI生成失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
alert("AI生成请求失败,请检查网络连接");
|
||||
} finally {
|
||||
setAiGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryStyle = (key: string) => {
|
||||
return CATEGORIES.find((c) => c.key === key)?.color || "bg-gray-100 text-gray-700";
|
||||
};
|
||||
|
||||
const getCategoryLabel = (key: string) => {
|
||||
return CATEGORIES.find((c) => c.key === key)?.label || key;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">AI日报管理</h1>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-24 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">AI日报管理</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">创建、编辑和发布每日AI行业日报</p>
|
||||
</div>
|
||||
<Button onClick={startCreate} className="gap-1.5 rounded-xl">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
新建日报
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!showForm && (
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5 shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||
<p className="text-xs text-muted-foreground">总日报数</p>
|
||||
<p className="mt-1 text-2xl font-bold">{total}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5 shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||
<p className="text-xs text-muted-foreground">已发布</p>
|
||||
<p className="mt-1 text-2xl font-bold text-emerald-600">
|
||||
{reports.filter((r) => r.status === "published").length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5 shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||
<p className="text-xs text-muted-foreground">草稿</p>
|
||||
<p className="mt-1 text-2xl font-bold text-amber-600">
|
||||
{reports.filter((r) => r.status === "draft").length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showForm && (
|
||||
<div className="mb-6 rounded-2xl border border-violet-500/20 bg-violet-500/5 p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-violet-700">AI生成日报</h3>
|
||||
<span className="text-[10px] text-violet-600/50">复制提示词 → 发给AI → 粘贴JSON导入</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleAiGenerate}
|
||||
disabled={aiGenerating}
|
||||
size="sm"
|
||||
className="gap-1.5 rounded-lg text-xs bg-violet-600 hover:bg-violet-500"
|
||||
>
|
||||
{aiGenerating ? (
|
||||
<>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin">
|
||||
<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="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
一键AI生成
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const prompt = AI_PROMPT_TEMPLATE.replace("{date}", new Date().toLocaleDateString("zh-CN"));
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
}}
|
||||
className="gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
复制提示词
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={importJson}
|
||||
onChange={(e) => setImportJson(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="将AI返回的JSON粘贴到这里,点击导入自动填充表单..."
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground/30 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all resize-y font-mono leading-relaxed"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button onClick={handleImport} disabled={importing} size="sm" className="gap-1.5 rounded-lg text-xs">
|
||||
导入JSON填充表单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 rounded-2xl border border-border/50 bg-white p-6 shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">{editing ? "编辑日报" : "新建日报"}</h2>
|
||||
<Button variant="ghost" size="sm" onClick={resetForm} className="text-xs">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formDate}
|
||||
onChange={(e) => setFormDate(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-border/60 bg-white px-3 text-sm focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="AI日报:2026年5月6日 核心要点概览"
|
||||
className="h-9 w-full rounded-lg border border-border/60 bg-white px-3 text-sm focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">概述</label>
|
||||
<textarea
|
||||
value={formSummary}
|
||||
onChange={(e) => setFormSummary(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="今日AI行业整体趋势概述..."
|
||||
className="w-full rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
新闻条目 ({formItems.length})
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={addItem} className="gap-1 text-xs rounded-lg">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加条目
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{formItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-border/40 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-bold text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={item.category}
|
||||
onChange={(e) => updateItem(index, "category", e.target.value)}
|
||||
className="h-8 rounded-lg border border-border/60 bg-white px-2 text-xs focus:border-primary/50 focus:outline-none"
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<option key={cat.key} value={cat.key}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={item.title}
|
||||
onChange={(e) => updateItem(index, "title", e.target.value)}
|
||||
placeholder="新闻标题"
|
||||
className="h-8 flex-1 rounded-lg border border-border/60 bg-white px-3 text-sm focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={item.hotScore ?? 0}
|
||||
onChange={(e) => updateItem(index, "hotScore", parseInt(e.target.value) || 0)}
|
||||
placeholder="热度"
|
||||
className="h-8 w-16 rounded-lg border border-border/60 bg-white px-2 text-sm text-center tabular-nums focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15"
|
||||
title="热度评分(0-100)"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={item.summary || ""}
|
||||
onChange={(e) => updateItem(index, "summary", e.target.value)}
|
||||
placeholder="一句话摘要"
|
||||
className="h-8 w-full rounded-lg border border-border/60 bg-white px-3 text-xs focus:border-primary/50 focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
value={item.detail || ""}
|
||||
onChange={(e) => updateItem(index, "detail", e.target.value)}
|
||||
rows={2}
|
||||
placeholder="详细内容..."
|
||||
className="w-full rounded-lg border border-border/60 bg-white px-3 py-1.5 text-xs focus:border-primary/50 focus:outline-none resize-y"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.sourceUrl || ""}
|
||||
onChange={(e) => updateItem(index, "sourceUrl", e.target.value)}
|
||||
placeholder="来源链接(选填)"
|
||||
className="h-8 w-full rounded-lg border border-border/60 bg-white px-3 text-xs focus:border-primary/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeItem(index)}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-red-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border/40">
|
||||
<Button variant="outline" onClick={resetForm} className="rounded-xl">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleSave();
|
||||
}}
|
||||
disabled={saving}
|
||||
className="rounded-xl"
|
||||
>
|
||||
{saving ? "保存中..." : editing ? "更新日报" : "创建日报"}
|
||||
</Button>
|
||||
{editing && editing.status === "draft" && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleSave();
|
||||
await handlePublish(editing.id);
|
||||
}}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
保存并发布
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{reports.length === 0 ? (
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">暂无日报,点击上方「新建日报」开始创建</p>
|
||||
</div>
|
||||
) : (
|
||||
reports.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className="group rounded-2xl border border-border/50 bg-white p-5 shadow-[0_1px_3px_rgba(0,0,0,0.05)] transition-all hover:border-border/60 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{new Date(report.date).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
report.status === "published"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{report.status === "published" ? "已发布" : "草稿"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/40">
|
||||
{report.hotCount} 条热点
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">{report.title}</h3>
|
||||
{report.summary && (
|
||||
<p className="mt-1 text-xs text-muted-foreground/70 line-clamp-2">{report.summary}</p>
|
||||
)}
|
||||
{report.items.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{report.items.slice(0, 5).map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] ${getCategoryStyle(item.category)}`}
|
||||
>
|
||||
{item.title.substring(0, 15)}...
|
||||
</span>
|
||||
))}
|
||||
{report.items.length > 5 && (
|
||||
<span className="text-[10px] text-muted-foreground/40">
|
||||
+{report.items.length - 5} 更多
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{report.status === "draft" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePublish(report.id)}
|
||||
className="gap-1 text-xs rounded-lg text-emerald-600 border-emerald-200 hover:bg-emerald-50"
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => startEdit(report)}
|
||||
className="text-xs rounded-lg"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(report.id)}
|
||||
className="text-xs rounded-lg text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > 20 && (
|
||||
<div className="mt-6 flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="rounded-lg text-xs"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="flex items-center text-xs text-muted-foreground">
|
||||
{page} / {Math.ceil(total / 20)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= Math.ceil(total / 20)}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="rounded-lg text-xs"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const NEWS_CATEGORIES = ["产品发布", "融资投资", "技术突破", "政策法规", "开源项目", "行业事件"];
|
||||
|
||||
const NEWS_PROMPT = `你是AI行业新闻分析师。请推荐今天({today})全球AI领域最重要的 10 条新闻动态。
|
||||
|
||||
严格要求:
|
||||
1. 新闻必须是真实发生的,时间在最近一周内
|
||||
2. 涵盖产品发布、融资投资、技术突破、政策法规、开源项目、行业事件等多个领域
|
||||
3. 优先关注中国AI行业动态
|
||||
4. sourceUrl必须是真实可访问的新闻链接
|
||||
|
||||
每条新闻包含以下字段:
|
||||
- title: 新闻标题(简洁有力,20字以内)
|
||||
- summary: 新闻摘要(100字以内,突出核心信息)
|
||||
- detail: 详细描述(200字以内,包含关键数据和影响分析)
|
||||
- category: 分类(只能是以下之一:"产品发布"/"融资投资"/"技术突破"/"政策法规"/"开源项目"/"行业事件")
|
||||
- sourceUrl: 新闻来源链接
|
||||
- hotScore: 热度评分(1-100,根据影响力和关注度评估)
|
||||
|
||||
返回纯JSON数组,不要包含任何其他文字或markdown标记。`;
|
||||
|
||||
export default function DiscoverNewsPage() {
|
||||
const [jsonInput, setJsonInput] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
success: boolean;
|
||||
date: string;
|
||||
totalItems: number;
|
||||
} | null>(null);
|
||||
const [existingReport, setExistingReport] = useState<{
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
items: { id: number; category: string; title: string; summary: string; hotScore: number }[];
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport();
|
||||
}, []);
|
||||
|
||||
const fetchReport = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/discover-news");
|
||||
const data = await res.json();
|
||||
if (data.report) {
|
||||
setExistingReport(data.report);
|
||||
}
|
||||
} catch { /* */ } finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPrompt = () => {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
return NEWS_PROMPT.replace("{today}", today);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!jsonInput.trim()) return;
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
try {
|
||||
let items = JSON.parse(jsonInput.trim());
|
||||
if (!Array.isArray(items)) {
|
||||
alert("JSON格式错误:需要是数组");
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/admin/discover-news", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setImportResult({ success: true, date: data.date, totalItems: data.totalItems });
|
||||
fetchReport();
|
||||
} else {
|
||||
alert(data.error || "导入失败");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("JSON解析失败: " + (err instanceof Error ? err.message : "格式错误"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">AI新闻发现</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
复制提示词发给AI → 粘贴JSON → 导入今日新闻
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">1</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">复制提示词</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">复制提示词发给AI助手获取新闻JSON</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">2</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">粘贴导入</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">将AI返回的JSON粘贴并导入</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">3</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">查看日报</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">导入后自动生成今日AI日报</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-primary">新闻发现提示词</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => navigator.clipboard.writeText(getPrompt())} className="gap-1.5 rounded-lg text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
复制提示词
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto rounded-xl border border-primary/10 bg-white p-3 scrollbar-thin">
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-relaxed whitespace-pre-wrap">{getPrompt()}</p>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
{NEWS_CATEGORIES.map((cat) => (
|
||||
<span key={cat} className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary/70">{cat}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<h3 className="text-sm font-semibold mb-3">粘贴AI返回的JSON</h3>
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
placeholder='[{"title":"新闻标题","summary":"摘要","detail":"详细描述","category":"产品发布","sourceUrl":"https://...","hotScore":80}]'
|
||||
className="w-full h-48 rounded-xl border border-border/50 bg-muted/10 p-4 text-xs font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/30 resize-y"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground/50">粘贴AI返回的纯JSON数组,系统会自动生成今日AI日报</p>
|
||||
<Button onClick={handleImport} disabled={importing || !jsonInput.trim()} className="gap-1.5 rounded-xl">
|
||||
{importing ? "导入中..." : "导入今日新闻"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div className="mb-8 rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-5">
|
||||
<h3 className="text-sm font-semibold text-emerald-600">✓ 导入成功!{importResult.date} 共 {importResult.totalItems} 条新闻</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingReport && (
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">{existingReport.title}</h2>
|
||||
<div className="space-y-3">
|
||||
{existingReport.items.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 rounded-xl border border-border/40 p-3">
|
||||
<span className="shrink-0 inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary/70">{item.category}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">{item.summary}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/50">🔥 {item.hotScore}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!existingReport && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">今日暂无新闻</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">复制提示词发给AI,获取JSON后粘贴导入</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const PARENT_CATEGORIES = [
|
||||
{ id: 1, name: "代码生成" },
|
||||
{ id: 10, name: "测试" },
|
||||
{ id: 20, name: "代码质量与分析" },
|
||||
{ id: 29, name: "安全" },
|
||||
{ id: 36, name: "文档" },
|
||||
{ id: 43, name: "架构与设计" },
|
||||
{ id: 49, name: "需求工程" },
|
||||
{ id: 56, name: "DevOps与部署" },
|
||||
{ id: 62, name: "调试与错误处理" },
|
||||
{ id: 69, name: "形式化验证" },
|
||||
{ id: 76, name: "维护与重构" },
|
||||
{ id: 82, name: "版本控制与协作" },
|
||||
{ id: 86, name: "开发工具" },
|
||||
{ id: 91, name: "UI/UX设计" },
|
||||
{ id: 95, name: "科学研究" },
|
||||
{ id: 103, name: "金融" },
|
||||
{ id: 109, name: "云服务" },
|
||||
{ id: 115, name: "移动开发" },
|
||||
{ id: 120, name: "AI/ML工程" },
|
||||
{ id: 127, name: "营销与增长" },
|
||||
];
|
||||
|
||||
const SKILL_TAG_POOL = [
|
||||
"代码生成", "代码审查", "自动化测试", "安全检测", "文档生成",
|
||||
"DevOps", "重构", "调试", "AI Agent", "Prompt工程",
|
||||
"RAG", "MCP", "前端开发", "后端开发", "移动开发",
|
||||
"云原生", "开源项目", "中文优化", "开箱即用", "企业级",
|
||||
];
|
||||
|
||||
export default function DiscoverSkillsPage() {
|
||||
const [mode, setMode] = useState<"manual" | "auto">("auto");
|
||||
const [excludedSkillNames, setExcludedSkillNames] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState<{ id: number; name: string } | null>(null);
|
||||
const [jsonInput, setJsonInput] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
details: { name: string; status: string }[];
|
||||
} | null>(null);
|
||||
|
||||
const [autoRunning, setAutoRunning] = useState(false);
|
||||
const [autoResult, setAutoResult] = useState<{
|
||||
discovered: number;
|
||||
skipped: number;
|
||||
reviewed: number;
|
||||
failed: number;
|
||||
details: { name: string; status: string; reason?: string; overall?: number }[];
|
||||
} | null>(null);
|
||||
|
||||
const fetchExcludedNames = useCallback(async () => {
|
||||
try {
|
||||
const [pendingRes, skillsRes] = await Promise.all([
|
||||
fetch("/api/admin/pending-skills?pageSize=500"),
|
||||
fetch("/api/skills?pageSize=500"),
|
||||
]);
|
||||
const pendingData = await pendingRes.json();
|
||||
const skillsData = await skillsRes.json();
|
||||
const names = new Set<string>();
|
||||
if (pendingData.skills) pendingData.skills.forEach((s: { name: string }) => names.add(s.name));
|
||||
if (skillsData.skills) skillsData.skills.forEach((s: { name: string }) => names.add(s.name));
|
||||
setExcludedSkillNames(Array.from(names).sort());
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExcludedNames().finally(() => setLoading(false));
|
||||
}, [fetchExcludedNames]);
|
||||
|
||||
const getPrompt = () => {
|
||||
if (!selectedCategory) return "";
|
||||
const excluded = excludedSkillNames.length > 0 ? `\n\n以下技能已收录,请勿重复推荐:\n${excludedSkillNames.join("、")}` : "";
|
||||
return `你是AI编程技能专家。请为"${selectedCategory.name}"领域推荐 10 个真实存在且活跃的AI编程技能(Prompt/Agent/Workflow/开源项目)。
|
||||
|
||||
严格要求:
|
||||
1. 优先推荐 Gitee(gitee.com)和 GitCode(gitcode.com)上的国内开源项目,也接受少量优秀的国际开源项目
|
||||
2. 推荐的技能必须是真实可用的AI编程技能
|
||||
3. sourceUrl必须是真实可访问的链接(Gitee/GitCode/GitHub仓库URL)
|
||||
4. 每个技能的name、sourceUrl必须唯一,不能与已有技能重复${excluded}
|
||||
|
||||
每个技能包含以下字段:
|
||||
- name: 技能名称
|
||||
- description: 一句话中文简介(50字以内)
|
||||
- sourceUrl: 来源链接
|
||||
- sourceType: 来源类型("gitee"/"gitcode"/"github"/"other")
|
||||
- rating: 用户评分(1.0-5.0)
|
||||
- features: 功能特性数组(3-5个,每个10字以内)
|
||||
- tags: 标签数组(3-5个,从限定标签池中选取:${SKILL_TAG_POOL.join("、")})
|
||||
|
||||
返回纯JSON数组,不要包含任何其他文字或markdown标记。`;
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedCategory || !jsonInput.trim()) return;
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
try {
|
||||
let skills = JSON.parse(jsonInput.trim());
|
||||
if (!Array.isArray(skills)) {
|
||||
alert("JSON格式错误:需要是数组");
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/admin/discover-skills", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ skills, categoryId: selectedCategory.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setImportResult({ total: data.total, imported: data.imported, skipped: data.skipped, details: data.details });
|
||||
fetchExcludedNames();
|
||||
} else {
|
||||
alert(data.error || "导入失败");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("JSON解析失败: " + (err instanceof Error ? err.message : "格式错误"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoDiscover = async () => {
|
||||
setAutoRunning(true);
|
||||
setAutoResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/discover-skills/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dailyLimit: 10, withReview: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.discovered !== undefined) {
|
||||
setAutoResult(data);
|
||||
fetchExcludedNames();
|
||||
} else {
|
||||
alert(data.error || "发现失败");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("请求失败: " + (err instanceof Error ? err.message : "网络错误"));
|
||||
} finally {
|
||||
setAutoRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8"><h1 className="text-2xl font-bold tracking-tight">探索AI技能</h1></div>
|
||||
<div className="space-y-4">{Array.from({ length: 3 }).map((_, i) => (<div key={i} className="h-24 animate-pulse rounded-2xl bg-muted/20" />))}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">探索AI技能</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{mode === "auto" ? "一键自动发现GitHub热门AI项目并生成评测" : "选择分类 → 复制提示词发给AI → 粘贴JSON → 导入审核列表"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex rounded-xl border border-border/50 bg-white p-1">
|
||||
<button
|
||||
onClick={() => setMode("auto")}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
mode === "auto" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
🤖 自动发现
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("manual")}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
mode === "manual" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
📋 手动导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "auto" && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-blue-500/5 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">一键自动发现</h2>
|
||||
<p className="text-xs text-muted-foreground">GitHub搜索 + DeepSeek评测 + 自动入库</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 mb-5">
|
||||
<div className="rounded-xl border border-border/40 bg-white p-3">
|
||||
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">搜索范围</p>
|
||||
<p className="text-sm font-medium text-foreground">10个AI关键词</p>
|
||||
<p className="text-[10px] text-muted-foreground/50">agent/llm/rag/code...</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-white p-3">
|
||||
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">每日上限</p>
|
||||
<p className="text-sm font-medium text-foreground">10个项目</p>
|
||||
<p className="text-[10px] text-muted-foreground/50">控制API消耗</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-white p-3">
|
||||
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">自动评测</p>
|
||||
<p className="text-sm font-medium text-foreground">五维评分</p>
|
||||
<p className="text-[10px] text-muted-foreground/50">DeepSeek AI生成</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAutoDiscover}
|
||||
disabled={autoRunning}
|
||||
className="gap-2 rounded-xl bg-violet-600 hover:bg-violet-700"
|
||||
>
|
||||
{autoRunning ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
正在发现中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
开始发现
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{autoResult && (
|
||||
<div className={`rounded-2xl border p-5 ${autoResult.discovered > 0 ? "border-emerald-500/20 bg-emerald-500/5" : "border-amber-500/20 bg-amber-500/5"}`}>
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
发现结果:发现 {autoResult.discovered} 个 / 跳过 {autoResult.skipped} 个 / 评测 {autoResult.reviewed} 个 / 失败 {autoResult.failed} 个
|
||||
</h3>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1.5 scrollbar-thin">
|
||||
{autoResult.details.map((d, i) => (
|
||||
<div key={i} className={`flex items-center gap-2 text-[11px] ${
|
||||
d.status === "reviewed" || d.status === "imported" ? "text-emerald-600/70" :
|
||||
d.status === "skipped" ? "text-amber-600/70" : "text-red-600/70"
|
||||
}`}>
|
||||
<span>{d.status === "reviewed" || d.status === "imported" ? "✓" : d.status === "skipped" ? "⊘" : "✕"}</span>
|
||||
<span className="font-medium">{d.name}</span>
|
||||
{d.overall !== undefined && <span className="text-violet-600/70">★{d.overall}</span>}
|
||||
{d.reason && <span className="text-muted-foreground/50">— {d.reason}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "manual" && (
|
||||
<>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">1</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">选择分类</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">选择要探索的技能分类</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">2</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">复制提示词</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">复制提示词发给AI助手获取JSON</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">3</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">粘贴导入</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">将AI返回的JSON粘贴并导入</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3">选择分类</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{PARENT_CATEGORIES.map((cat) => {
|
||||
const isSelected = selectedCategory?.id === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => { setSelectedCategory(cat); setImportResult(null); }}
|
||||
className={`group relative rounded-xl border p-4 text-left transition-all duration-200 ${
|
||||
isSelected ? "border-primary/50 bg-primary/10 ring-1 ring-primary/20" : "border-border/50 bg-white hover:border-primary/30 hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm font-medium ${isSelected ? "text-primary" : "text-foreground"}`}>{cat.name}</span>
|
||||
{isSelected && (<div className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">✓</div>)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCategory && (
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-primary">提示词({selectedCategory.name})</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => navigator.clipboard.writeText(getPrompt())} className="gap-1.5 rounded-lg text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
复制提示词
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto rounded-xl border border-primary/10 bg-white p-3 scrollbar-thin">
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-relaxed whitespace-pre-wrap">{getPrompt()}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-muted-foreground/50">已排除 {excludedSkillNames.length} 个已收录技能</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<h3 className="text-sm font-semibold mb-3">粘贴AI返回的JSON</h3>
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
placeholder='[{"name":"技能名","description":"简介","sourceUrl":"https://github.com/...","sourceType":"github","rating":4.5,"features":["特性1"],"tags":["标签1"]}]'
|
||||
className="w-full h-48 rounded-xl border border-border/50 bg-muted/10 p-4 text-xs font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/30 resize-y"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground/50">粘贴AI返回的纯JSON数组,系统会自动去重导入</p>
|
||||
<Button onClick={handleImport} disabled={importing || !jsonInput.trim()} className="gap-1.5 rounded-xl">
|
||||
{importing ? "导入中..." : "导入审核列表"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div className={`mb-8 rounded-2xl border p-5 ${importResult.imported > 0 ? "border-emerald-500/20 bg-emerald-500/5" : "border-amber-500/20 bg-amber-500/5"}`}>
|
||||
<h3 className="text-sm font-semibold mb-2">导入结果:共 {importResult.total} 个,导入 {importResult.imported} 个,跳过 {importResult.skipped} 个</h3>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{importResult.details.map((d, i) => (
|
||||
<p key={i} className={`text-[11px] ${d.status === "已导入" ? "text-emerald-600/70" : "text-amber-600/70"}`}>
|
||||
{d.status === "已导入" ? "✓" : "⊘"} {d.name} — {d.status}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const TOOL_CATEGORIES = [
|
||||
{ id: 165, name: "文本生成" },
|
||||
{ id: 174, name: "图像生成" },
|
||||
{ id: 183, name: "视频生成" },
|
||||
{ id: 191, name: "音频与语音" },
|
||||
{ id: 199, name: "代码开发" },
|
||||
{ id: 208, name: "AI Agent" },
|
||||
{ id: 216, name: "办公效率" },
|
||||
{ id: 224, name: "设计与创意" },
|
||||
{ id: 231, name: "营销与SEO" },
|
||||
{ id: 238, name: "数据分析" },
|
||||
{ id: 245, name: "教育与学习" },
|
||||
{ id: 252, name: "搜索与信息" },
|
||||
{ id: 258, name: "安全与合规" },
|
||||
{ id: 264, name: "医疗健康" },
|
||||
{ id: 270, name: "金融与法律" },
|
||||
{ id: 276, name: "电商与零售" },
|
||||
{ id: 282, name: "3D与游戏" },
|
||||
{ id: 288, name: "科学研究" },
|
||||
{ id: 294, name: "模型与框架" },
|
||||
{ id: 302, name: "聊天机器人" },
|
||||
];
|
||||
|
||||
const TAG_POOL = [
|
||||
"文本生成", "图像生成", "视频生成", "音频处理", "代码生成",
|
||||
"数据分析", "自然语言理解", "内容检测", "AI Agent", "RAG",
|
||||
"机器人流程自动化", "国产AI", "中文优化", "开箱即用", "免费使用",
|
||||
"企业级", "多模态", "大语言模型", "语音合成", "智能客服",
|
||||
];
|
||||
|
||||
export default function DiscoverToolsPage() {
|
||||
const [excludedToolNames, setExcludedToolNames] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState<{ id: number; name: string } | null>(null);
|
||||
const [jsonInput, setJsonInput] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
details: { name: string; status: string }[];
|
||||
} | null>(null);
|
||||
const [logoScope, setLogoScope] = useState<"pending" | "published" | "all">("pending");
|
||||
const [logoFetching, setLogoFetching] = useState(false);
|
||||
const [logoResult, setLogoResult] = useState<{
|
||||
total: number;
|
||||
fetched: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleFetchLogos = async () => {
|
||||
setLogoFetching(true);
|
||||
setLogoResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/pending/fetch-logos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scope: logoScope, refetch: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setLogoResult({ total: data.total, fetched: data.fetched, failed: data.failed, skipped: data.skipped });
|
||||
fetchExcludedNames();
|
||||
}
|
||||
} catch { /* */ } finally {
|
||||
setLogoFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExcludedNames = useCallback(async () => {
|
||||
try {
|
||||
const [pendingRes, toolsRes] = await Promise.all([
|
||||
fetch("/api/admin/pending?pageSize=500"),
|
||||
fetch("/api/tools?pageSize=500"),
|
||||
]);
|
||||
const pendingData = await pendingRes.json();
|
||||
const toolsData = await toolsRes.json();
|
||||
const names = new Set<string>();
|
||||
if (pendingData.tools) pendingData.tools.forEach((t: { name: string }) => names.add(t.name));
|
||||
if (toolsData.tools) toolsData.tools.forEach((t: { name: string }) => names.add(t.name));
|
||||
setExcludedToolNames(Array.from(names).sort());
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExcludedNames().finally(() => setLoading(false));
|
||||
}, [fetchExcludedNames]);
|
||||
|
||||
const getPrompt = () => {
|
||||
if (!selectedCategory) return "";
|
||||
const excluded = excludedToolNames.length > 0 ? `\n\n以下工具已收录,请勿重复推荐:\n${excludedToolNames.join("、")}` : "";
|
||||
return `你是国内AI工具专家。请为"${selectedCategory.name}"领域推荐 10 个真实存在且活跃的中国国产AI工具。
|
||||
|
||||
严格要求:
|
||||
1. 只推荐中国本土的AI工具,不推荐任何国外工具
|
||||
2. 工具必须在国内可直接访问,无需翻墙
|
||||
3. 工具必须支持中文界面或中文使用
|
||||
4. 优先推荐有特色的新兴工具
|
||||
5. websiteUrl必须是真实可访问的官网链接
|
||||
6. 每个工具的name、websiteUrl必须唯一,不能与已有工具重复${excluded}
|
||||
|
||||
每个工具包含以下字段:
|
||||
- name: 工具名称
|
||||
- description: 一句话中文简介(50字以内)
|
||||
- websiteUrl: 官网链接
|
||||
- logoUrl: 工具Logo图片链接(找不到则留空)
|
||||
- pricingModel: 定价模型("free"/"freemium"/"paid"/"subscription")
|
||||
- pricingDetail: 定价详情
|
||||
- features: 功能特性数组(3-5个,每个10字以内)
|
||||
- tags: 标签数组(3-5个,从限定标签池中选取:${TAG_POOL.join("、")})
|
||||
|
||||
返回纯JSON数组,不要包含任何其他文字或markdown标记。`;
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedCategory || !jsonInput.trim()) return;
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
try {
|
||||
let tools = JSON.parse(jsonInput.trim());
|
||||
if (!Array.isArray(tools)) {
|
||||
alert("JSON格式错误:需要是数组");
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/admin/discover-tools", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tools, categoryId: selectedCategory.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setImportResult({ total: data.total, imported: data.imported, skipped: data.skipped, details: data.details });
|
||||
fetchExcludedNames();
|
||||
} else {
|
||||
alert(data.error || "导入失败");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("JSON解析失败: " + (err instanceof Error ? err.message : "格式错误"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8"><h1 className="text-2xl font-bold tracking-tight">探索AI工具</h1></div>
|
||||
<div className="space-y-4">{Array.from({ length: 3 }).map((_, i) => (<div key={i} className="h-24 animate-pulse rounded-2xl bg-muted/20" />))}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">探索AI工具</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
选择分类 → 复制提示词发给AI → 粘贴JSON → 导入审核列表
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-4">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">1</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">选择分类</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">从下方选择要探索的分类</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">2</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">复制提示词</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">复制提示词发给AI助手获取JSON</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">3</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">粘贴导入</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">将AI返回的JSON粘贴到输入框并导入</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">4</span>
|
||||
<h3 className="mt-3 text-sm font-semibold">抓取Logo</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">导入后批量抓取工具Logo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3">选择分类</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{TOOL_CATEGORIES.map((cat) => {
|
||||
const isSelected = selectedCategory?.id === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => { setSelectedCategory(cat); setImportResult(null); }}
|
||||
className={`group relative rounded-xl border p-4 text-left transition-all duration-200 ${
|
||||
isSelected ? "border-primary/50 bg-primary/10 ring-1 ring-primary/20" : "border-border/50 bg-white hover:border-primary/30 hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm font-medium ${isSelected ? "text-primary" : "text-foreground"}`}>{cat.name}</span>
|
||||
{isSelected && (<div className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">✓</div>)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCategory && (
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-primary">提示词({selectedCategory.name})</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigator.clipboard.writeText(getPrompt())}
|
||||
className="gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
复制提示词
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto rounded-xl border border-primary/10 bg-white p-3 scrollbar-thin">
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-relaxed whitespace-pre-wrap">{getPrompt()}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-muted-foreground/50">已排除 {excludedToolNames.length} 个已收录工具 · 将提示词发给AI助手,获取JSON后粘贴到下方</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<h3 className="text-sm font-semibold mb-3">粘贴AI返回的JSON</h3>
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
placeholder='[{"name":"工具名","description":"简介","websiteUrl":"https://...","pricingModel":"freemium","features":["特性1"],"tags":["标签1"]}]'
|
||||
className="w-full h-48 rounded-xl border border-border/50 bg-muted/10 p-4 text-xs font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/30 resize-y"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground/50">粘贴AI返回的纯JSON数组,系统会自动去重导入</p>
|
||||
<Button onClick={handleImport} disabled={importing || !jsonInput.trim()} className="gap-1.5 rounded-xl">
|
||||
{importing ? (
|
||||
<><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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>导入审核列表</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div className={`mb-8 rounded-2xl border p-5 ${importResult.imported > 0 ? "border-emerald-500/20 bg-emerald-500/5" : "border-amber-500/20 bg-amber-500/5"}`}>
|
||||
<h3 className="text-sm font-semibold mb-2">导入结果:共 {importResult.total} 个,导入 {importResult.imported} 个,跳过 {importResult.skipped} 个</h3>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{importResult.details.map((d, i) => (
|
||||
<p key={i} className={`text-[11px] ${d.status === "已导入" ? "text-emerald-600/70" : "text-amber-600/70"}`}>
|
||||
{d.status === "已导入" ? "✓" : "⊘"} {d.name} — {d.status}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Logo抓取</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-2">
|
||||
{(["pending", "published", "all"] as const).map((opt) => (
|
||||
<button key={opt} onClick={() => setLogoScope(opt)} className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${logoScope === opt ? "bg-primary text-white" : "text-muted-foreground border border-border/60 hover:bg-muted/30"}`}>
|
||||
{opt === "pending" ? "待审核" : opt === "published" ? "已发布" : "全部"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={handleFetchLogos} disabled={logoFetching} className="gap-1.5 rounded-xl">
|
||||
{logoFetching ? "抓取中..." : "开始抓取Logo"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{logoResult && (
|
||||
<div className="flex gap-6 text-xs">
|
||||
<span>总计: <strong>{logoResult.total}</strong></span>
|
||||
<span className="text-emerald-600">成功: <strong>{logoResult.fetched}</strong></span>
|
||||
<span className="text-red-500">失败: <strong>{logoResult.failed}</strong></span>
|
||||
<span className="text-amber-600">跳过: <strong>{logoResult.skipped}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
badge?: string | null;
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
title: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
title: "工具管理",
|
||||
items: [
|
||||
{
|
||||
label: "待审核工具",
|
||||
href: "/admin/pending",
|
||||
icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
},
|
||||
{
|
||||
label: "已发布工具",
|
||||
href: "/admin/tools",
|
||||
icon: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
|
||||
},
|
||||
{
|
||||
label: "分类管理",
|
||||
href: "/admin/categories",
|
||||
icon: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "技能库",
|
||||
items: [
|
||||
{
|
||||
label: "待审核技能",
|
||||
href: "/admin/pending-skills",
|
||||
icon: "M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4",
|
||||
},
|
||||
{
|
||||
label: "已发布技能",
|
||||
href: "/admin/skills",
|
||||
icon: "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z",
|
||||
},
|
||||
{
|
||||
label: "技能分类管理",
|
||||
href: "/admin/skill-categories",
|
||||
icon: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
|
||||
},
|
||||
{
|
||||
label: "评测管理",
|
||||
href: "/admin/reviews",
|
||||
icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI 发现",
|
||||
items: [
|
||||
{
|
||||
label: "探索AI工具",
|
||||
href: "/admin/discover-tools",
|
||||
icon: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||
},
|
||||
{
|
||||
label: "检测AI工具",
|
||||
href: "/admin/check-tools",
|
||||
icon: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
},
|
||||
{
|
||||
label: "探索Skill技能",
|
||||
href: "/admin/discover-skills",
|
||||
icon: "M13 10V3L4 14h7v7l9-11h-7z",
|
||||
},
|
||||
{
|
||||
label: "更新Skill技能",
|
||||
href: "/admin/update-skills",
|
||||
icon: "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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "内容管理",
|
||||
items: [
|
||||
{
|
||||
label: "AI日报管理",
|
||||
href: "/admin/daily-reports",
|
||||
icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z",
|
||||
},
|
||||
{
|
||||
label: "AI新闻发现",
|
||||
href: "/admin/discover-news",
|
||||
icon: "M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "系统配置",
|
||||
items: [
|
||||
{
|
||||
label: "系统配置",
|
||||
href: "/admin/system-config",
|
||||
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
|
||||
},
|
||||
{
|
||||
label: "角色权限",
|
||||
href: "/admin/roles",
|
||||
icon: "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname === "/admin/login") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold tracking-tight">
|
||||
追光<span className="gradient-text">AI</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60">管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 p-3 overflow-y-auto scrollbar-thin">
|
||||
{menuGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<div className="mb-2 px-3 pt-2 pb-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/40">
|
||||
{group.title}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`group relative flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-200 ${
|
||||
isActive
|
||||
? "bg-primary/10 font-medium text-primary shadow-sm shadow-primary/5"
|
||||
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-[3px] rounded-r-full bg-primary" />
|
||||
)}
|
||||
<div className={`flex h-7 w-7 items-center justify-center rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? "bg-primary/15"
|
||||
: "bg-transparent group-hover:bg-muted/40"
|
||||
}`}>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`shrink-0 transition-colors duration-200 ${
|
||||
isActive ? "text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<path d={item.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary/15 px-1.5 text-[10px] font-semibold text-primary">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border/50 p-3">
|
||||
<div className="mb-2 rounded-xl border border-border/40 bg-muted/20 p-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary/25 to-primary/10 text-xs font-bold text-primary">
|
||||
A
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium text-foreground">管理员</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground/60">admin@zhuiguang.ai</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-all duration-200 hover:bg-muted/40 hover:text-foreground"
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg transition-all duration-200 group-hover:bg-muted/40">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</div>
|
||||
返回前台
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="ml-[260px] flex-1 overflow-auto bg-[hsl(220,14%,96%)]">
|
||||
<div className="p-6 lg:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use server";
|
||||
|
||||
export async function loginAction(
|
||||
_prevState: { error?: string } | null,
|
||||
formData: FormData
|
||||
) {
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
if (!email || !password) {
|
||||
return { error: "请输入邮箱和密码" };
|
||||
}
|
||||
|
||||
if (
|
||||
email === "admin@zhuiguang.ai" &&
|
||||
password === "admin123"
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { error: "邮箱或密码错误" };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getCsrfToken } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [csrfToken, setCsrfToken] = useState("");
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchToken = async () => {
|
||||
try {
|
||||
const token = await getCsrfToken();
|
||||
if (token) {
|
||||
setCsrfToken(token);
|
||||
setReady(true);
|
||||
} else {
|
||||
const res = await fetch("/api/auth/csrf");
|
||||
const data = await res.json();
|
||||
if (data.csrfToken) {
|
||||
setCsrfToken(data.csrfToken);
|
||||
setReady(true);
|
||||
} else {
|
||||
setError("无法获取安全令牌,请刷新页面重试");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const res = await fetch("/api/auth/csrf");
|
||||
const data = await res.json();
|
||||
if (data.csrfToken) {
|
||||
setCsrfToken(data.csrfToken);
|
||||
setReady(true);
|
||||
} else {
|
||||
setError("无法获取安全令牌,请刷新页面重试");
|
||||
}
|
||||
} catch {
|
||||
setError("网络连接失败,请检查服务是否正常运行");
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchToken();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center px-4 overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute -top-40 left-1/2 -translate-x-1/2 h-[600px] w-[800px] rounded-full bg-primary/8 blur-[120px]" />
|
||||
<div className="absolute top-20 -left-40 h-[400px] w-[400px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||||
<div className="absolute bottom-0 right-0 h-[350px] w-[350px] rounded-full bg-cyan-500/4 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 dot-grid opacity-30" />
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
追光<span className="gradient-text">AI</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">后台管理系统</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-white p-6 shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-200/60 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
method="POST"
|
||||
action="/api/auth/callback/credentials"
|
||||
className="space-y-4"
|
||||
>
|
||||
<input name="csrfToken" type="hidden" value={csrfToken} />
|
||||
<input name="callbackUrl" type="hidden" value="/admin/pending" />
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
邮箱
|
||||
</label>
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/40" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
defaultValue="admin@zhuiguang.ai"
|
||||
className="h-11 w-full rounded-xl border border-border/60 bg-white pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="admin@zhuiguang.ai"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/40" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 w-full rounded-xl border border-border/60 bg-white pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11 glow-sm" disabled={!ready}>
|
||||
{ready ? "登录" : "加载中..."}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground/40">
|
||||
追光AI © 2025
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface PendingSkill {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
logoUrl: string | null;
|
||||
sourceUrl: string | null;
|
||||
sourceType: string;
|
||||
categoryId: number | null;
|
||||
features: unknown;
|
||||
tags: unknown;
|
||||
rating: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function PendingSkillsPage() {
|
||||
const [skills, setSkills] = useState<PendingSkill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchPublishing, setBatchPublishing] = useState(false);
|
||||
const [batchRejecting, setBatchRejecting] = useState(false);
|
||||
const [publishingAll, setPublishingAll] = useState(false);
|
||||
|
||||
const [editSkill, setEditSkill] = useState<PendingSkill | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
sourceUrl: "",
|
||||
categoryId: "" as string,
|
||||
rating: 0,
|
||||
features: "",
|
||||
tags: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchSkills = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/pending-skills?page=${page}&pageSize=${pageSize}`);
|
||||
const data = await res.json();
|
||||
setSkills(data.skills || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
console.error("Failed to fetch skills");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/skill-categories");
|
||||
const data = await res.json();
|
||||
const flat: Category[] = [];
|
||||
const walk = (list: { id: number; name: string; children?: { id: number; name: string }[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ id: c.id, name: c.name });
|
||||
if (Array.isArray(c.children)) walk(c.children as typeof list);
|
||||
}
|
||||
};
|
||||
walk(data);
|
||||
setCategories(flat);
|
||||
} catch {
|
||||
console.error("Failed to fetch categories");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkills();
|
||||
fetchCategories();
|
||||
}, [fetchSkills, fetchCategories]);
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const isAllSelected = skills.length > 0 && selectedIds.size === skills.length;
|
||||
const isPartialSelected = selectedIds.size > 0 && selectedIds.size < skills.length;
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(skills.map((s) => s.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (id: number) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleBatchPublish = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`确定批量通过并发布 ${selectedIds.size} 个技能?`)) return;
|
||||
setBatchPublishing(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/pending-skills/batch-publish", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setMessage(`批量发布完成:成功 ${data.published} 个,跳过 ${data.skipped} 个`);
|
||||
fetchSkills();
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setMessage("批量发布失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
setMessage("批量发布失败");
|
||||
} finally {
|
||||
setBatchPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchReject = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`确定批量拒绝 ${selectedIds.size} 个技能?此操作不可恢复!`)) return;
|
||||
setBatchRejecting(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/pending-skills/batch-reject", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setMessage(`批量拒绝完成:已拒绝 ${data.rejected} 个,跳过 ${data.skipped} 个`);
|
||||
fetchSkills();
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setMessage("批量拒绝失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
setMessage("批量拒绝失败");
|
||||
} finally {
|
||||
setBatchRejecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishAll = async () => {
|
||||
if (total === 0) return;
|
||||
if (!confirm(`确定一键通过全部 ${total} 个待审核技能?此操作将发布所有待审核技能到已发布列表。`)) return;
|
||||
if (!confirm("再次确认:此操作不可撤销,确定继续?")) return;
|
||||
setPublishingAll(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/pending-skills/publish-all", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setMessage(`一键审核完成:共 ${data.total} 个,成功发布 ${data.published} 个,跳过 ${data.skipped} 个`);
|
||||
fetchSkills();
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setMessage("一键审核失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
setMessage("一键审核失败");
|
||||
} finally {
|
||||
setPublishingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: number) => {
|
||||
if (!confirm("确定拒绝该技能?")) return;
|
||||
try {
|
||||
await fetch(`/api/admin/pending-skills/${id}`, { method: "DELETE" });
|
||||
setSkills((prev) => prev.filter((s) => s.id !== id));
|
||||
setSelectedIds((prev) => { const n = new Set(prev); n.delete(id); return n; });
|
||||
} catch {
|
||||
console.error("Reject failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickPublish = async (id: number) => {
|
||||
if (!confirm("确定通过并发布该技能?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/pending-skills/${id}/publish`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSkills((prev) => prev.filter((s) => s.id !== id));
|
||||
setSelectedIds((prev) => { const n = new Set(prev); n.delete(id); return n; });
|
||||
} else {
|
||||
alert(data.error || "发布失败");
|
||||
}
|
||||
} catch {
|
||||
console.error("Publish failed");
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (skill: PendingSkill) => {
|
||||
setEditSkill(skill);
|
||||
setEditForm({
|
||||
name: skill.name,
|
||||
description: skill.description || "",
|
||||
sourceUrl: skill.sourceUrl || "",
|
||||
categoryId: skill.categoryId?.toString() || "",
|
||||
rating: skill.rating || 0,
|
||||
features: typeof skill.features === "string" ? skill.features : JSON.stringify(skill.features || {}, null, 2),
|
||||
tags: Array.isArray(skill.tags) ? (skill.tags as string[]).join(", ") : String(skill.tags || ""),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editSkill) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const parsedFeatures = (() => { try { return JSON.parse(editForm.features); } catch { return editForm.features ? { raw: editForm.features } : null; } })();
|
||||
const parsedTags = editForm.tags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
await fetch(`/api/admin/pending-skills/${editSkill.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
sourceUrl: editForm.sourceUrl,
|
||||
categoryId: editForm.categoryId ? parseInt(editForm.categoryId) : null,
|
||||
rating: editForm.rating,
|
||||
features: parsedFeatures,
|
||||
tags: parsedTags,
|
||||
}),
|
||||
});
|
||||
setEditSkill(null);
|
||||
fetchSkills();
|
||||
} catch {
|
||||
console.error("Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!editSkill) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const parsedFeatures = (() => { try { return JSON.parse(editForm.features); } catch { return editForm.features ? { raw: editForm.features } : null; } })();
|
||||
const parsedTags = editForm.tags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
await fetch(`/api/admin/pending-skills/${editSkill.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
sourceUrl: editForm.sourceUrl,
|
||||
categoryId: editForm.categoryId ? parseInt(editForm.categoryId) : null,
|
||||
rating: editForm.rating,
|
||||
features: parsedFeatures,
|
||||
tags: parsedTags,
|
||||
}),
|
||||
});
|
||||
const res = await fetch(`/api/admin/pending-skills/${editSkill.id}/publish`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setEditSkill(null);
|
||||
fetchSkills();
|
||||
} else {
|
||||
alert(data.error || "发布失败");
|
||||
}
|
||||
} catch {
|
||||
console.error("Publish failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryName = (id: number | null) => {
|
||||
if (!id) return "未分类";
|
||||
return categories.find((c) => c.id === id)?.name || "未分类";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">待审核技能</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
审核AI发现的GitHub开源技能,编辑后发布上架
|
||||
</p>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<Button
|
||||
onClick={handlePublishAll}
|
||||
disabled={publishingAll || batchPublishing || batchRejecting}
|
||||
className="gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5"
|
||||
>
|
||||
{publishingAll ? (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin">
|
||||
<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" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
一键全部通过
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{total}</p>
|
||||
<p className="text-xs text-muted-foreground">待审核</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{skills.filter((s) => s.rating > 0).length}</p>
|
||||
<p className="text-xs text-muted-foreground">有Star数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{selectedIds.size}</p>
|
||||
<p className="text-xs text-muted-foreground">已选择</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
已选择 <span className="text-primary">{selectedIds.size}</span> 个技能
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBatchPublish}
|
||||
disabled={batchPublishing || batchRejecting}
|
||||
className="gap-1.5 rounded-lg text-xs h-8 px-3 bg-emerald-600 hover:bg-emerald-500"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{batchPublishing ? "发布中..." : "批量通过"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleBatchReject}
|
||||
disabled={batchPublishing || batchRejecting}
|
||||
className="gap-1.5 rounded-lg text-xs h-8 px-3"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{batchRejecting ? "拒绝中..." : "批量拒绝"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
className="gap-1.5 rounded-lg text-xs h-8 px-3 text-muted-foreground"
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className="mb-6 flex items-center gap-2.5 rounded-xl border border-emerald-200/60 bg-emerald-50 px-4 py-3 text-sm text-emerald-600">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{message}
|
||||
<button onClick={() => setMessage("")} className="ml-auto text-emerald-600/50 hover:text-emerald-600">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无待审核技能</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60 w-10">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="flex h-5 w-5 items-center justify-center rounded border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: isAllSelected || isPartialSelected ? "var(--color-primary)" : "var(--color-border)",
|
||||
backgroundColor: isAllSelected ? "var(--color-primary)" : isPartialSelected ? "var(--color-primary)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{isAllSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
)}
|
||||
{isPartialSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"><path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">分类</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">技能名称</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">简介</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">来源</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">Star</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">发现时间</th>
|
||||
<th className="px-4 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{skills.map((skill) => (
|
||||
<tr
|
||||
key={skill.id}
|
||||
className={`border-b border-border/40 transition-all duration-200 hover:bg-muted/30 ${
|
||||
selectedIds.has(skill.id) ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleSelect(skill.id)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: selectedIds.has(skill.id) ? "var(--color-primary)" : "var(--color-border)",
|
||||
backgroundColor: selectedIds.has(skill.id) ? "var(--color-primary)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{selectedIds.has(skill.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
|
||||
{getCategoryName(skill.categoryId)}
|
||||
</span>
|
||||
</td>
|
||||
<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" />
|
||||
) : (
|
||||
<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)}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-foreground">{skill.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[200px]">
|
||||
<span className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{skill.description || "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[180px]">
|
||||
{skill.sourceUrl ? (
|
||||
<a
|
||||
href={skill.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate text-xs text-blue-600 hover:text-blue-300 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
<span className="truncate">{skill.sourceUrl.replace(/^https?:\/\/github\.com\//, "")}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{skill.rating > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-amber-600">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
{skill.rating >= 1000 ? `${(skill.rating / 1000).toFixed(1)}k` : skill.rating}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{new Date(skill.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(skill)}
|
||||
className="gap-1 rounded-lg border-border/50 text-[11px] h-7 px-2"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleQuickPublish(skill.id)}
|
||||
className="gap-1 rounded-lg text-[11px] h-7 px-2 bg-emerald-600 hover:bg-emerald-500"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleReject(skill.id)}
|
||||
className="gap-1 rounded-lg text-[11px] h-7 px-2"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => { setPage((p) => p - 1); setSelectedIds(new Set()); }}
|
||||
className="rounded-xl"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => { setPage((p) => p + 1); setSelectedIds(new Set()); }}
|
||||
className="rounded-xl"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={!!editSkill} onOpenChange={(open) => !open && setEditSkill(null)}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto border-border/50 bg-white text-foreground sm:max-w-xl rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">编辑技能</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">名称</label>
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="输入技能名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">简介</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200 resize-none"
|
||||
placeholder="输入技能简介"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">GitHub 地址</label>
|
||||
<input
|
||||
value={editForm.sourceUrl}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, sourceUrl: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="https://github.com/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">分类</label>
|
||||
<select
|
||||
value={editForm.categoryId}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, categoryId: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">Star 数</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.rating}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, rating: parseInt(e.target.value) || 0 }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">功能特性 (JSON)</label>
|
||||
<textarea
|
||||
value={editForm.features}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, features: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200 resize-none font-mono"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">标签 (逗号分隔)</label>
|
||||
<input
|
||||
value={editForm.tags}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, tags: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="AI Agent, RAG, 大模型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 gap-2">
|
||||
<Button variant="outline" onClick={() => setEditSkill(null)} disabled={saving} className="rounded-xl">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleSave} disabled={saving} className="rounded-xl">
|
||||
{saving ? "保存中..." : "仅保存"}
|
||||
</Button>
|
||||
<Button onClick={handlePublish} disabled={saving} className="glow-sm rounded-xl">
|
||||
{saving ? "发布中..." : "发布上架"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface PendingTool {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string | null;
|
||||
categoryId: number | null;
|
||||
pricingModel: string | null;
|
||||
pricingDetail: string | null;
|
||||
features: unknown;
|
||||
tags: unknown;
|
||||
sourceUrl: string | null;
|
||||
createdAt: string;
|
||||
category: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const add = () => {
|
||||
const v = input.trim();
|
||||
if (v && !value.includes(v)) {
|
||||
onChange([...value, v]);
|
||||
}
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const remove = (idx: number) => {
|
||||
onChange(value.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), add())}
|
||||
placeholder={placeholder}
|
||||
className="h-10 flex-1 rounded-xl border border-border/50 bg-white px-3.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={add} className="rounded-xl">
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-white px-2.5 py-1 text-xs text-foreground transition-all duration-200 hover:border-border/50"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
className="text-muted-foreground/50 transition-colors hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PendingPage() {
|
||||
const [tools, setTools] = useState<PendingTool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [logoFilter, setLogoFilter] = useState<"all" | "has" | "missing">("all");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchPublishing, setBatchPublishing] = useState(false);
|
||||
const [batchRejecting, setBatchRejecting] = useState(false);
|
||||
const [fetchingLogos, setFetchingLogos] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
const [editTool, setEditTool] = useState<PendingTool | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
websiteUrl: "",
|
||||
logoUrl: "",
|
||||
categoryId: "" as string,
|
||||
pricingModel: "freemium",
|
||||
pricingDetail: "",
|
||||
features: [] as string[],
|
||||
tags: [] as string[],
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/pending?page=${page}&pageSize=${pageSize}`);
|
||||
const data = await res.json();
|
||||
setTools(data.tools || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
console.error("Failed to fetch tools");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/categories");
|
||||
const data = await res.json();
|
||||
const flat: Category[] = [];
|
||||
const walk = (list: { id: number; name: string; children?: { id: number; name: string }[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ id: c.id, name: c.name });
|
||||
if (Array.isArray(c.children)) walk(c.children as typeof list);
|
||||
}
|
||||
};
|
||||
walk(data);
|
||||
setCategories(flat);
|
||||
} catch {
|
||||
console.error("Failed to fetch categories");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
fetchCategories();
|
||||
}, [fetchTools, fetchCategories]);
|
||||
|
||||
const filteredTools = tools.filter((t) => {
|
||||
if (searchTerm && !t.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
if (logoFilter === "has" && !t.logoUrl) return false;
|
||||
if (logoFilter === "missing" && t.logoUrl) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedIds.size === filteredTools.length && filteredTools.length > 0) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredTools.map((t) => t.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (id: number) => {
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id);
|
||||
} else {
|
||||
newSelected.add(id);
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
};
|
||||
|
||||
const handleBatchPublish = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`确定批量通过并发布 ${selectedIds.size} 个工具?`)) return;
|
||||
setBatchPublishing(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/pending/batch-publish", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setMessage(`批量发布完成:成功 ${data.published} 个,跳过 ${data.skipped} 个`);
|
||||
fetchTools();
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setMessage("批量发布失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
setMessage("批量发布失败");
|
||||
} finally {
|
||||
setBatchPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchReject = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`确定批量拒绝 ${selectedIds.size} 个工具?此操作不可恢复!`)) return;
|
||||
setBatchRejecting(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/pending/batch-reject", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setMessage(`批量拒绝完成:已拒绝 ${data.rejected} 个,跳过 ${data.skipped} 个`);
|
||||
fetchTools();
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setMessage("批量拒绝失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
setMessage("批量拒绝失败");
|
||||
} finally {
|
||||
setBatchRejecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchLogos = async (refetch = false) => {
|
||||
setFetchingLogos(true);
|
||||
setMessage(refetch ? "正在重新抓取所有Logo..." : "正在抓取缺失Logo...");
|
||||
try {
|
||||
const res = await fetch("/api/admin/pending/fetch-logos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refetch }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setMessage(`Logo抓取完成:共 ${data.total} 个,成功 ${data.fetched} 个,失败 ${data.failed} 个,跳过 ${data.skipped} 个`);
|
||||
fetchTools();
|
||||
} else {
|
||||
setMessage("Logo抓取失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
setMessage("Logo抓取请求失败");
|
||||
} finally {
|
||||
setFetchingLogos(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: number) => {
|
||||
if (!confirm("确定拒绝该工具?")) return;
|
||||
try {
|
||||
await fetch(`/api/admin/pending/${id}`, { method: "DELETE" });
|
||||
setTools((prev) => prev.filter((t) => t.id !== id));
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
console.error("Reject failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickPublish = async (id: number) => {
|
||||
if (!confirm("确定通过并发布该工具?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/pending/${id}/publish`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setTools((prev) => prev.filter((t) => t.id !== id));
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
alert(data.error || "发布失败");
|
||||
}
|
||||
} catch {
|
||||
console.error("Publish failed");
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (tool: PendingTool) => {
|
||||
setEditTool(tool);
|
||||
setEditForm({
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
websiteUrl: tool.websiteUrl || "",
|
||||
logoUrl: tool.logoUrl || "",
|
||||
categoryId: tool.categoryId?.toString() || "",
|
||||
pricingModel: tool.pricingModel || "freemium",
|
||||
pricingDetail: tool.pricingDetail || "",
|
||||
features: Array.isArray(tool.features) ? (tool.features as string[]) : [],
|
||||
tags: Array.isArray(tool.tags) ? (tool.tags as string[]) : [],
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editTool) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/api/admin/pending/${editTool.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
websiteUrl: editForm.websiteUrl,
|
||||
logoUrl: editForm.logoUrl,
|
||||
categoryId: editForm.categoryId ? parseInt(editForm.categoryId) : null,
|
||||
pricingModel: editForm.pricingModel,
|
||||
pricingDetail: editForm.pricingDetail,
|
||||
features: editForm.features,
|
||||
tags: editForm.tags,
|
||||
}),
|
||||
});
|
||||
setEditTool(null);
|
||||
fetchTools();
|
||||
} catch {
|
||||
console.error("Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!editTool) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/api/admin/pending/${editTool.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
websiteUrl: editForm.websiteUrl,
|
||||
logoUrl: editForm.logoUrl,
|
||||
categoryId: editForm.categoryId ? parseInt(editForm.categoryId) : null,
|
||||
pricingModel: editForm.pricingModel,
|
||||
pricingDetail: editForm.pricingDetail,
|
||||
features: editForm.features,
|
||||
tags: editForm.tags,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/admin/pending/${editTool.id}/publish`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setEditTool(null);
|
||||
fetchTools();
|
||||
} else {
|
||||
alert(data.error || "发布失败");
|
||||
}
|
||||
} catch {
|
||||
console.error("Publish failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = filteredTools.length > 0 && selectedIds.size === filteredTools.length;
|
||||
const isPartialSelected = selectedIds.size > 0 && selectedIds.size < filteredTools.length;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">待审核工具</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
审核AI发现的工具,编辑后发布上架
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{total}</p>
|
||||
<p className="text-xs text-muted-foreground">待审核</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{tools.filter((t) => t.logoUrl).length}</p>
|
||||
<p className="text-xs text-muted-foreground">已获取Logo</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-red-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-red-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{tools.filter((t) => !t.logoUrl).length}</p>
|
||||
<p className="text-xs text-muted-foreground">未获取Logo</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground/50">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜索工具名称..."
|
||||
className="h-10 w-full rounded-xl border border-border/50 bg-white pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button onClick={() => setSearchTerm("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-foreground">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={logoFilter}
|
||||
onChange={(e) => setLogoFilter(e.target.value as "all" | "has" | "missing")}
|
||||
className="h-10 rounded-xl border border-border/50 bg-white px-3 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="all">全部Logo</option>
|
||||
<option value="has">有Logo</option>
|
||||
<option value="missing">无Logo</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleFetchLogos(false)}
|
||||
disabled={fetchingLogos}
|
||||
className="gap-1.5 rounded-xl border-border/50 text-xs h-10 px-3"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{fetchingLogos ? "抓取中..." : "抓取缺失Logo"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleFetchLogos(true)}
|
||||
disabled={fetchingLogos}
|
||||
className="gap-1.5 rounded-xl border-border/50 text-xs h-10 px-3"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path 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>
|
||||
{fetchingLogos ? "抓取中..." : "重新抓取全部Logo"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
已选择 <span className="text-primary">{selectedIds.size}</span> 个工具
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBatchPublish}
|
||||
disabled={batchPublishing || batchRejecting}
|
||||
className="gap-1.5 rounded-lg text-xs h-8 px-3 bg-emerald-600 hover:bg-emerald-500"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{batchPublishing ? "发布中..." : "批量通过"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleBatchReject}
|
||||
disabled={batchPublishing || batchRejecting}
|
||||
className="gap-1.5 rounded-lg text-xs h-8 px-3"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{batchRejecting ? "拒绝中..." : "批量拒绝"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
className="gap-1.5 rounded-lg text-xs h-8 px-3 text-muted-foreground"
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className="mb-6 flex items-center gap-2.5 rounded-xl border border-emerald-200/60 bg-emerald-50 px-4 py-3 text-sm text-emerald-600">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{message}
|
||||
<button onClick={() => setMessage("")} className="ml-auto text-emerald-600/50 hover:text-emerald-600">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无待审核工具</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">在系统配置中查看发现提示词,手动添加工具</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60 w-10">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="flex h-5 w-5 items-center justify-center rounded border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: isAllSelected || isPartialSelected ? "var(--color-primary)" : "var(--color-border)",
|
||||
backgroundColor: isAllSelected ? "var(--color-primary)" : isPartialSelected ? "var(--color-primary)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{isAllSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
)}
|
||||
{isPartialSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"><path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">分类</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">ID</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">图标</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">Logo</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">简称</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">简介</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">网址</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">来源</th>
|
||||
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">发现时间</th>
|
||||
<th className="px-4 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTools.map((tool) => (
|
||||
<tr
|
||||
key={tool.id}
|
||||
className={`border-b border-border/40 transition-all duration-200 hover:bg-muted/30 ${
|
||||
selectedIds.has(tool.id) ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleSelect(tool.id)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded border transition-all duration-200"
|
||||
style={{
|
||||
borderColor: selectedIds.has(tool.id) ? "var(--color-primary)" : "var(--color-border)",
|
||||
backgroundColor: selectedIds.has(tool.id) ? "var(--color-primary)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{selectedIds.has(tool.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
|
||||
{tool.category?.name || "未分类"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{tool.id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-8 w-8 rounded-lg border border-border/50 bg-white object-contain p-0.5"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/50 bg-gradient-to-br from-primary/15 to-primary/5 text-[10px] font-semibold text-primary">
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{tool.logoUrl ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
已获取
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-red-500/10 px-2 py-0.5 text-[10px] font-medium text-red-600">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
未获取
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-foreground">{tool.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[200px]">
|
||||
<span className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{tool.description || "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[180px]">
|
||||
{tool.websiteUrl ? (
|
||||
<a
|
||||
href={tool.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate text-xs text-blue-600 hover:text-blue-300 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
<span className="truncate">{tool.websiteUrl.replace(/^https?:\/\//, "")}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-violet-600/70">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
AI发现
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{new Date(tool.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(tool)}
|
||||
className="gap-1 rounded-lg border-border/50 text-[11px] h-7 px-2"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleQuickPublish(tool.id)}
|
||||
className="gap-1 rounded-lg text-[11px] h-7 px-2 bg-emerald-600 hover:bg-emerald-500"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleReject(tool.id)}
|
||||
className="gap-1 rounded-lg text-[11px] h-7 px-2"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > pageSize && (
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => { setPage((p) => p - 1); setSelectedIds(new Set()); }}
|
||||
className="rounded-xl"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {Math.ceil(total / pageSize)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => { setPage((p) => p + 1); setSelectedIds(new Set()); }}
|
||||
className="rounded-xl"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={!!editTool} onOpenChange={(open) => !open && setEditTool(null)}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto border-border/50 bg-white text-foreground sm:max-w-xl rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">编辑工具</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">名称</label>
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => ({ ...f, name: e.target.value }))
|
||||
}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="输入工具名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">简介</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => ({ ...f, description: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200 resize-none"
|
||||
placeholder="输入工具简介"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">官网链接</label>
|
||||
<input
|
||||
value={editForm.websiteUrl}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => ({ ...f, websiteUrl: e.target.value }))
|
||||
}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">Logo URL</label>
|
||||
<input
|
||||
value={editForm.logoUrl}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => ({ ...f, logoUrl: e.target.value }))
|
||||
}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">分类</label>
|
||||
<select
|
||||
value={editForm.categoryId}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => ({ ...f, categoryId: e.target.value }))
|
||||
}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">定价模型</label>
|
||||
<select
|
||||
value={editForm.pricingModel}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => ({ ...f, pricingModel: e.target.value }))
|
||||
}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="free">免费</option>
|
||||
<option value="freemium">免费增值</option>
|
||||
<option value="paid">付费</option>
|
||||
<option value="subscription">订阅</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">定价详情</label>
|
||||
<input
|
||||
value={editForm.pricingDetail}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => ({ ...f, pricingDetail: e.target.value }))
|
||||
}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="如:¥99/月"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">功能特性</label>
|
||||
<TagInput
|
||||
value={editForm.features}
|
||||
onChange={(v) => setEditForm((f) => ({ ...f, features: v }))}
|
||||
placeholder="输入功能特性后回车"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">标签</label>
|
||||
<TagInput
|
||||
value={editForm.tags}
|
||||
onChange={(v) => setEditForm((f) => ({ ...f, tags: v }))}
|
||||
placeholder="输入标签后回车"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditTool(null)}
|
||||
disabled={saving}
|
||||
className="rounded-xl"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-xl"
|
||||
>
|
||||
{saving ? "保存中..." : "仅保存"}
|
||||
</Button>
|
||||
<Button onClick={handlePublish} disabled={saving} className="glow-sm rounded-xl">
|
||||
{saving ? "发布中..." : "发布上架"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import ReviewRow from "./ReviewRow";
|
||||
|
||||
interface ReviewItem {
|
||||
id: number;
|
||||
skillId: number;
|
||||
skillName: string;
|
||||
slug: string;
|
||||
overall: number;
|
||||
capability: number;
|
||||
devExp: number;
|
||||
costLicense: number;
|
||||
community: number;
|
||||
performance: number;
|
||||
status: string;
|
||||
reviewedAt: string;
|
||||
}
|
||||
|
||||
interface ReviewListProps {
|
||||
reviews: ReviewItem[];
|
||||
onAction: (id: number, action: string) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function ReviewList({ reviews, onAction, onDelete }: ReviewListProps) {
|
||||
if (reviews.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无评测数据</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">运行 auto-review.mjs 脚本生成评测</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="px-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-center 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>
|
||||
<th className="px-5 py-3.5 text-center 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>
|
||||
<th className="px-5 py-3.5 text-center 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>
|
||||
<th className="px-5 py-3.5 text-center 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reviews.map((review) => (
|
||||
<ReviewRow
|
||||
key={review.id}
|
||||
review={review}
|
||||
onAction={onAction}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ReviewItem {
|
||||
id: number;
|
||||
skillId: number;
|
||||
skillName: string;
|
||||
slug: string;
|
||||
overall: number;
|
||||
capability: number;
|
||||
devExp: number;
|
||||
costLicense: number;
|
||||
community: number;
|
||||
performance: number;
|
||||
status: string;
|
||||
reviewedAt: string;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||
DRAFT: { label: "草稿", color: "text-amber-700", bg: "bg-amber-50 border-amber-200/60" },
|
||||
PUBLISHED: { label: "已发布", color: "text-emerald-700", bg: "bg-emerald-50 border-emerald-200/60" },
|
||||
REJECTED: { label: "已驳回", color: "text-red-700", bg: "bg-red-50 border-red-200/60" },
|
||||
OUTDATED: { label: "已过期", color: "text-gray-500", bg: "bg-gray-50 border-gray-200/60" },
|
||||
};
|
||||
|
||||
function getScoreColor(score: number): string {
|
||||
if (score >= 4) return "text-emerald-600";
|
||||
if (score >= 3) return "text-amber-600";
|
||||
return "text-red-600";
|
||||
}
|
||||
|
||||
export default function ReviewRow({
|
||||
review,
|
||||
onAction,
|
||||
onDelete,
|
||||
}: {
|
||||
review: ReviewItem;
|
||||
onAction: (id: number, action: string) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}) {
|
||||
const sc = statusConfig[review.status] || statusConfig.DRAFT;
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border/40 transition-all duration-200 hover:bg-muted/30">
|
||||
<td className="px-5 py-3.5">
|
||||
<Link
|
||||
href={`/admin/reviews/${review.id}`}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-gradient-to-br from-violet-500/15 to-violet-500/5 text-xs font-semibold text-violet-600">
|
||||
{review.skillName.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{review.skillName}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground/50">
|
||||
{new Date(review.reviewedAt).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`px-5 py-3.5 text-center font-medium ${getScoreColor(review.capability)}`}>
|
||||
{review.capability}
|
||||
</td>
|
||||
<td className={`px-5 py-3.5 text-center font-medium ${getScoreColor(review.devExp)}`}>
|
||||
{review.devExp}
|
||||
</td>
|
||||
<td className={`px-5 py-3.5 text-center font-medium ${getScoreColor(review.costLicense)}`}>
|
||||
{review.costLicense}
|
||||
</td>
|
||||
<td className={`px-5 py-3.5 text-center font-medium ${getScoreColor(review.community)}`}>
|
||||
{review.community}
|
||||
</td>
|
||||
<td className={`px-5 py-3.5 text-center font-medium ${getScoreColor(review.performance)}`}>
|
||||
{review.performance}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span className={`text-sm font-bold ${getScoreColor(review.overall)}`}>
|
||||
{review.overall}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span className={`inline-flex rounded-lg border px-2 py-0.5 text-[10px] font-medium ${sc.bg} ${sc.color}`}>
|
||||
{sc.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
{review.status === "DRAFT" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onAction(review.id, "publish")}
|
||||
className="gap-1 rounded-lg text-xs bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
)}
|
||||
{review.status === "PUBLISHED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onAction(review.id, "unpublish")}
|
||||
className="gap-1 rounded-lg text-xs"
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
)}
|
||||
{review.status === "DRAFT" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onAction(review.id, "reject")}
|
||||
className="gap-1 rounded-lg text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
)}
|
||||
<Link href={`/admin/reviews/${review.id}`}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1 rounded-lg text-xs"
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onAction(review.id, "regenerate")}
|
||||
className="gap-1 rounded-lg text-xs text-violet-600 hover:text-violet-700"
|
||||
>
|
||||
重评
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(review.id)}
|
||||
className="gap-1 rounded-lg text-xs"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
interface ReviewStatsProps {
|
||||
total: number;
|
||||
published: number;
|
||||
draft: number;
|
||||
average: string;
|
||||
currentFilter: string;
|
||||
onFilterChange: (filter: string) => void;
|
||||
}
|
||||
|
||||
const filterOptions = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "DRAFT", label: "草稿" },
|
||||
{ value: "PUBLISHED", label: "已发布" },
|
||||
{ value: "REJECTED", label: "已驳回" },
|
||||
{ value: "OUTDATED", label: "已过期" },
|
||||
];
|
||||
|
||||
export default function ReviewStats({
|
||||
total,
|
||||
published,
|
||||
draft,
|
||||
average,
|
||||
currentFilter,
|
||||
onFilterChange,
|
||||
}: ReviewStatsProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-4">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{total}</p>
|
||||
<p className="text-xs text-muted-foreground">总评测数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{published}</p>
|
||||
<p className="text-xs text-muted-foreground">已发布</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{draft}</p>
|
||||
<p className="text-xs text-muted-foreground">待审核</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{average}</p>
|
||||
<p className="text-xs text-muted-foreground">平均评分</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">筛选:</span>
|
||||
{filterOptions.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
|
||||
currentFilter === f.value
|
||||
? "border-primary/30 bg-primary/10 text-primary shadow-sm shadow-primary/5"
|
||||
: "border-border/50 bg-white text-muted-foreground hover:bg-muted/40 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ReviewDetail {
|
||||
id: number;
|
||||
skillId: number;
|
||||
overall: number;
|
||||
capability: number;
|
||||
devExp: number;
|
||||
costLicense: number;
|
||||
community: number;
|
||||
performance: number;
|
||||
reviews: {
|
||||
capability?: { score: number; summary: string; detail: string };
|
||||
devExp?: { score: number; summary: string; detail: string };
|
||||
costLicense?: { score: number; summary: string; detail: string };
|
||||
community?: { score: number; summary: string; detail: string };
|
||||
performance?: { score: number; summary: string; detail: string };
|
||||
};
|
||||
longArticle: string | null;
|
||||
videoScript: string | null;
|
||||
socialCards: Record<string, unknown> | null;
|
||||
status: string;
|
||||
reviewedAt: string;
|
||||
createdAt: string;
|
||||
skill: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
sourceUrl: string;
|
||||
description: string;
|
||||
features: Record<string, unknown> | null;
|
||||
tags: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
const dimLabels: Record<string, string> = {
|
||||
capability: "场景与能力",
|
||||
devExp: "开发体验",
|
||||
costLicense: "成本与许可",
|
||||
community: "社区健康",
|
||||
performance: "性能稳定",
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||
DRAFT: { label: "草稿", color: "text-amber-700", bg: "bg-amber-50 border-amber-200/60" },
|
||||
PUBLISHED: { label: "已发布", color: "text-emerald-700", bg: "bg-emerald-50 border-emerald-200/60" },
|
||||
REJECTED: { label: "已驳回", color: "text-red-700", bg: "bg-red-50 border-red-200/60" },
|
||||
OUTDATED: { label: "已过期", color: "text-gray-500", bg: "bg-gray-50 border-gray-200/60" },
|
||||
};
|
||||
|
||||
function getScoreColor(score: number): string {
|
||||
if (score >= 4) return "text-emerald-600";
|
||||
if (score >= 3) return "text-amber-600";
|
||||
return "text-red-600";
|
||||
}
|
||||
|
||||
function getScoreBg(score: number): string {
|
||||
if (score >= 4) return "bg-emerald-50";
|
||||
if (score >= 3) return "bg-amber-50";
|
||||
return "bg-red-50";
|
||||
}
|
||||
|
||||
export default function ReviewDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const reviewId = params.id as string;
|
||||
|
||||
const [review, setReview] = useState<ReviewDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState<string | null>(null);
|
||||
const [scores, setScores] = useState({
|
||||
capability: 0,
|
||||
devExp: 0,
|
||||
costLicense: 0,
|
||||
community: 0,
|
||||
performance: 0,
|
||||
});
|
||||
|
||||
const fetchReview = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/reviews/${reviewId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setReview(data);
|
||||
setScores({
|
||||
capability: data.capability,
|
||||
devExp: data.devExp,
|
||||
costLicense: data.costLicense,
|
||||
community: data.community,
|
||||
performance: data.performance,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch review");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [reviewId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReview();
|
||||
}, [fetchReview]);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/reviews/${reviewId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchReview();
|
||||
}
|
||||
} catch {
|
||||
console.error("Action failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveScores = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const overall =
|
||||
Math.round(
|
||||
((scores.capability +
|
||||
scores.devExp +
|
||||
scores.costLicense +
|
||||
scores.community +
|
||||
scores.performance) /
|
||||
5) *
|
||||
10
|
||||
) / 10;
|
||||
|
||||
const res = await fetch(`/api/admin/reviews/${reviewId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "updateScores",
|
||||
...scores,
|
||||
overall,
|
||||
reviews: review?.reviews,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEditing(false);
|
||||
fetchReview();
|
||||
}
|
||||
} catch {
|
||||
console.error("Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (type: "longArticle" | "videoScript" | "socialCards") => {
|
||||
setGenerating(type);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/reviews/${reviewId}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type }),
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchReview();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || "生成失败");
|
||||
}
|
||||
} catch {
|
||||
alert("生成请求失败");
|
||||
} finally {
|
||||
setGenerating(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="space-y-4">
|
||||
<div className="h-10 w-48 animate-pulse rounded-xl bg-muted/20" />
|
||||
<div className="h-64 animate-pulse rounded-2xl bg-muted/20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
return (
|
||||
<div className="animate-fade-in flex flex-col items-center justify-center py-24">
|
||||
<p className="text-sm text-muted-foreground">评测不存在</p>
|
||||
<Link href="/admin/reviews" className="mt-4 text-sm text-primary hover:underline">
|
||||
返回列表
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sc = statusConfig[review.status] || statusConfig.DRAFT;
|
||||
const overall =
|
||||
Math.round(
|
||||
((review.capability +
|
||||
review.devExp +
|
||||
review.costLicense +
|
||||
review.community +
|
||||
review.performance) /
|
||||
5) *
|
||||
10
|
||||
) / 10;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Link
|
||||
href="/admin/reviews"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{review.skill.name}</h1>
|
||||
<span className={`inline-flex rounded-lg border px-2.5 py-0.5 text-[11px] font-medium ${sc.bg} ${sc.color}`}>
|
||||
{sc.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
评测详情 · 综合评分 <span className={`font-bold ${getScoreColor(overall)}`}>{overall}</span> / 5
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{review.status === "DRAFT" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleAction("publish")}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
发布评测
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction("reject")}
|
||||
disabled={saving}
|
||||
className="rounded-xl text-red-600 hover:text-red-700"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{review.status === "PUBLISHED" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction("unpublish")}
|
||||
disabled={saving}
|
||||
className="rounded-xl"
|
||||
>
|
||||
撤回为草稿
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction("regenerate")}
|
||||
disabled={saving}
|
||||
className="rounded-xl"
|
||||
>
|
||||
请求重评
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-base font-semibold">五维评分</h2>
|
||||
{!editing ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded-lg text-xs"
|
||||
>
|
||||
编辑评分
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(false)}
|
||||
className="rounded-lg text-xs"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveScores}
|
||||
disabled={saving}
|
||||
className="rounded-lg text-xs"
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(dimLabels).map(([key, label]) => {
|
||||
const dimData = review.reviews?.[key as keyof typeof review.reviews];
|
||||
const score = editing ? scores[key as keyof typeof scores] : review[key as keyof ReviewDetail] as number;
|
||||
return (
|
||||
<div key={key} className={`rounded-xl border border-border/40 p-4 ${getScoreBg(score)}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-foreground/80">{label}</span>
|
||||
{editing ? (
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
step="0.5"
|
||||
value={scores[key as keyof typeof scores]}
|
||||
onChange={(e) =>
|
||||
setScores((s) => ({
|
||||
...s,
|
||||
[key]: parseFloat(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
className="h-8 w-20 rounded-lg border border-border/50 bg-white px-2 text-sm text-center font-bold focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15"
|
||||
/>
|
||||
) : (
|
||||
<span className={`text-lg font-bold ${getScoreColor(score)}`}>
|
||||
★ {score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{dimData && typeof dimData === "object" && "summary" in dimData && (
|
||||
<p className="text-xs font-medium text-foreground/70 mb-1">
|
||||
{dimData.summary}
|
||||
</p>
|
||||
)}
|
||||
{dimData && typeof dimData === "object" && "detail" in dimData && (
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{dimData.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-center gap-2 rounded-xl bg-violet-50 border border-violet-200/60 p-4">
|
||||
<span className="text-sm text-muted-foreground">综合评分</span>
|
||||
<span className="text-3xl font-bold text-violet-700">
|
||||
{editing
|
||||
? (Math.round(((scores.capability + scores.devExp + scores.costLicense + scores.community + scores.performance) / 5) * 10) / 10)
|
||||
: overall}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/ 5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{review.longArticle && (
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<h2 className="text-base font-semibold mb-3">长文评测</h2>
|
||||
<div className="prose prose-sm max-w-none text-muted-foreground/80 whitespace-pre-wrap">
|
||||
{review.longArticle}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{review.videoScript && (
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<h2 className="text-base font-semibold mb-3">视频脚本</h2>
|
||||
<div className="prose prose-sm max-w-none text-muted-foreground/80 whitespace-pre-wrap">
|
||||
{review.videoScript}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{review.socialCards && (
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<h2 className="text-base font-semibold mb-3">社交卡片</h2>
|
||||
<div className="space-y-3">
|
||||
{(typeof review.socialCards === "object" && review.socialCards !== null && "cards" in review.socialCards && Array.isArray((review.socialCards as { cards: unknown[] }).cards))
|
||||
? ((review.socialCards as { cards: { title: string; body: string }[] }).cards).map((card, i) => (
|
||||
<div key={i} className="rounded-xl border border-border/40 bg-gradient-to-br from-violet-50/50 to-blue-50/50 p-4">
|
||||
<p className="text-sm font-medium text-foreground">{card.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{card.body}</p>
|
||||
</div>
|
||||
))
|
||||
: (
|
||||
<pre className="text-xs text-muted-foreground/80 whitespace-pre-wrap">
|
||||
{JSON.stringify(review.socialCards, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<h2 className="text-base font-semibold mb-4">内容生成</h2>
|
||||
<p className="text-xs text-muted-foreground/60 mb-4">
|
||||
基于 DeepSeek AI 自动生成评测衍生内容
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
onClick={() => handleGenerate("longArticle")}
|
||||
disabled={generating !== null}
|
||||
className="flex flex-col items-center gap-2 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:border-primary/30 hover:bg-primary/5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{generating === "longArticle" ? "生成中..." : review.longArticle ? "重新生成长文" : "生成长文"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">公众号深度评测</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGenerate("videoScript")}
|
||||
disabled={generating !== null}
|
||||
className="flex flex-col items-center gap-2 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:border-primary/30 hover:bg-primary/5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-rose-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-rose-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{generating === "videoScript" ? "生成中..." : review.videoScript ? "重新生成脚本" : "生成视频脚本"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">60秒短视频脚本</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGenerate("socialCards")}
|
||||
disabled={generating !== null}
|
||||
className="flex flex-col items-center gap-2 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:border-primary/30 hover:bg-primary/5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{generating === "socialCards" ? "生成中..." : review.socialCards ? "重新生成卡片" : "生成社交卡片"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">小红书/即刻/微博</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<h2 className="text-base font-semibold mb-4">技能信息</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground/60 uppercase tracking-wider">名称</p>
|
||||
<p className="text-sm font-medium text-foreground">{review.skill.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground/60 uppercase tracking-wider">简介</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">{review.skill.description || "暂无"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground/60 uppercase tracking-wider">来源</p>
|
||||
<a
|
||||
href={review.skill.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate block"
|
||||
>
|
||||
{review.skill.sourceUrl}
|
||||
</a>
|
||||
</div>
|
||||
{review.skill.features && typeof review.skill.features === "object" && (
|
||||
<>
|
||||
{(review.skill.features as Record<string, unknown>).language && (
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground/60 uppercase tracking-wider">语言</p>
|
||||
<p className="text-sm text-foreground">{(review.skill.features as Record<string, unknown>).language as string}</p>
|
||||
</div>
|
||||
)}
|
||||
{(review.skill.features as Record<string, unknown>).license && (
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground/60 uppercase tracking-wider">许可证</p>
|
||||
<p className="text-sm text-foreground">{(review.skill.features as Record<string, unknown>).license as string}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<h2 className="text-base font-semibold mb-4">评测时间线</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-foreground">创建时间</p>
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
{new Date(review.createdAt).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-violet-500/10">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-foreground">评测时间</p>
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
{new Date(review.reviewedAt).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<h2 className="text-base font-semibold mb-4">前台预览</h2>
|
||||
<Link
|
||||
href={`/skills/${review.skill.slug}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 rounded-xl border border-primary/20 bg-primary/5 p-3 text-sm text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
在新窗口打开技能详情页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ReviewStats from "./ReviewStats";
|
||||
import ReviewList from "./ReviewList";
|
||||
|
||||
interface Review {
|
||||
id: number;
|
||||
skillId: number;
|
||||
skillName: string;
|
||||
slug: string;
|
||||
sourceUrl: string;
|
||||
description: string;
|
||||
overall: number;
|
||||
capability: number;
|
||||
devExp: number;
|
||||
costLicense: number;
|
||||
community: number;
|
||||
performance: number;
|
||||
reviews: unknown;
|
||||
status: string;
|
||||
reviewedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function ReviewsPage() {
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [stats, setStats] = useState({ total: 0, published: 0, draft: 0, overallAvg: 0 });
|
||||
const pageSize = 20;
|
||||
|
||||
const fetchReviews = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString(),
|
||||
});
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
const res = await fetch(`/api/admin/reviews?${params}`);
|
||||
const data = await res.json();
|
||||
setReviews(data.reviews || []);
|
||||
setTotal(data.total || 0);
|
||||
if (data.stats) setStats(data.stats);
|
||||
} catch {
|
||||
console.error("Failed to fetch reviews");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReviews();
|
||||
}, [fetchReviews]);
|
||||
|
||||
const handleAction = async (id: number, action: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/reviews/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchReviews();
|
||||
}
|
||||
} catch {
|
||||
console.error("Action failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定删除该评测?删除后不可恢复。")) return;
|
||||
try {
|
||||
await fetch(`/api/admin/reviews/${id}`, { method: "DELETE" });
|
||||
fetchReviews();
|
||||
} catch {
|
||||
console.error("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">评测管理</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
管理自动生成的五维评测,共 {total} 条
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ReviewStats
|
||||
total={stats.total}
|
||||
published={stats.published}
|
||||
draft={stats.draft}
|
||||
average={stats.overallAvg.toFixed(1)}
|
||||
currentFilter={statusFilter}
|
||||
onFilterChange={(f) => {
|
||||
setStatusFilter(f);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ReviewList
|
||||
reviews={reviews}
|
||||
onAction={handleAction}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded-xl"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded-xl"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
groupName: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
isSystem: boolean;
|
||||
userCount: number;
|
||||
permissionIds: number[];
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
const res = await fetch("/api/admin/roles");
|
||||
const data = await res.json();
|
||||
setRoles(data);
|
||||
}, []);
|
||||
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
const res = await fetch("/api/admin/permissions");
|
||||
const data = await res.json();
|
||||
setPermissions(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchRoles(), fetchPermissions()]).finally(() => setLoading(false));
|
||||
}, [fetchRoles, fetchPermissions]);
|
||||
|
||||
const selectRole = (role: Role) => {
|
||||
setSelectedRoleId(role.id);
|
||||
setCheckedIds(new Set(role.permissionIds));
|
||||
setShowCreate(false);
|
||||
};
|
||||
|
||||
const togglePermission = (permId: number) => {
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(permId)) next.delete(permId);
|
||||
else next.add(permId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
const groupPerms = permissions.filter((p) => p.groupName === groupName);
|
||||
const allChecked = groupPerms.every((p) => checkedIds.has(p.id));
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allChecked) {
|
||||
groupPerms.forEach((p) => next.delete(p.id));
|
||||
} else {
|
||||
groupPerms.forEach((p) => next.add(p.id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedRoleId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/api/admin/roles/${selectedRoleId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ permissionIds: Array.from(checkedIds) }),
|
||||
});
|
||||
fetchRoles();
|
||||
} catch {
|
||||
alert("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newSlug.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/roles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName, slug: newSlug, permissionIds: [] }),
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchRoles();
|
||||
setShowCreate(false);
|
||||
setNewName("");
|
||||
setNewSlug("");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || "创建失败");
|
||||
}
|
||||
} catch {
|
||||
alert("创建失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (role: Role) => {
|
||||
if (role.isSystem) { alert("系统内置角色不能删除"); return; }
|
||||
if (role.userCount > 0) { alert(`该角色下还有 ${role.userCount} 个用户`); return; }
|
||||
if (!confirm(`确定删除角色"${role.name}"?`)) return;
|
||||
try {
|
||||
await fetch(`/api/admin/roles/${role.id}`, { method: "DELETE" });
|
||||
fetchRoles();
|
||||
if (selectedRoleId === role.id) setSelectedRoleId(null);
|
||||
} catch { alert("删除失败"); }
|
||||
};
|
||||
|
||||
const groups = Array.from(new Set(permissions.map((p) => p.groupName)));
|
||||
const selectedRole = roles.find((r) => r.id === selectedRoleId);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
<div className="w-64 shrink-0 border-r border-border/50 bg-white p-4 overflow-y-auto">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-foreground">角色列表</h2>
|
||||
<button
|
||||
onClick={() => { setShowCreate(true); setSelectedRoleId(null); }}
|
||||
className="rounded-lg bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mb-3 rounded-xl border border-border/50 bg-muted/30 p-3 space-y-2">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="角色名称"
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-white px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/15"
|
||||
/>
|
||||
<input
|
||||
value={newSlug}
|
||||
onChange={(e) => setNewSlug(e.target.value.replace(/[^a-z0-9_]/g, ""))}
|
||||
placeholder="标识 (英文)"
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-white px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/15"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !newName.trim() || !newSlug.trim()}
|
||||
className="rounded-lg bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(false); setNewName(""); setNewSlug(""); }}
|
||||
className="rounded-lg border border-border/50 px-3 py-1 text-xs text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{roles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
onClick={() => selectRole(role)}
|
||||
className={`group flex items-center justify-between rounded-lg px-3 py-2 cursor-pointer transition-colors ${
|
||||
role.id === selectedRoleId
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm">{role.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{role.permissionIds.length} 权限 · {role.userCount} 用户
|
||||
{role.isSystem && <span className="ml-1 rounded bg-amber-100 px-1 text-[10px] text-amber-700">系统</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!role.isSystem && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(role); }}
|
||||
className="ml-2 hidden rounded p-1 text-muted-foreground hover:bg-red-50 hover:text-red-500 group-hover:block"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{selectedRole ? (
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-foreground">{selectedRole.name} — 权限配置</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{selectedRole.isSystem ? "系统内置角色" : "自定义角色"} ·
|
||||
{checkedIds.size} / {permissions.length} 个权限已启用
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50 transition-all"
|
||||
>
|
||||
{saving ? "保存中..." : `保存权限 (${checkedIds.size})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{groups.map((groupName) => {
|
||||
const groupPerms = permissions.filter((p) => p.groupName === groupName);
|
||||
const allChecked = groupPerms.every((p) => checkedIds.has(p.id));
|
||||
const someChecked = groupPerms.some((p) => checkedIds.has(p.id));
|
||||
return (
|
||||
<div key={groupName} className="rounded-xl border border-border/50 bg-white p-4">
|
||||
<label
|
||||
onClick={() => toggleGroup(groupName)}
|
||||
className="mb-3 flex cursor-pointer items-center gap-2 select-none"
|
||||
>
|
||||
<div className={`flex h-5 w-5 items-center justify-center rounded border-2 transition-colors ${
|
||||
allChecked ? "border-primary bg-primary" : someChecked ? "border-primary/50 bg-primary/20" : "border-border"
|
||||
}`}>
|
||||
{allChecked && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
|
||||
{someChecked && !allChecked && <div className="h-2 w-2 rounded-sm bg-primary" />}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground">{groupName}</span>
|
||||
<span className="text-xs text-muted-foreground">({groupPerms.length})</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{groupPerms.map((perm) => (
|
||||
<label
|
||||
key={perm.id}
|
||||
onClick={() => togglePermission(perm.id)}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm select-none transition-colors ${
|
||||
checkedIds.has(perm.id)
|
||||
? "bg-primary/5 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border-2 transition-colors ${
|
||||
checkedIds.has(perm.id) ? "border-primary bg-primary" : "border-border"
|
||||
}`}>
|
||||
{checkedIds.has(perm.id) && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
|
||||
</div>
|
||||
{perm.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg className="mx-auto mb-4 h-16 w-16 text-muted-foreground/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
<p className="text-sm font-medium text-foreground">选择一个角色查看权限配置</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">从左侧列表选择角色,查看和编辑其权限</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface SkillCategoryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
parentId: number | null;
|
||||
sortOrder: number;
|
||||
skillCount: number;
|
||||
childCount: number;
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
slug: "",
|
||||
icon: "",
|
||||
parentId: "" as string,
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
export default function SkillCategoriesAdminPage() {
|
||||
const [categories, setCategories] = useState<SkillCategoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [editCat, setEditCat] = useState<SkillCategoryItem | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/skill-categories");
|
||||
const data = await res.json();
|
||||
setCategories(data);
|
||||
} catch {
|
||||
console.error("Fetch skill categories failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
const topLevelCategories = categories.filter((c) => !c.parentId);
|
||||
const secondLevelCategories = categories.filter((c) => c.parentId);
|
||||
const totalSkills = categories.reduce((sum, c) => sum + c.skillCount, 0);
|
||||
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
};
|
||||
|
||||
const openEdit = (cat: SkillCategoryItem) => {
|
||||
setIsNew(false);
|
||||
setEditCat(cat);
|
||||
setForm({
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
icon: cat.icon || "",
|
||||
parentId: cat.parentId?.toString() || "",
|
||||
sortOrder: cat.sortOrder,
|
||||
});
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setIsNew(true);
|
||||
setEditCat({} as SkillCategoryItem);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
slug: form.slug || generateSlug(form.name) || undefined,
|
||||
icon: form.icon || null,
|
||||
parentId: form.parentId ? parseInt(form.parentId) : null,
|
||||
sortOrder: form.sortOrder,
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
const res = await fetch("/api/admin/skill-categories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "创建失败");
|
||||
return;
|
||||
}
|
||||
} else if (editCat?.id) {
|
||||
const res = await fetch(`/api/admin/skill-categories/${editCat.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "更新失败");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEditCat(null);
|
||||
fetchCategories();
|
||||
} catch {
|
||||
setError("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (cat: SkillCategoryItem) => {
|
||||
if (!confirm(`确定删除分类"${cat.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/skill-categories/${cat.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
alert(data.error || "删除失败");
|
||||
return;
|
||||
}
|
||||
setCategories((prev) => prev.filter((c) => c.id !== cat.id));
|
||||
} catch {
|
||||
console.error("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">技能分类管理</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
管理AI技能的分类结构
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{topLevelCategories.length}</p>
|
||||
<p className="text-xs text-muted-foreground">一级分类</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-purple-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-purple-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{secondLevelCategories.length}</p>
|
||||
<p className="text-xs text-muted-foreground">二级分类</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{totalSkills}</p>
|
||||
<p className="text-xs text-muted-foreground">分类下技能</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">共 {categories.length} 个分类</span>
|
||||
<Button onClick={openNew} className="gap-2 glow-sm rounded-xl">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加分类
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无分类</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">点击上方按钮添加第一个分类</p>
|
||||
<Button variant="outline" size="sm" className="mt-4 rounded-xl" onClick={openNew}>
|
||||
添加分类
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="px-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-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">Slug</th>
|
||||
<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-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">子分类</th>
|
||||
<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">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topLevelCategories.map((cat) => (
|
||||
<>
|
||||
<tr key={cat.id} className="border-b border-border/40 transition-all duration-200 hover:bg-muted/30">
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{cat.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<code className="rounded-md border border-border/40 bg-white px-2 py-0.5 text-xs text-muted-foreground">{cat.slug}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-muted-foreground">{cat.sortOrder}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{cat.childCount > 0 ? (
|
||||
<span className="inline-flex rounded-lg border border-purple-200/60 bg-purple-50 px-2 py-0.5 text-[10px] font-medium text-purple-600">{cat.childCount}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="inline-flex rounded-lg border border-primary/20 bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">{cat.skillCount}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(cat)} className="gap-1.5 rounded-lg border-border/50 text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDelete(cat)} className="gap-1.5 rounded-lg text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{categories.filter((c) => c.parentId === cat.id).map((child) => (
|
||||
<tr key={child.id} className="border-b border-border/50 bg-muted/20 transition-all duration-200 hover:bg-white">
|
||||
<td className="px-5 py-3 pl-12">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-border">└</span>
|
||||
<span className="text-muted-foreground">{child.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<code className="rounded-md border border-border/40 bg-white px-2 py-0.5 text-xs text-muted-foreground">{child.slug}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{child.sortOrder}</td>
|
||||
<td className="px-5 py-3">
|
||||
{child.childCount > 0 ? (
|
||||
<span className="inline-flex rounded-lg border border-purple-200/60 bg-purple-50 px-2 py-0.5 text-[10px] font-medium text-purple-600">{child.childCount}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex rounded-lg border border-primary/20 bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">{child.skillCount}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(child)} className="gap-1.5 rounded-lg border-border/50 text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDelete(child)} className="gap-1.5 rounded-lg text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={!!editCat} onOpenChange={(open) => { if (!open) { setEditCat(null); setError(""); } }}>
|
||||
<DialogContent className="border-border/50 bg-white text-foreground sm:max-w-md rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">{isNew ? "添加技能分类" : "编辑技能分类"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">名称</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name,
|
||||
slug: isNew && !f.slug ? generateSlug(name) : f.slug,
|
||||
}));
|
||||
}}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="输入分类名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">Slug</label>
|
||||
<input
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||
placeholder="自动生成"
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">图标</label>
|
||||
<input value={form.icon} onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))} placeholder="可选" className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">排序值</label>
|
||||
<input type="number" value={form.sortOrder} onChange={(e) => setForm((f) => ({ ...f, sortOrder: parseInt(e.target.value) || 0 }))} className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">父分类</label>
|
||||
<select value={form.parentId} onChange={(e) => setForm((f) => ({ ...f, parentId: e.target.value }))} className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200">
|
||||
<option value="">无(一级分类)</option>
|
||||
{topLevelCategories.filter((c) => c.id !== editCat?.id).map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="mt-6 gap-2">
|
||||
<Button variant="outline" onClick={() => { setEditCat(null); setError(""); }} disabled={saving} className="rounded-xl">取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="glow-sm rounded-xl">{saving ? "保存中..." : "保存"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface Skill {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
sourceUrl: string;
|
||||
sourceType: string;
|
||||
categoryId: number;
|
||||
features: unknown;
|
||||
tags: unknown;
|
||||
viewCount: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
category: { name: string };
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [categories, setCategories] = useState<{ id: number; name: string }[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const [editSkill, setEditSkill] = useState<Skill | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
sourceUrl: "",
|
||||
sourceType: "github",
|
||||
logoUrl: "",
|
||||
categoryId: "" as string,
|
||||
features: "",
|
||||
tags: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchSkills = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/skills?page=${page}&pageSize=${pageSize}`);
|
||||
const data = await res.json();
|
||||
setSkills(data.skills || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
console.error("Failed to fetch skills");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkills();
|
||||
fetchCategories();
|
||||
}, [fetchSkills]);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/skill-categories");
|
||||
const data = await res.json();
|
||||
const flat: { id: number; name: string }[] = [];
|
||||
const walk = (list: { id: number; name: string; children?: { id: number; name: string }[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ id: c.id, name: c.name });
|
||||
if (Array.isArray(c.children)) walk(c.children as typeof list);
|
||||
}
|
||||
};
|
||||
walk(data);
|
||||
setCategories(flat);
|
||||
} catch {
|
||||
console.error("Failed to fetch categories");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openEdit = (skill: Skill) => {
|
||||
setEditSkill(skill);
|
||||
setEditForm({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
sourceUrl: skill.sourceUrl,
|
||||
sourceType: skill.sourceType,
|
||||
logoUrl: skill.logoUrl || "",
|
||||
categoryId: skill.categoryId?.toString() || "",
|
||||
features: typeof skill.features === "string" ? skill.features : JSON.stringify(skill.features || {}, null, 2),
|
||||
tags: Array.isArray(skill.tags) ? (skill.tags as string[]).join(", ") : String(skill.tags || ""),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editSkill) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const parsedFeatures = (() => { try { return JSON.parse(editForm.features); } catch { return editForm.features ? { raw: editForm.features } : null; } })();
|
||||
const parsedTags = editForm.tags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
await fetch(`/api/admin/skills/${editSkill.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...editForm,
|
||||
categoryId: editForm.categoryId ? parseInt(editForm.categoryId) : null,
|
||||
features: parsedFeatures,
|
||||
tags: parsedTags,
|
||||
}),
|
||||
});
|
||||
setEditSkill(null);
|
||||
fetchSkills();
|
||||
} catch {
|
||||
console.error("Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定删除该技能?")) return;
|
||||
try {
|
||||
await fetch(`/api/admin/skills/${id}`, { method: "DELETE" });
|
||||
setSkills((prev) => prev.filter((s) => s.id !== id));
|
||||
setTotal((prev) => prev - 1);
|
||||
} catch {
|
||||
console.error("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const truncate = (str: string | null, len: number) => {
|
||||
if (!str) return "-";
|
||||
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 (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">已发布技能</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
管理已上架的技能,共 {total} 个
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{total}</p>
|
||||
<p className="text-xs text-muted-foreground">已发布技能</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{skills.reduce((sum, s) => sum + s.viewCount, 0)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">总浏览量</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{new Set(skills.map((s) => s.sourceType)).size}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">来源类型</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无已发布技能</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">审核待发布技能后即可在此查看</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<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-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">简介</th>
|
||||
<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-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">来源</th>
|
||||
<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">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{skills.map((skill) => (
|
||||
<tr
|
||||
key={skill.id}
|
||||
className="border-b border-border/40 transition-all duration-200 hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{skill.logoUrl ? (
|
||||
<img
|
||||
src={skill.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-9 w-9 rounded-lg border border-border/50 bg-white object-contain p-0.5"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-gradient-to-br from-violet-500/15 to-violet-500/5 text-xs font-semibold text-violet-600">
|
||||
{skill.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{skill.name}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground/50">{skill.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-muted-foreground line-clamp-1">
|
||||
{truncate(skill.description, 50)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="inline-flex rounded-lg border border-primary/20 bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
{skill.category?.name || "未分类"}
|
||||
</span>
|
||||
</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}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-muted-foreground">{skill.viewCount}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(skill)}
|
||||
className="gap-1.5 rounded-lg border-border/50 text-xs"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(skill.id)}
|
||||
className="gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded-xl"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded-xl"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={!!editSkill} onOpenChange={(open) => !open && setEditSkill(null)}>
|
||||
<DialogContent className="border-border/50 bg-white text-foreground sm:max-w-md rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">编辑技能</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">名称</label>
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">简介</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">来源地址</label>
|
||||
<input
|
||||
value={editForm.sourceUrl}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, sourceUrl: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">来源类型</label>
|
||||
<select
|
||||
value={editForm.sourceType}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, sourceType: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="huggingface">HuggingFace</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">Logo URL</label>
|
||||
<input
|
||||
value={editForm.logoUrl}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, logoUrl: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">分类</label>
|
||||
<select
|
||||
value={editForm.categoryId}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, categoryId: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">功能特性 (JSON)</label>
|
||||
<textarea
|
||||
value={editForm.features}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, features: e.target.value }))}
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200 resize-none font-mono"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">标签 (逗号分隔)</label>
|
||||
<input
|
||||
value={editForm.tags}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, tags: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="AI Agent, RAG, 大模型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 gap-2">
|
||||
<Button variant="outline" onClick={() => setEditSkill(null)} disabled={saving} className="rounded-xl">取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="glow-sm rounded-xl">
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface LogoFetchConfig {
|
||||
enabledMethods: string[];
|
||||
commonPaths: string[];
|
||||
faviconServices: { name: string; url: string; enabled: boolean }[];
|
||||
minFileSize: number;
|
||||
requestTimeout: number;
|
||||
}
|
||||
|
||||
interface DiscoveryPromptConfig {
|
||||
toolPrompt: string;
|
||||
toolTagPool: string[];
|
||||
skillPrompt: string;
|
||||
skillTagPool: string[];
|
||||
logoPrompt: string;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGO_CONFIG: LogoFetchConfig = {
|
||||
enabledMethods: [
|
||||
"html_link_icon",
|
||||
"html_meta_og",
|
||||
"html_img_logo",
|
||||
"common_paths",
|
||||
"favicon_services",
|
||||
],
|
||||
commonPaths: [
|
||||
"/apple-touch-icon.png",
|
||||
"/apple-touch-icon-precomposed.png",
|
||||
"/logo.png",
|
||||
"/logo.svg",
|
||||
"/favicon-32x32.png",
|
||||
"/favicon.ico",
|
||||
],
|
||||
faviconServices: [
|
||||
{ name: "favicon.im", url: "https://favicon.im/{domain}", enabled: true },
|
||||
{ name: "Google Favicons", url: "https://www.google.com/s2/favicons?domain={domain}&sz=128", enabled: true },
|
||||
{ name: "Clearbit", url: "https://logo.clearbit.com/{domain}", enabled: true },
|
||||
],
|
||||
minFileSize: 300,
|
||||
requestTimeout: 5000,
|
||||
};
|
||||
|
||||
const DEFAULT_DISCOVERY_CONFIG: DiscoveryPromptConfig = {
|
||||
toolPrompt: `你是国内AI工具专家。请为"{category_name}"领域推荐 10 个真实存在且活跃的中国国产AI工具。
|
||||
|
||||
严格要求:
|
||||
1. 只推荐中国本土的AI工具,不推荐任何国外工具(如ChatGPT、Midjourney、Claude等一律排除)
|
||||
2. 工具必须在国内可直接访问,无需翻墙,无需海外手机号注册
|
||||
3. 工具必须支持中文界面或中文使用,国人开箱即用
|
||||
4. 优先推荐有特色的新兴工具,避免推荐已经非常知名的工具
|
||||
5. websiteUrl必须是真实可访问的官网链接
|
||||
|
||||
每个工具包含以下字段:
|
||||
- name: 工具名称
|
||||
- description: 一句话中文简介(50字以内,突出核心功能和适用场景)
|
||||
- websiteUrl: 官网链接(必须是真实可访问的URL,以http://或https://开头,国内可直连)
|
||||
- logoUrl: 工具Logo图片链接(优先使用官网的apple-touch-icon或og:image地址,如https://example.com/logo.png,找不到则留空字符串)
|
||||
- pricingModel: 定价模型,只能是以下之一:"free"、"freemium"、"paid"、"subscription"
|
||||
- pricingDetail: 定价详情(如"免费"、"¥99/月"、"新用户赠送100积分"等)
|
||||
- features: 功能特性数组(3-5个关键特性,每个特性10字以内)
|
||||
- tags: 标签数组(3-5个标签,从限定标签池中选取)
|
||||
|
||||
返回纯JSON数组,不要包含任何其他文字、markdown标记或代码块标记。`,
|
||||
toolTagPool: [
|
||||
"文本生成", "图像生成", "视频生成", "音频处理", "代码生成",
|
||||
"数据分析", "自然语言理解", "内容检测", "AI Agent", "RAG", "机器人流程自动化",
|
||||
"国产AI", "中文优化", "开箱即用", "免费使用", "企业级",
|
||||
],
|
||||
skillPrompt: `你是国内AI技能专家。请为"{category_name}"领域推荐 10 个真实存在且活跃的中国国产开源AI技能/项目。
|
||||
|
||||
严格要求:
|
||||
1. 优先推荐国内开发者的开源项目(Gitee、国内GitHub镜像、国内团队维护的项目等)
|
||||
2. 也推荐在国内可直接使用的国际开源项目(无需翻墙即可安装使用)
|
||||
3. 项目必须有中文文档或中文社区支持,国人开箱即用
|
||||
4. sourceUrl必须是真实可访问的项目地址
|
||||
5. 不推荐纯英文、无中文支持的项目
|
||||
|
||||
每个技能包含以下字段:
|
||||
- name: 技能名称
|
||||
- description: 一句话中文简介(50字以内,突出核心功能和适用场景)
|
||||
- sourceUrl: 项目地址(必须是真实可访问的URL,如 https://github.com/... 或 https://gitee.com/... 或 https://huggingface.co/...)
|
||||
- sourceType: 来源类型,只能是以下之一:"github"、"gitlab"、"huggingface"、"other"
|
||||
- features: 功能特性数组(3-5个关键特性,每个特性10字以内)
|
||||
- tags: 标签数组(3-5个标签)
|
||||
|
||||
返回纯JSON数组,不要包含任何其他文字、markdown标记或代码块标记。`,
|
||||
skillTagPool: [
|
||||
"开源", "国产", "中文支持", "开箱即用", "免费",
|
||||
"NLP", "CV", "语音", "多模态", "Agent",
|
||||
],
|
||||
logoPrompt: `你是Logo抓取专家。给定一个网站URL,请按以下策略获取该网站的Logo图标。
|
||||
|
||||
抓取策略(按优先级依次尝试):
|
||||
|
||||
1. HTML Link 图标
|
||||
访问网站首页HTML,从 <link rel="icon">、<link rel="shortcut icon">、<link rel="apple-touch-icon">、<link rel="apple-touch-icon-precomposed"> 标签中提取 href 属性值。
|
||||
注意:href可能是相对路径(/favicon.ico)、协议相对路径(//cdn.example.com/icon.png)或绝对路径。
|
||||
优先选择 apple-touch-icon,因为它通常是最高清的图标。
|
||||
|
||||
2. OG/Twitter 图片
|
||||
从 <meta property="og:image"> 或 <meta name="twitter:image"> 的 content 属性提取社交分享图。
|
||||
这类图片通常质量较高,但可能是横幅图而非Logo。
|
||||
|
||||
3. HTML Logo 图片
|
||||
从 <img> 标签中搜索 src 或 data-src 属性包含 "logo" 或 "brand" 关键词的图片。
|
||||
需要过滤掉 data: URI 和过短的无效链接。
|
||||
|
||||
4. 常见路径探测
|
||||
直接请求以下常见图标路径(HEAD请求验证):
|
||||
- /apple-touch-icon.png
|
||||
- /apple-touch-icon-precomposed.png
|
||||
- /logo.png
|
||||
- /logo.svg
|
||||
- /favicon-32x32.png
|
||||
- /favicon.ico
|
||||
验证条件:Content-Type 为 image/* 且 Content-Length > 300字节。
|
||||
|
||||
5. Favicon 服务(第三方API)
|
||||
- favicon.im:https://favicon.im/{domain}
|
||||
- Google Favicons:https://www.google.com/s2/favicons?domain={domain}&sz=128
|
||||
- Clearbit:https://logo.clearbit.com/{domain}
|
||||
注意:favicon.im 返回的 SVG 可能是通用占位符(<300字节),需按文件大小和类型过滤。
|
||||
国内环境下 favicon.im 可用性最好。
|
||||
|
||||
URL解析规则:
|
||||
- 以 // 开头:补全为 https:
|
||||
- 以 / 开头:拼接网站 origin
|
||||
- 以 http 开头:直接使用
|
||||
- 其他:拼接为 {origin}/{href}
|
||||
|
||||
注意事项:
|
||||
- 国内网站 HTML 中 href 属性可能无引号,解析时需同时匹配有引号和无引号的情况
|
||||
- 部分网站使用 CDN 域名托管图标,需正确解析跨域URL
|
||||
- 最小文件大小阈值:300字节(小于此大小视为占位符,跳过)
|
||||
- 单个请求超时:5秒`,
|
||||
};
|
||||
|
||||
const METHOD_LABELS: Record<string, { label: string; desc: string }> = {
|
||||
html_link_icon: {
|
||||
label: "HTML Link 图标",
|
||||
desc: '从网页 <link rel="icon"> 标签提取图标,支持有引号和无引号的属性值',
|
||||
},
|
||||
html_meta_og: {
|
||||
label: "OG/Twitter 图片",
|
||||
desc: "从 <meta og:image> / <meta twitter:image> 提取社交分享图",
|
||||
},
|
||||
html_img_logo: {
|
||||
label: "HTML Logo 图片",
|
||||
desc: '从 <img src="*logo*"> 标签提取含 logo 关键词的图片',
|
||||
},
|
||||
common_paths: {
|
||||
label: "常见路径探测",
|
||||
desc: "直接访问 /apple-touch-icon.png、/logo.png、/favicon.ico 等常见路径",
|
||||
},
|
||||
favicon_services: {
|
||||
label: "Favicon 服务",
|
||||
desc: "通过第三方 favicon 服务获取图标(favicon.im、Google、Clearbit)",
|
||||
},
|
||||
};
|
||||
|
||||
export default function SystemConfigPage() {
|
||||
const [logoConfig, setLogoConfig] = useState<LogoFetchConfig>(DEFAULT_LOGO_CONFIG);
|
||||
const [discoveryConfig, setDiscoveryConfig] = useState<DiscoveryPromptConfig>(DEFAULT_DISCOVERY_CONFIG);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"logo" | "tool" | "skill">("logo");
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/system-config");
|
||||
const data = await res.json();
|
||||
if (data.logoFetchConfig) {
|
||||
const saved = typeof data.logoFetchConfig === "string"
|
||||
? JSON.parse(data.logoFetchConfig)
|
||||
: data.logoFetchConfig;
|
||||
setLogoConfig({ ...DEFAULT_LOGO_CONFIG, ...saved });
|
||||
}
|
||||
if (data.discoveryPromptConfig) {
|
||||
const saved = typeof data.discoveryPromptConfig === "string"
|
||||
? JSON.parse(data.discoveryPromptConfig)
|
||||
: data.discoveryPromptConfig;
|
||||
setDiscoveryConfig({ ...DEFAULT_DISCOVERY_CONFIG, ...saved });
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch config");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await fetch("/api/admin/system-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
logoFetchConfig: logoConfig,
|
||||
discoveryPromptConfig: discoveryConfig,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setMessage("保存成功");
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
} else {
|
||||
setMessage("保存失败: " + (data.error || "未知错误"));
|
||||
}
|
||||
} catch {
|
||||
setMessage("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMethod = (method: string) => {
|
||||
setLogoConfig((prev) => ({
|
||||
...prev,
|
||||
enabledMethods: prev.enabledMethods.includes(method)
|
||||
? prev.enabledMethods.filter((m) => m !== method)
|
||||
: [...prev.enabledMethods, method],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleService = (idx: number) => {
|
||||
setLogoConfig((prev) => ({
|
||||
...prev,
|
||||
faviconServices: prev.faviconServices.map((s, i) =>
|
||||
i === idx ? { ...s, enabled: !s.enabled } : s
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateCommonPath = (idx: number, value: string) => {
|
||||
setLogoConfig((prev) => ({
|
||||
...prev,
|
||||
commonPaths: prev.commonPaths.map((p, i) => (i === idx ? value : p)),
|
||||
}));
|
||||
};
|
||||
|
||||
const addCommonPath = () => {
|
||||
setLogoConfig((prev) => ({ ...prev, commonPaths: [...prev.commonPaths, "/"] }));
|
||||
};
|
||||
|
||||
const removeCommonPath = (idx: number) => {
|
||||
setLogoConfig((prev) => ({
|
||||
...prev,
|
||||
commonPaths: prev.commonPaths.filter((_, i) => i !== idx),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateTagPool = (type: "tool" | "skill", idx: number, value: string) => {
|
||||
const key = type === "tool" ? "toolTagPool" : "skillTagPool";
|
||||
setDiscoveryConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: (prev[key] as string[]).map((t, i) => (i === idx ? value : t)),
|
||||
}));
|
||||
};
|
||||
|
||||
const addTag = (type: "tool" | "skill") => {
|
||||
const key = type === "tool" ? "toolTagPool" : "skillTagPool";
|
||||
setDiscoveryConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: [...(prev[key] as string[]), ""],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeTag = (type: "tool" | "skill", idx: number) => {
|
||||
const key = type === "tool" ? "toolTagPool" : "skillTagPool";
|
||||
setDiscoveryConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: (prev[key] as string[]).filter((_, i) => i !== idx),
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8"><h1 className="text-2xl font-bold tracking-tight">系统配置</h1></div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-24 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: "logo" as const, label: "Logo 抓取", icon: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" },
|
||||
{ key: "tool" as const, label: "工具发现提示词", icon: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" },
|
||||
{ key: "skill" as const, label: "技能发现提示词", icon: "M13 10V3L4 14h7v7l9-11h-7z" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">系统配置</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
管理 Logo 抓取策略、工具和技能发现提示词,配置项可驱动AI完成发现和更新
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={saving} className="glow-sm rounded-xl">
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-6 flex items-center gap-2.5 rounded-xl border px-4 py-3 text-sm ${
|
||||
message.includes("成功")
|
||||
? "border-emerald-200/60 bg-emerald-50 text-emerald-600"
|
||||
: "border-red-200/60 bg-red-50 text-red-600"
|
||||
}`}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{message.includes("成功")
|
||||
? <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
: <path d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />}
|
||||
</svg>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex gap-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||
activeTab === tab.key
|
||||
? "border-primary/30 bg-primary/10 text-primary"
|
||||
: "border-border/50 bg-white text-muted-foreground hover:border-border/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={tab.icon} />
|
||||
</svg>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "logo" && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Logo 抓取策略</h2>
|
||||
<p className="text-xs text-muted-foreground">配置工具 Logo 的获取方式和优先级,按勾选顺序依次尝试</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(METHOD_LABELS).map(([key, { label, desc }]) => (
|
||||
<label key={key} className={`flex items-start gap-3 rounded-xl border p-4 transition-all duration-200 cursor-pointer ${logoConfig.enabledMethods.includes(key) ? "border-primary/30 bg-primary/5" : "border-border/40 bg-muted/20 hover:border-border/40"}`}>
|
||||
<input type="checkbox" checked={logoConfig.enabledMethods.includes(key)} onChange={() => toggleMethod(key)} className="mt-0.5 h-4 w-4 rounded border-border/50 text-primary focus:ring-primary/30" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/50">#{logoConfig.enabledMethods.indexOf(key) + 1 || "-"}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600"><path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">常见路径列表</h2>
|
||||
<p className="text-xs text-muted-foreground">按顺序探测网站根目录下的图标文件路径</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{logoConfig.commonPaths.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="w-6 text-center text-[10px] text-muted-foreground/50">{i + 1}</span>
|
||||
<input value={p} onChange={(e) => updateCommonPath(i, e.target.value)} className="h-9 flex-1 rounded-lg border border-border/40 bg-white px-3 text-sm font-mono text-foreground focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/15 transition-all duration-200" />
|
||||
<button onClick={() => removeCommonPath(i)} className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 text-muted-foreground/50 transition-all duration-200 hover:border-red-300/40 hover:text-red-600">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={addCommonPath} className="mt-2 gap-1.5 rounded-lg text-xs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||
添加路径
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-600"><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Favicon 服务</h2>
|
||||
<p className="text-xs text-muted-foreground">第三方图标获取服务,{`{domain}`} 为占位符</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{logoConfig.faviconServices.map((svc, i) => (
|
||||
<label key={i} className={`flex items-center gap-3 rounded-xl border p-3 transition-all duration-200 cursor-pointer ${svc.enabled ? "border-primary/30 bg-primary/5" : "border-border/40 bg-muted/20"}`}>
|
||||
<input type="checkbox" checked={svc.enabled} onChange={() => toggleService(i)} className="h-4 w-4 rounded border-border/50 text-primary focus:ring-primary/30" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{svc.name}</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground truncate">{svc.url}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600"><path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">高级参数</h2>
|
||||
<p className="text-xs text-muted-foreground">调整抓取行为的细节参数</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">最小文件大小(字节)</label>
|
||||
<input type="number" value={logoConfig.minFileSize} onChange={(e) => setLogoConfig((prev) => ({ ...prev, minFileSize: parseInt(e.target.value) || 300 }))} className="h-10 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200" />
|
||||
<p className="mt-1 text-[10px] text-muted-foreground/50">小于此大小的响应视为占位符,跳过</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">请求超时(毫秒)</label>
|
||||
<input type="number" value={logoConfig.requestTimeout} onChange={(e) => setLogoConfig((prev) => ({ ...prev, requestTimeout: parseInt(e.target.value) || 5000 }))} className="h-10 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200" />
|
||||
<p className="mt-1 text-[10px] text-muted-foreground/50">单个请求的最大等待时间</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-cyan-600"><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Logo 抓取提示词</h2>
|
||||
<p className="text-xs text-muted-foreground">描述Logo抓取策略的完整提示词,可提供给AI模型驱动自动抓取</p>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={discoveryConfig.logoPrompt}
|
||||
onChange={(e) =>
|
||||
setDiscoveryConfig((prev) => ({
|
||||
...prev,
|
||||
logoPrompt: e.target.value,
|
||||
}))
|
||||
}
|
||||
rows={20}
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200 resize-y font-mono leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeTab === "tool" || activeTab === "skill") && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${activeTab === "tool" ? "bg-blue-500/10" : "bg-amber-500/10"}`}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={activeTab === "tool" ? "text-blue-600" : "text-amber-600"}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={activeTab === "tool" ? "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" : "M13 10V3L4 14h7v7l9-11h-7z"} />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{activeTab === "tool" ? "AI工具发现提示词" : "AI技能发现提示词"}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeTab === "tool"
|
||||
? "使用此提示词驱动AI发现国产AI工具,{category_name}会被替换为分类名"
|
||||
: "使用此提示词驱动AI发现国产开源AI技能/项目,{category_name}会被替换为分类名"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={activeTab === "tool" ? discoveryConfig.toolPrompt : discoveryConfig.skillPrompt}
|
||||
onChange={(e) =>
|
||||
setDiscoveryConfig((prev) => ({
|
||||
...prev,
|
||||
[activeTab === "tool" ? "toolPrompt" : "skillPrompt"]: e.target.value,
|
||||
}))
|
||||
}
|
||||
rows={20}
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200 resize-y font-mono leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-purple-600"><path strokeLinecap="round" strokeLinejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">标签池</h2>
|
||||
<p className="text-xs text-muted-foreground">AI返回的标签只能从此标签池中选取</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(activeTab === "tool" ? discoveryConfig.toolTagPool : discoveryConfig.skillTagPool).map((tag, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-white px-2.5 py-1.5">
|
||||
<input
|
||||
value={tag}
|
||||
onChange={(e) => updateTagPool(activeTab, i, e.target.value)}
|
||||
className="w-16 border-0 bg-transparent p-0 text-xs text-foreground focus:outline-none"
|
||||
/>
|
||||
<button onClick={() => removeTag(activeTab, i)} className="text-muted-foreground/50 hover:text-red-600 transition-colors">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addTag(activeTab)}
|
||||
className="flex items-center gap-1 rounded-lg border border-dashed border-border/40 px-2.5 py-1.5 text-xs text-muted-foreground/60 transition-colors hover:border-primary/30 hover:text-primary"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-6">
|
||||
<div className="relative z-10">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-cyan-600"><path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">使用说明</h2>
|
||||
<p className="text-xs text-muted-foreground">如何使用此配置驱动AI发现{activeTab === "tool" ? "工具" : "技能"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-white p-4">
|
||||
<ol className="space-y-2.5 text-sm text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary">1</span>
|
||||
<span>复制上方提示词,将 <code className="rounded bg-primary/10 px-1 text-primary">{"{category_name}"}</code> 替换为目标分类名称</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary">2</span>
|
||||
<span>将提示词发送给任意AI模型(DeepSeek、ChatGPT、Kimi等),获取JSON结果</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary">3</span>
|
||||
<span>将AI返回的JSON数据粘贴到待审核页面的批量导入功能中</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary">4</span>
|
||||
<span>系统会自动抓取Logo、去重,进入待审核列表</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="mt-0.5 shrink-0 text-amber-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div className="text-xs text-amber-600/80 leading-relaxed">
|
||||
<p className="font-medium text-amber-600 mb-1">配置驱动说明</p>
|
||||
<p>本配置项完整描述了发现{activeTab === "tool" ? "工具" : "技能"}的流程、数据格式和约束条件。任何AI模型只需读取此配置,即可独立完成{activeTab === "tool" ? "工具" : "技能"}的发现和更新,无需额外的API集成或脚本。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving} className="glow-sm rounded-xl px-8">
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface Tool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
categoryId: number;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
pricingDetail: string | null;
|
||||
features: unknown;
|
||||
tags: unknown;
|
||||
viewCount: number;
|
||||
status: string;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
paid: "付费",
|
||||
subscription: "订阅",
|
||||
};
|
||||
|
||||
const pricingColor: Record<string, string> = {
|
||||
free: "border-emerald-200/60 bg-emerald-50 text-emerald-600",
|
||||
freemium: "border-blue-200/60 bg-blue-50 text-blue-600",
|
||||
paid: "border-amber-200/60 bg-amber-50 text-amber-600",
|
||||
subscription: "border-purple-200/60 bg-purple-50 text-purple-600",
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
};
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
draft: "border-amber-200/60 bg-amber-50 text-amber-600",
|
||||
published: "border-emerald-200/60 bg-emerald-50 text-emerald-600",
|
||||
};
|
||||
|
||||
function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const add = () => {
|
||||
const v = input.trim();
|
||||
if (v && !value.includes(v)) onChange([...value, v]);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), add())}
|
||||
placeholder={placeholder}
|
||||
className="h-10 flex-1 rounded-xl border border-border/50 bg-white px-3.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={add} className="rounded-xl">
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-white px-2.5 py-1 text-xs text-foreground transition-all duration-200 hover:border-border/50"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
className="text-muted-foreground/50 transition-colors hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
description: "",
|
||||
websiteUrl: "",
|
||||
logoUrl: "",
|
||||
categoryId: "" as string,
|
||||
pricingModel: "freemium",
|
||||
pricingDetail: "",
|
||||
status: "draft",
|
||||
features: [] as string[],
|
||||
tags: [] as string[],
|
||||
};
|
||||
|
||||
export default function ToolsAdminPage() {
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
const [editTool, setEditTool] = useState<Tool | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/tools?page=${page}&pageSize=${pageSize}`);
|
||||
const data = await res.json();
|
||||
setTools(data.tools || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
console.error("Fetch tools failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/categories");
|
||||
const data = await res.json();
|
||||
const flat: Category[] = [];
|
||||
const walk = (list: { id: number; name: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ id: c.id, name: c.name });
|
||||
if (Array.isArray(c.children)) walk(c.children as typeof list);
|
||||
}
|
||||
};
|
||||
walk(data);
|
||||
setCategories(flat);
|
||||
} catch {
|
||||
console.error("Fetch categories failed");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
fetchCategories();
|
||||
}, [fetchTools, fetchCategories]);
|
||||
|
||||
const openEdit = (tool: Tool) => {
|
||||
setIsNew(false);
|
||||
setEditTool(tool);
|
||||
setForm({
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
websiteUrl: tool.websiteUrl || "",
|
||||
logoUrl: tool.logoUrl || "",
|
||||
categoryId: tool.categoryId?.toString() || "",
|
||||
pricingModel: tool.pricingModel || "freemium",
|
||||
pricingDetail: tool.pricingDetail || "",
|
||||
status: tool.status || "draft",
|
||||
features: Array.isArray(tool.features) ? (tool.features as string[]) : [],
|
||||
tags: Array.isArray(tool.tags) ? (tool.tags as string[]) : [],
|
||||
});
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setIsNew(true);
|
||||
setEditTool({} as Tool);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
websiteUrl: form.websiteUrl,
|
||||
logoUrl: form.logoUrl,
|
||||
categoryId: form.categoryId ? parseInt(form.categoryId) : 1,
|
||||
pricingModel: form.pricingModel,
|
||||
pricingDetail: form.pricingDetail,
|
||||
features: form.features,
|
||||
tags: form.tags,
|
||||
status: form.status,
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
await fetch("/api/admin/tools", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else if (editTool?.id) {
|
||||
await fetch(`/api/admin/tools/${editTool.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
setEditTool(null);
|
||||
fetchTools();
|
||||
} catch {
|
||||
console.error("Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定删除该工具?")) return;
|
||||
try {
|
||||
await fetch(`/api/admin/tools/${id}`, { method: "DELETE" });
|
||||
setTools((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch {
|
||||
console.error("Delete 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);
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">已发布工具管理</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
管理所有已发布和草稿状态的AI工具
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{publishedCount}</p>
|
||||
<p className="text-xs text-muted-foreground">已发布</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{draftCount}</p>
|
||||
<p className="text-xs text-muted-foreground">草稿</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/10">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{totalViews.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">总浏览量</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">共 {total} 个工具</span>
|
||||
</div>
|
||||
<Button onClick={openNew} className="gap-2 glow-sm rounded-xl">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加新工具
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无工具</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">点击上方按钮添加第一个工具</p>
|
||||
<Button variant="outline" size="sm" className="mt-4 rounded-xl" onClick={openNew}>
|
||||
添加新工具
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="px-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-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">分类</th>
|
||||
<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-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">浏览量</th>
|
||||
<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">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tools.map((tool) => (
|
||||
<tr
|
||||
key={tool.id}
|
||||
className="border-b border-border/40 transition-all duration-200 hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-9 w-9 rounded-lg border border-border/50 bg-white object-contain p-0.5"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-gradient-to-br from-primary/15 to-primary/5 text-xs font-semibold text-primary">
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{tool.name}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground/50">{tool.websiteUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-muted-foreground">
|
||||
{tool.category?.name || <span className="text-muted-foreground/40">未分类</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex rounded-lg border px-2 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "border-border/50 bg-secondary text-muted-foreground"}`}>
|
||||
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-muted-foreground">{tool.viewCount.toLocaleString()}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-medium ${statusColor[tool.status] || "border-border/50 bg-secondary text-muted-foreground"}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${tool.status === "published" ? "bg-emerald-400" : "bg-amber-400"}`} />
|
||||
{statusLabel[tool.status] || tool.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(tool)}
|
||||
className="gap-1.5 rounded-lg border-border/50 text-xs"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(tool.id)}
|
||||
className="gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded-xl"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded-xl"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!editTool}
|
||||
onOpenChange={(open) => !open && setEditTool(null)}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto border-border/50 bg-white text-foreground sm:max-w-xl rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">{isNew ? "添加新工具" : "编辑工具"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">名称</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="输入工具名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">简介</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-border/50 bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200 resize-none"
|
||||
placeholder="输入工具简介"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">官网链接</label>
|
||||
<input
|
||||
value={form.websiteUrl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, websiteUrl: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">Logo URL</label>
|
||||
<input
|
||||
value={form.logoUrl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, logoUrl: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">分类</label>
|
||||
<select
|
||||
value={form.categoryId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, categoryId: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">定价模型</label>
|
||||
<select
|
||||
value={form.pricingModel}
|
||||
onChange={(e) => setForm((f) => ({ ...f, pricingModel: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="free">免费</option>
|
||||
<option value="freemium">免费增值</option>
|
||||
<option value="paid">付费</option>
|
||||
<option value="subscription">订阅</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">定价详情</label>
|
||||
<input
|
||||
value={form.pricingDetail}
|
||||
onChange={(e) => setForm((f) => ({ ...f, pricingDetail: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:bg-background focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
placeholder="如:¥99/月"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">状态</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-white px-4 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">功能特性</label>
|
||||
<TagInput
|
||||
value={form.features}
|
||||
onChange={(v) => setForm((f) => ({ ...f, features: v }))}
|
||||
placeholder="输入功能特性后回车"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">标签</label>
|
||||
<TagInput
|
||||
value={form.tags}
|
||||
onChange={(v) => setForm((f) => ({ ...f, tags: v }))}
|
||||
placeholder="输入标签后回车"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditTool(null)}
|
||||
disabled={saving}
|
||||
className="rounded-xl"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="glow-sm rounded-xl">
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface SkillItem {
|
||||
id: number;
|
||||
name: string;
|
||||
sourceUrl: string | null;
|
||||
sourceType: string | null;
|
||||
rating: number;
|
||||
categoryId: number | null;
|
||||
}
|
||||
|
||||
type TabType = "published" | "pending";
|
||||
|
||||
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 [progress, setProgress] = useState(0);
|
||||
const [progressTotal, setProgressTotal] = useState(0);
|
||||
|
||||
const githubSkills = skills.filter((s) => s.sourceUrl && s.sourceUrl.includes("github.com"));
|
||||
const otherSkills = skills.filter((s) => s.sourceUrl && !s.sourceUrl.includes("github.com"));
|
||||
const noUrlSkills = skills.filter((s) => !s.sourceUrl);
|
||||
|
||||
const fetchSkills = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const endpoint = activeTab === "published" ? "/api/admin/skills?pageSize=9999" : "/api/admin/pending-skills?pageSize=9999";
|
||||
const res = await fetch(endpoint);
|
||||
const data = await res.json();
|
||||
const list = (data.skills || []).map((s: { id: number; name: string; sourceUrl: string | null; sourceType: string | null; rating: number; categoryId: number | null }) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
sourceUrl: s.sourceUrl,
|
||||
sourceType: s.sourceType,
|
||||
rating: s.rating || 0,
|
||||
categoryId: s.categoryId,
|
||||
}));
|
||||
setSkills(list);
|
||||
} catch {
|
||||
console.error("Failed to fetch skills");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkills();
|
||||
}, [fetchSkills]);
|
||||
|
||||
const handleQuickUpdate = async () => {
|
||||
setUpdating(true);
|
||||
setUpdateResults([]);
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
alert("更新请求失败");
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">更新Skill技能</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
一键更新所有技能的GitHub/Gitee/GitCode星级评分
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">1</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">选择范围</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground leading-relaxed">选择更新已发布或待审核技能</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">2</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">一键更新</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground leading-relaxed">系统自动从GitHub/Gitee/GitCode获取最新星级</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary">3</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">自动同步</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground leading-relaxed">星级变化自动写入数据库</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-1 rounded-xl border border-border/50 bg-white p-1">
|
||||
<button
|
||||
onClick={() => { setActiveTab("published"); setUpdateResults([]); }}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ${activeTab === "published" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground hover:bg-white"}`}
|
||||
>
|
||||
已发布技能
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab("pending"); setUpdateResults([]); }}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ${activeTab === "pending" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground hover:bg-white"}`}
|
||||
>
|
||||
待审核技能
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<span>共 <strong className="text-foreground">{skills.length}</strong> 个</span>
|
||||
<span>GitHub <strong className="text-blue-600">{githubSkills.length}</strong></span>
|
||||
<span>其他来源 <strong className="text-amber-600">{otherSkills.length}</strong></span>
|
||||
<span>无地址 <strong className="text-red-600">{noUrlSkills.length}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleQuickUpdate} disabled={updating || loading} className="gap-1.5 rounded-xl">
|
||||
{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</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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="font-medium text-foreground">{progress} / {progressTotal}</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted/30">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-primary to-primary/70 transition-all duration-300" style={{ width: `${progressTotal > 0 ? (progress / progressTotal) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateResults.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-border/50 bg-white p-4">
|
||||
<p className="text-2xl font-bold text-foreground">{updateResults.length}</p>
|
||||
<p className="text-xs text-muted-foreground">已更新</p>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-white p-4">
|
||||
<p className="text-2xl font-bold text-muted-foreground">{unchangedCount}</p>
|
||||
<p className="text-xs text-muted-foreground/60">不变</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="px-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-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 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">
|
||||
<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-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()}
|
||||
</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()}
|
||||
</span>
|
||||
)}
|
||||
{change === "same" && (
|
||||
<span className="inline-flex items-center gap-1 rounded-lg border border-muted-foreground/20 bg-muted/10 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateResults.length === 0 && !updating && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-white">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) data.name = body.name;
|
||||
if (body.slug !== undefined) data.slug = body.slug;
|
||||
if (body.icon !== undefined) data.icon = body.icon;
|
||||
if (body.parentId !== undefined) data.parentId = body.parentId;
|
||||
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
||||
|
||||
const category = await prisma.category.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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
const toolCount = await prisma.tool.count({ where: { categoryId: id } });
|
||||
if (toolCount > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `该分类下有 ${toolCount} 个工具,无法删除` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const childCount = await prisma.category.count({ where: { parentId: id } });
|
||||
if (childCount > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `该分类下有 ${childCount} 个子分类,无法删除` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET() {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: { select: { tools: true, children: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
icon: c.icon,
|
||||
parentId: c.parentId,
|
||||
sortOrder: c.sortOrder,
|
||||
toolCount: c._count.tools,
|
||||
childCount: c._count.children,
|
||||
createdAt: c.createdAt,
|
||||
}))
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
slug: body.slug || body.name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, ""),
|
||||
icon: body.icon || null,
|
||||
parentId: body.parentId || null,
|
||||
sortOrder: body.sortOrder || 0,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ category }, { status: 201 });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
const { type } = body as { type: "published" | "pending" };
|
||||
|
||||
if (!type || !["published", "pending"].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "type 参数无效,必须为 published 或 pending" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "published") {
|
||||
await prisma.tool.delete({ where: { id } });
|
||||
} else {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import { fetchLogoWithMethod } from "@/lib/logo-fetcher";
|
||||
import { downloadLogo } from "@/lib/logo-storage";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type } = body as { type: "published" | "pending" };
|
||||
|
||||
if (!type || !["published", "pending"].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "type 参数无效,必须为 published 或 pending" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tools =
|
||||
type === "published"
|
||||
? await prisma.tool.findMany({
|
||||
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
|
||||
})
|
||||
: await prisma.pendingTool.findMany({
|
||||
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
|
||||
});
|
||||
|
||||
const results: {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
accessible: boolean;
|
||||
logoFetched: boolean;
|
||||
logoMethod: string;
|
||||
logoUrl: string;
|
||||
}[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
const url = tool.websiteUrl;
|
||||
let accessible = false;
|
||||
let html: string | null = null;
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
accessible = res.status >= 200 && res.status < 400;
|
||||
|
||||
if (accessible) {
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
if (ct.includes("text/html")) {
|
||||
html = await res.text();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
accessible = false;
|
||||
}
|
||||
}
|
||||
|
||||
let logoFetched = false;
|
||||
let logoMethod = "";
|
||||
let logoUrl = tool.logoUrl || "";
|
||||
|
||||
const needsLogo = !logoUrl || logoUrl === "" || (!logoUrl.startsWith("/logos/") && !logoUrl.startsWith("data:"));
|
||||
|
||||
if (accessible && url && needsLogo) {
|
||||
try {
|
||||
let logoResult = { url: "", method: "" };
|
||||
|
||||
if (html) {
|
||||
logoResult = await fetchLogoWithMethod(url, html);
|
||||
}
|
||||
|
||||
if (!logoResult.url) {
|
||||
logoResult = await fetchLogoWithMethod(url);
|
||||
}
|
||||
|
||||
if (logoResult.url) {
|
||||
const localPath = await downloadLogo(logoResult.url, tool.name.replace(/[^a-z0-9]/gi, "_").substring(0, 20));
|
||||
|
||||
if (type === "published") {
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { logoUrl: localPath },
|
||||
});
|
||||
} else {
|
||||
await prisma.pendingTool.update({
|
||||
where: { id: tool.id },
|
||||
data: { logoUrl: localPath },
|
||||
});
|
||||
}
|
||||
|
||||
logoFetched = true;
|
||||
logoMethod = logoResult.method;
|
||||
logoUrl = localPath;
|
||||
}
|
||||
} catch {
|
||||
// Logo fetch failed silently
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
url: url || "",
|
||||
accessible,
|
||||
logoFetched,
|
||||
logoMethod,
|
||||
logoUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const report = await prisma.dailyReport.findUnique({
|
||||
where: { id },
|
||||
include: { items: { orderBy: { sortOrder: "asc" } } },
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
const { date, title, summary, status, items } = body;
|
||||
|
||||
const existing = await prisma.dailyReport.findUnique({ where: { id } });
|
||||
if (!existing) return NextResponse.json({ error: "日报不存在" }, { status: 404 });
|
||||
|
||||
if (items) {
|
||||
await prisma.dailyReportItem.deleteMany({ where: { reportId: id } });
|
||||
}
|
||||
|
||||
const report = await prisma.dailyReport.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(date !== undefined && { date: new Date(date) }),
|
||||
...(title !== undefined && { title }),
|
||||
...(summary !== undefined && { summary }),
|
||||
...(status !== undefined && { status }),
|
||||
...(items !== undefined && {
|
||||
hotCount: items.length,
|
||||
items: {
|
||||
create: items.map((item: { sortOrder?: number; category?: string; title: string; summary?: string; detail?: string; sourceUrl?: string; imageUrl?: string; hotScore?: number }, index: number) => ({
|
||||
sortOrder: item.sortOrder ?? index,
|
||||
category: item.category || "model",
|
||||
title: item.title,
|
||||
summary: item.summary || null,
|
||||
detail: item.detail || null,
|
||||
sourceUrl: item.sourceUrl || null,
|
||||
imageUrl: item.imageUrl || null,
|
||||
hotScore: item.hotScore || 0,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
},
|
||||
include: { items: { orderBy: { sortOrder: "asc" } } },
|
||||
});
|
||||
|
||||
return NextResponse.json({ report });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import { extractJSON } from "@/lib/utils";
|
||||
import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
});
|
||||
|
||||
const DAILY_PROMPT = `你是AI行业资深分析师。请为{date}生成一份AI日报,包含6-8条当日最重要的AI行业热点新闻。
|
||||
|
||||
要求:
|
||||
1. 每条新闻必须有真实的新闻事件基础,不能编造
|
||||
2. 每条新闻必须覆盖不同分类维度,确保多样性
|
||||
3. 标题简洁有力(20字以内),摘要精炼(50字以内),详情深入(100-200字)
|
||||
4. 优先关注中国AI行业动态,兼顾全球重要事件
|
||||
5. 分类标注规则(必须使用以下key值):
|
||||
- model:模型前沿(LLM/多模态新模型发布、架构创新、训练方法突破)
|
||||
- product:产品落地(AI产品发布/更新、C端应用上线、B端解决方案、新功能)
|
||||
- opensource:开源生态(开源项目动态、框架更新、开源社区大事件)
|
||||
- business:商业产业(投融资、收购并购、财报营收、公司战略、行业趋势)
|
||||
- policy:政策治理(AI法规政策、监管动态、伦理治理、国际AI治理)
|
||||
- insight:深度洞察(行业领袖观点、深度分析报告、学术研究前沿)
|
||||
|
||||
返回纯JSON格式:
|
||||
{
|
||||
"title": "AI日报:{date} 核心要点概览",
|
||||
"summary": "今日AI行业整体趋势概述(100字以内)",
|
||||
"items": [
|
||||
{
|
||||
"category": "model",
|
||||
"title": "新闻标题",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细内容描述",
|
||||
"sourceUrl": "",
|
||||
"hotScore": 85
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
hotScore热度评分规则(0-100)`;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const date = body.date || new Date().toLocaleDateString("zh-CN");
|
||||
|
||||
const prompt = DAILY_PROMPT.replace(/\{date\}/g, date);
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "deepseek-v4-pro",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const content = completion.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: "生成内容为空" }, { status: 500 });
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = extractJSON(content);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "无法解析AI返回的JSON", raw: content }, { status: 422 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: parsed });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message || "生成失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20");
|
||||
const status = searchParams.get("status") || undefined;
|
||||
|
||||
const where = status ? { status: status as "draft" | "published" } : {};
|
||||
|
||||
const [reports, total] = await Promise.all([
|
||||
prisma.dailyReport.findMany({
|
||||
where,
|
||||
include: { items: { orderBy: { sortOrder: "asc" } } },
|
||||
orderBy: { date: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.dailyReport.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ reports, total, page, pageSize });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { date, title, summary, items } = body;
|
||||
|
||||
if (!date || !title) {
|
||||
return NextResponse.json({ error: "日期和标题不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const report = await prisma.dailyReport.create({
|
||||
data: {
|
||||
date: new Date(date),
|
||||
title,
|
||||
summary: summary || null,
|
||||
hotCount: items?.length || 0,
|
||||
items: items
|
||||
? {
|
||||
create: items.map((item: { sortOrder?: number; category?: string; title: string; summary?: string; detail?: string; sourceUrl?: string; imageUrl?: string; hotScore?: number }, index: number) => ({
|
||||
sortOrder: item.sortOrder ?? index,
|
||||
category: item.category || "model",
|
||||
title: item.title,
|
||||
summary: item.summary || null,
|
||||
detail: item.detail || null,
|
||||
sourceUrl: item.sourceUrl || null,
|
||||
imageUrl: item.imageUrl || null,
|
||||
hotScore: item.hotScore || 0,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ report });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
function getTodayDate() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { items } = body as {
|
||||
items: { title: string; summary?: string; detail?: string; category?: string; sourceUrl?: string; hotScore?: number }[];
|
||||
};
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json({ error: "items 数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const today = getTodayDate();
|
||||
|
||||
const existing = await prisma.dailyReport.findUnique({
|
||||
where: { date: new Date(today) },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.dailyReport.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
const report = await prisma.dailyReport.create({
|
||||
data: {
|
||||
date: new Date(today),
|
||||
title: `AI日报 - ${today}`,
|
||||
summary: `${today} AI行业${items.length}条重要新闻`,
|
||||
hotCount: items.length,
|
||||
status: "published",
|
||||
items: {
|
||||
create: items.map((item, index) => ({
|
||||
sortOrder: index + 1,
|
||||
category: item.category || "tech",
|
||||
title: item.title || "",
|
||||
summary: item.summary || "",
|
||||
detail: item.detail || "",
|
||||
sourceUrl: item.sourceUrl || null,
|
||||
hotScore: item.hotScore || 50,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
date: today,
|
||||
totalItems: items.length,
|
||||
report: {
|
||||
id: report.id,
|
||||
title: report.title,
|
||||
date: report.date,
|
||||
items: report.items.map((i) => ({
|
||||
id: i.id,
|
||||
category: i.category,
|
||||
title: i.title,
|
||||
summary: i.summary,
|
||||
detail: i.detail,
|
||||
sourceUrl: i.sourceUrl,
|
||||
hotScore: i.hotScore,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
|
||||
const report = await prisma.dailyReport.findUnique({
|
||||
where: { date: new Date(today) },
|
||||
include: { items: { orderBy: { sortOrder: "asc" } } },
|
||||
});
|
||||
|
||||
if (!report) {
|
||||
return NextResponse.json({ report: null, date: today });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
report: {
|
||||
id: report.id,
|
||||
title: report.title,
|
||||
date: report.date,
|
||||
summary: report.summary,
|
||||
hotCount: report.hotCount,
|
||||
items: report.items.map((i) => ({
|
||||
id: i.id,
|
||||
category: i.category,
|
||||
title: i.title,
|
||||
summary: i.summary,
|
||||
detail: i.detail,
|
||||
sourceUrl: i.sourceUrl,
|
||||
hotScore: i.hotScore,
|
||||
})),
|
||||
},
|
||||
date: today,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { skills, categoryId } = body as {
|
||||
skills: { name: string; description?: string; sourceUrl?: string; sourceType?: string; rating?: number; features?: string[]; tags?: string[] }[];
|
||||
categoryId: number;
|
||||
};
|
||||
|
||||
if (!skills || !Array.isArray(skills) || skills.length === 0) {
|
||||
return NextResponse.json({ error: "skills 数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
return NextResponse.json({ error: "categoryId 不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [existingPending, existingPublished] = await Promise.all([
|
||||
prisma.pendingSkill.findMany({ select: { name: true, sourceUrl: true } }),
|
||||
prisma.skill.findMany({ select: { name: true, sourceUrl: true } }),
|
||||
]);
|
||||
|
||||
const allExisting = [...existingPending, ...existingPublished];
|
||||
const nameSet = new Set(allExisting.map((s) => s.name));
|
||||
const urlSet = new Set(allExisting.map((s) => s.sourceUrl).filter(Boolean) as string[]);
|
||||
const existingSlugs = await prisma.pendingSkill.findMany({ select: { slug: true } });
|
||||
const slugSet = new Set(existingSlugs.map((s) => s.slug));
|
||||
|
||||
const toCreate: { name: string; slug: string; description: string | null; sourceUrl: string | null; sourceType: string; categoryId: number | null; features: any; tags: any; rating: number }[] = [];
|
||||
const results: { name: string; status: string }[] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
const name = skill.name || "";
|
||||
if (!name || nameSet.has(name)) {
|
||||
results.push({ name: name || "未知", status: nameSet.has(name) ? "跳过-名称重复" : "跳过-名称为空" });
|
||||
continue;
|
||||
}
|
||||
const sourceUrl = skill.sourceUrl || "";
|
||||
if (sourceUrl && urlSet.has(sourceUrl)) {
|
||||
results.push({ name, status: "跳过-地址重复" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!slug || /^[\u4e00-\u9fff-]+$/.test(slug)) {
|
||||
slug = "skill-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
if (slugSet.has(slug)) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
slugSet.add(slug);
|
||||
|
||||
toCreate.push({
|
||||
name,
|
||||
slug,
|
||||
description: skill.description || null,
|
||||
sourceUrl: sourceUrl || null,
|
||||
sourceType: skill.sourceType || "github",
|
||||
categoryId,
|
||||
features: skill.features || null,
|
||||
tags: skill.tags || null,
|
||||
rating: skill.rating || 0,
|
||||
});
|
||||
|
||||
nameSet.add(name);
|
||||
if (sourceUrl) urlSet.add(sourceUrl);
|
||||
results.push({ name, status: "已导入" });
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.$transaction(
|
||||
toCreate.map((data) =>
|
||||
prisma.pendingSkill.create({ data })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: skills.length,
|
||||
imported: results.filter((r) => r.status === "已导入").length,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { discoverSkills } from "@/lib/skill-discoverer";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const result = await discoverSkills(prisma, {
|
||||
minStars: body.minStars ?? 500,
|
||||
maxPerQuery: body.maxPerQuery ?? 5,
|
||||
dailyLimit: body.dailyLimit ?? 10,
|
||||
withReview: body.withReview ?? true,
|
||||
queries: body.queries,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { tools, categoryId } = body as {
|
||||
tools: { name: string; description?: string; websiteUrl?: string; logoUrl?: string; pricingModel?: string; pricingDetail?: string; features?: string[]; tags?: string[] }[];
|
||||
categoryId: number;
|
||||
};
|
||||
|
||||
if (!tools || !Array.isArray(tools) || tools.length === 0) {
|
||||
return NextResponse.json({ error: "tools 数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
return NextResponse.json({ error: "categoryId 不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [existingPending, existingPublished] = await Promise.all([
|
||||
prisma.pendingTool.findMany({ select: { name: true, websiteUrl: true } }),
|
||||
prisma.tool.findMany({ select: { name: true, websiteUrl: true } }),
|
||||
]);
|
||||
|
||||
const allExisting = [...existingPending, ...existingPublished];
|
||||
const nameSet = new Set(allExisting.map((t) => t.name));
|
||||
const urlSet = new Set(allExisting.map((t) => t.websiteUrl).filter(Boolean) as string[]);
|
||||
const existingSlugs = await prisma.pendingTool.findMany({ select: { slug: true } });
|
||||
const slugSet = new Set(existingSlugs.map((s) => s.slug));
|
||||
|
||||
const toCreate: { name: string; slug: string; description: string | null; websiteUrl: string | null; logoUrl: string | null; categoryId: number | null; pricingModel: "free" | "freemium" | "paid" | "subscription"; pricingDetail: string | null; features: any; tags: any; sourceUrl: string }[] = [];
|
||||
const results: { name: string; status: string }[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
const name = tool.name || "";
|
||||
if (!name || nameSet.has(name)) {
|
||||
results.push({ name: name || "未知", status: nameSet.has(name) ? "跳过-名称重复" : "跳过-名称为空" });
|
||||
continue;
|
||||
}
|
||||
const websiteUrl = tool.websiteUrl || "";
|
||||
if (websiteUrl && urlSet.has(websiteUrl)) {
|
||||
results.push({ name, status: "跳过-网址重复" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!slug || /^[\u4e00-\u9fff-]+$/.test(slug)) {
|
||||
slug = "tool-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
if (slugSet.has(slug)) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
slugSet.add(slug);
|
||||
|
||||
toCreate.push({
|
||||
name,
|
||||
slug,
|
||||
description: tool.description || null,
|
||||
websiteUrl: websiteUrl || null,
|
||||
logoUrl: tool.logoUrl || null,
|
||||
categoryId,
|
||||
pricingModel: (tool.pricingModel as "free" | "freemium" | "paid" | "subscription") || "freemium",
|
||||
pricingDetail: tool.pricingDetail || null,
|
||||
features: tool.features || null,
|
||||
tags: tool.tags || null,
|
||||
sourceUrl: "AI发现",
|
||||
});
|
||||
|
||||
nameSet.add(name);
|
||||
if (websiteUrl) urlSet.add(websiteUrl);
|
||||
results.push({ name, status: "已导入" });
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.$transaction(
|
||||
toCreate.map((data) =>
|
||||
prisma.pendingTool.create({ data })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: tools.length,
|
||||
imported: results.filter((r) => r.status === "已导入").length,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { batchDownloadLogos } from "@/lib/logo-storage";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const type = body.type || "tools";
|
||||
const concurrency = body.concurrency || 5;
|
||||
|
||||
if (type === "tools") {
|
||||
const tools = await prisma.tool.findMany({
|
||||
where: {
|
||||
logoUrl: { not: null },
|
||||
NOT: { logoUrl: { startsWith: "/logos/" } },
|
||||
},
|
||||
select: { id: true, logoUrl: true, name: true },
|
||||
});
|
||||
|
||||
if (tools.length === 0) {
|
||||
return NextResponse.json({ success: true, total: 0, migrated: 0, failed: 0 });
|
||||
}
|
||||
|
||||
const results = await batchDownloadLogos(
|
||||
tools.map((t) => ({ id: t.id, logoUrl: t.logoUrl!, name: t.name })),
|
||||
concurrency
|
||||
);
|
||||
|
||||
const toUpdate = results.filter((r) => r.localPath.startsWith("/logos/"));
|
||||
const failed = results.length - toUpdate.length;
|
||||
|
||||
if (toUpdate.length > 0) {
|
||||
await prisma.$transaction(
|
||||
toUpdate.map((r) =>
|
||||
prisma.tool.update({
|
||||
where: { id: r.id },
|
||||
data: { logoUrl: r.localPath },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, total: tools.length, migrated: toUpdate.length, failed });
|
||||
} else {
|
||||
const skills = await prisma.skill.findMany({
|
||||
where: {
|
||||
logoUrl: { not: null },
|
||||
NOT: { logoUrl: { startsWith: "/logos/" } },
|
||||
},
|
||||
select: { id: true, logoUrl: true, name: true },
|
||||
});
|
||||
|
||||
if (skills.length === 0) {
|
||||
return NextResponse.json({ success: true, total: 0, migrated: 0, failed: 0 });
|
||||
}
|
||||
|
||||
const results = await batchDownloadLogos(
|
||||
skills.map((s) => ({ id: s.id, logoUrl: s.logoUrl!, name: s.name })),
|
||||
concurrency
|
||||
);
|
||||
|
||||
const toUpdate = results.filter((r) => r.localPath.startsWith("/logos/"));
|
||||
const failed = results.length - toUpdate.length;
|
||||
|
||||
if (toUpdate.length > 0) {
|
||||
await prisma.$transaction(
|
||||
toUpdate.map((r) =>
|
||||
prisma.skill.update({
|
||||
where: { id: r.id },
|
||||
data: { logoUrl: r.localPath },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const pending = await prisma.pendingSkill.findUnique({ where: { id } });
|
||||
if (!pending) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
||||
|
||||
const slug = pending.slug || pending.name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 100);
|
||||
|
||||
const features = Array.isArray(pending.features) ? pending.features as any[] : [];
|
||||
const tags = Array.isArray(pending.tags) ? pending.tags as any[] : [];
|
||||
|
||||
const skill = await prisma.skill.create({
|
||||
data: {
|
||||
name: pending.name,
|
||||
slug,
|
||||
categoryId: pending.categoryId!,
|
||||
description: pending.description || "",
|
||||
logoUrl: pending.logoUrl,
|
||||
sourceUrl: pending.sourceUrl || "",
|
||||
sourceType: pending.sourceType || "github",
|
||||
features,
|
||||
tags,
|
||||
status: "published",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.pendingSkill.delete({ where: { id } });
|
||||
return NextResponse.json({ skill });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const ids: number[] = body.ids;
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: "ids数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pendings = await prisma.pendingSkill.findMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
|
||||
if (pendings.length === 0) {
|
||||
return NextResponse.json({ success: true, total: ids.length, published: 0, skipped: ids.length, details: [] });
|
||||
}
|
||||
|
||||
const existingSkills = await prisma.skill.findMany({
|
||||
where: { name: { in: pendings.map((p) => p.name) } },
|
||||
select: { name: true, sourceUrl: true },
|
||||
});
|
||||
const nameSet = new Set(existingSkills.map((s) => s.name));
|
||||
const urlSet = new Set(existingSkills.map((s) => s.sourceUrl).filter(Boolean) as string[]);
|
||||
|
||||
const toPublish: typeof pendings = [];
|
||||
const results: { id: number; name: string; status: string }[] = [];
|
||||
const skipIds: number[] = [];
|
||||
|
||||
for (const pending of pendings) {
|
||||
if (nameSet.has(pending.name)) {
|
||||
skipIds.push(pending.id);
|
||||
results.push({ id: pending.id, name: pending.name, status: "跳过-已存在同名技能" });
|
||||
continue;
|
||||
}
|
||||
if (pending.sourceUrl && urlSet.has(pending.sourceUrl)) {
|
||||
skipIds.push(pending.id);
|
||||
results.push({ id: pending.id, name: pending.name, status: "跳过-已存在同地址技能" });
|
||||
continue;
|
||||
}
|
||||
toPublish.push(pending);
|
||||
}
|
||||
|
||||
if (skipIds.length > 0) {
|
||||
await prisma.pendingSkill.deleteMany({ where: { id: { in: skipIds } } });
|
||||
}
|
||||
|
||||
if (toPublish.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: ids.length,
|
||||
published: 0,
|
||||
skipped: results.length,
|
||||
details: results,
|
||||
});
|
||||
}
|
||||
|
||||
const candidateSlugs = toPublish.map((p) => {
|
||||
let slug = p.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!slug || /^[\u4e00-\u9fff-]+$/.test(slug)) {
|
||||
slug = "skill-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
return slug;
|
||||
});
|
||||
|
||||
const slugConflicts = new Set(
|
||||
(await prisma.skill.findMany({
|
||||
where: { slug: { in: candidateSlugs } },
|
||||
select: { slug: true },
|
||||
})).map((s) => s.slug)
|
||||
);
|
||||
|
||||
const skillsData = toPublish.map((pending, i) => {
|
||||
let slug = candidateSlugs[i];
|
||||
if (slugConflicts.has(slug)) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
|
||||
return {
|
||||
name: pending.name,
|
||||
slug,
|
||||
description: pending.description || "",
|
||||
logoUrl: pending.logoUrl,
|
||||
sourceUrl: pending.sourceUrl || "",
|
||||
sourceType: pending.sourceType || "github",
|
||||
categoryId: pending.categoryId || 1,
|
||||
rating: pending.rating || 0,
|
||||
features: pending.features ?? undefined,
|
||||
tags: pending.tags ?? undefined,
|
||||
status: "published" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const published = skillsData.length;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.skill.createMany({ data: skillsData });
|
||||
await tx.pendingSkill.deleteMany({
|
||||
where: { id: { in: toPublish.map((p) => p.id) } },
|
||||
});
|
||||
});
|
||||
|
||||
for (const pending of toPublish) {
|
||||
results.push({ id: pending.id, name: pending.name, status: "已发布" });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: ids.length,
|
||||
published,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const ids: number[] = body.ids;
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: "ids数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await prisma.pendingSkill.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: ids.length,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const skills: {
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceUrl?: string;
|
||||
sourceType?: string;
|
||||
categoryId?: number;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
rating?: number;
|
||||
}[] = body.skills;
|
||||
|
||||
if (!Array.isArray(skills) || skills.length === 0) {
|
||||
return NextResponse.json({ error: "skills数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [existingPending, existingPublished, existingSlugs] = await Promise.all([
|
||||
prisma.pendingSkill.findMany({ select: { name: true, sourceUrl: true } }),
|
||||
prisma.skill.findMany({ select: { name: true, sourceUrl: true } }),
|
||||
prisma.pendingSkill.findMany({ select: { slug: true } }),
|
||||
]);
|
||||
|
||||
const allExisting = [...existingPending, ...existingPublished];
|
||||
const nameSet = new Set(allExisting.map((s) => s.name));
|
||||
const urlSet = new Set(allExisting.map((s) => s.sourceUrl).filter(Boolean) as string[]);
|
||||
const slugSet = new Set(existingSlugs.map((s) => s.slug));
|
||||
|
||||
const toCreate: { name: string; slug: string; description: string | null; sourceUrl: string | null; sourceType: string; categoryId: number | null; features: any; tags: any; rating: number }[] = [];
|
||||
const results: { name: string; status: string }[] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
if (!skill.name || nameSet.has(skill.name)) {
|
||||
results.push({ name: skill.name || "未知", status: "跳过-名称重复" });
|
||||
continue;
|
||||
}
|
||||
if (skill.sourceUrl && urlSet.has(skill.sourceUrl)) {
|
||||
results.push({ name: skill.name, status: "跳过-地址重复" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let slug = skill.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!slug || /^[\u4e00-\u9fff-]+$/.test(slug)) {
|
||||
slug = "skill-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
if (slugSet.has(slug)) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
slugSet.add(slug);
|
||||
|
||||
toCreate.push({
|
||||
name: skill.name,
|
||||
slug,
|
||||
description: skill.description || null,
|
||||
sourceUrl: skill.sourceUrl || null,
|
||||
sourceType: skill.sourceType || "github",
|
||||
categoryId: skill.categoryId || null,
|
||||
features: skill.features ? (skill.features as any) : null,
|
||||
tags: skill.tags ? (skill.tags as any) : null,
|
||||
rating: skill.rating || 0,
|
||||
});
|
||||
|
||||
nameSet.add(skill.name);
|
||||
if (skill.sourceUrl) urlSet.add(skill.sourceUrl);
|
||||
results.push({ name: skill.name, status: "已导入" });
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.$transaction(
|
||||
toCreate.map((data) =>
|
||||
prisma.pendingSkill.create({ data })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: skills.length,
|
||||
imported: results.filter((r) => r.status === "已导入").length,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST() {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const pendingSkills = await prisma.pendingSkill.findMany({
|
||||
where: { status: "pending" },
|
||||
});
|
||||
|
||||
if (pendingSkills.length === 0) {
|
||||
return NextResponse.json({ success: true, total: 0, published: 0, skipped: 0, details: [] });
|
||||
}
|
||||
|
||||
const existingSkills = await prisma.skill.findMany({
|
||||
select: { name: true, sourceUrl: true, slug: true },
|
||||
});
|
||||
const nameSet = new Set(existingSkills.map((s) => s.name));
|
||||
const urlSet = new Set(existingSkills.map((s) => s.sourceUrl).filter(Boolean) as string[]);
|
||||
const slugSet = new Set(existingSkills.map((s) => s.slug));
|
||||
|
||||
const toPublish: typeof pendingSkills = [];
|
||||
const skipIds: number[] = [];
|
||||
const details: { id: number; name: string; status: string }[] = [];
|
||||
|
||||
for (const pending of pendingSkills) {
|
||||
if (nameSet.has(pending.name) || (pending.sourceUrl && urlSet.has(pending.sourceUrl))) {
|
||||
skipIds.push(pending.id);
|
||||
details.push({ id: pending.id, name: pending.name, status: "跳过-已存在" });
|
||||
continue;
|
||||
}
|
||||
toPublish.push(pending);
|
||||
}
|
||||
|
||||
if (skipIds.length > 0) {
|
||||
await prisma.pendingSkill.deleteMany({ where: { id: { in: skipIds } } });
|
||||
}
|
||||
|
||||
let published = 0;
|
||||
|
||||
if (toPublish.length > 0) {
|
||||
const skillsData = toPublish.map((pending) => {
|
||||
let slug = pending.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!slug || /^[\u4e00-\u9fff-]+$/.test(slug)) {
|
||||
slug = "skill-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
if (slugSet.has(slug)) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
slugSet.add(slug);
|
||||
|
||||
return {
|
||||
name: pending.name,
|
||||
slug,
|
||||
description: pending.description || "",
|
||||
logoUrl: pending.logoUrl,
|
||||
sourceUrl: pending.sourceUrl || "",
|
||||
sourceType: pending.sourceType || "github",
|
||||
categoryId: pending.categoryId || 1,
|
||||
rating: pending.rating || 0,
|
||||
features: pending.features ?? undefined,
|
||||
tags: pending.tags ?? undefined,
|
||||
status: "published" as const,
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const data of skillsData) {
|
||||
await tx.skill.create({ data });
|
||||
published++;
|
||||
}
|
||||
await tx.pendingSkill.deleteMany({
|
||||
where: { id: { in: toPublish.map((p) => p.id) } },
|
||||
});
|
||||
});
|
||||
|
||||
for (const pending of toPublish) {
|
||||
details.push({ id: pending.id, name: pending.name, status: "已发布" });
|
||||
nameSet.add(pending.name);
|
||||
if (pending.sourceUrl) urlSet.add(pending.sourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: pendingSkills.length,
|
||||
published,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20");
|
||||
|
||||
const [skills, total] = await Promise.all([
|
||||
prisma.pendingSkill.findMany({
|
||||
where: { status: "pending" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.pendingSkill.count({ where: { status: "pending" } }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ skills, total, page, pageSize });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const result = await prisma.pendingSkill.deleteMany({});
|
||||
return NextResponse.json({ success: true, deleted: result.count });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
|| "tool";
|
||||
}
|
||||
|
||||
function pinyinSlug(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"文本生成": "text-generation", "图像生成": "image-generation",
|
||||
"视频生成": "video-generation", "音频处理": "audio-processing",
|
||||
"代码助手": "code-assistant", "办公效率": "office-efficiency",
|
||||
"AI 角色": "ai-character", "设计工具": "design-tools",
|
||||
"数据分析": "data-analysis", "内容检测": "content-detection",
|
||||
};
|
||||
if (map[name]) return map[name];
|
||||
const slug = generateSlug(name);
|
||||
if (/^[a-z0-9-]+$/.test(slug)) return slug;
|
||||
return "tool-" + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
const pending = await prisma.pendingTool.findUnique({ where: { id } });
|
||||
if (!pending) {
|
||||
return NextResponse.json({ error: "待审核工具不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
let slug = pinyinSlug(pending.name);
|
||||
const existing = await prisma.tool.findUnique({ where: { slug } });
|
||||
if (existing) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
|
||||
const categoryId = pending.categoryId ?? ((
|
||||
await prisma.category.findFirst({ orderBy: { sortOrder: "asc" } })
|
||||
)?.id ?? 1);
|
||||
|
||||
const tool = await prisma.tool.create({
|
||||
data: {
|
||||
name: pending.name,
|
||||
slug,
|
||||
categoryId,
|
||||
description: pending.description ?? "",
|
||||
logoUrl: pending.logoUrl,
|
||||
websiteUrl: pending.websiteUrl ?? "",
|
||||
pricingModel: pending.pricingModel ?? "freemium",
|
||||
pricingDetail: pending.pricingDetail,
|
||||
features: pending.features as any,
|
||||
tags: pending.tags as any,
|
||||
status: "published",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.pendingTool.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
toolId: tool.id,
|
||||
message: "发布成功",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { fetchLogo } from "@/lib/logo-fetcher";
|
||||
import { downloadLogo } from "@/lib/logo-storage";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
|
||||
const current = await prisma.pendingTool.findUnique({ where: { id } });
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: "工具不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const newWebsiteUrl = body.websiteUrl !== undefined ? body.websiteUrl : current.websiteUrl;
|
||||
let logoUrl = body.logoUrl !== undefined ? body.logoUrl : current.logoUrl;
|
||||
|
||||
const websiteChanged = body.websiteUrl !== undefined && body.websiteUrl !== current.websiteUrl;
|
||||
const logoEmpty = !logoUrl;
|
||||
|
||||
if (newWebsiteUrl && (websiteChanged || logoEmpty)) {
|
||||
let remoteLogoUrl = "";
|
||||
try {
|
||||
const htmlRes = await fetch(newWebsiteUrl, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
Accept: "text/html",
|
||||
},
|
||||
});
|
||||
if (htmlRes.ok) {
|
||||
const html = await htmlRes.text();
|
||||
remoteLogoUrl = await fetchLogo(newWebsiteUrl, html);
|
||||
}
|
||||
} catch { /* */ }
|
||||
|
||||
if (!remoteLogoUrl) {
|
||||
remoteLogoUrl = await fetchLogo(newWebsiteUrl);
|
||||
}
|
||||
|
||||
if (remoteLogoUrl) {
|
||||
logoUrl = await downloadLogo(remoteLogoUrl, (body.name || current.name).replace(/[^a-z0-9]/gi, "_").substring(0, 20));
|
||||
}
|
||||
} else if (logoUrl && !logoUrl.startsWith("/logos/") && !logoUrl.startsWith("data:")) {
|
||||
logoUrl = await downloadLogo(logoUrl, (body.name || current.name).replace(/[^a-z0-9]/gi, "_").substring(0, 20));
|
||||
}
|
||||
|
||||
const updated = await prisma.pendingTool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.description !== undefined && { description: body.description }),
|
||||
...(body.websiteUrl !== undefined && { websiteUrl: body.websiteUrl }),
|
||||
logoUrl: logoUrl || null,
|
||||
...(body.categoryId !== undefined && { categoryId: body.categoryId }),
|
||||
...(body.pricingModel !== undefined && { pricingModel: body.pricingModel }),
|
||||
...(body.pricingDetail !== undefined && { pricingDetail: body.pricingDetail }),
|
||||
...(body.features !== undefined && { features: body.features }),
|
||||
...(body.tags !== undefined && { tags: body.tags }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ tool: updated });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const ids: number[] = body.ids;
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: "ids数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pendings = await prisma.pendingTool.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: { category: { select: { id: true } } },
|
||||
});
|
||||
|
||||
if (pendings.length === 0) {
|
||||
return NextResponse.json({ success: true, total: ids.length, published: 0, skipped: ids.length, details: [] });
|
||||
}
|
||||
|
||||
const existingNames = await prisma.tool.findMany({
|
||||
where: { name: { in: pendings.map((p) => p.name) } },
|
||||
select: { name: true },
|
||||
});
|
||||
const nameSet = new Set(existingNames.map((t) => t.name));
|
||||
|
||||
const existingUrls = pendings.filter((p) => p.websiteUrl).map((p) => p.websiteUrl!);
|
||||
let urlSet = new Set<string>();
|
||||
if (existingUrls.length > 0) {
|
||||
const urlMatches = await prisma.tool.findMany({
|
||||
where: { websiteUrl: { in: existingUrls } },
|
||||
select: { websiteUrl: true },
|
||||
});
|
||||
urlSet = new Set(urlMatches.map((t) => t.websiteUrl));
|
||||
}
|
||||
|
||||
const toPublish: typeof pendings = [];
|
||||
const results: { id: number; name: string; status: string }[] = [];
|
||||
const skipIds: number[] = [];
|
||||
|
||||
for (const pending of pendings) {
|
||||
if (nameSet.has(pending.name)) {
|
||||
skipIds.push(pending.id);
|
||||
results.push({ id: pending.id, name: pending.name, status: "跳过-已存在同名工具" });
|
||||
continue;
|
||||
}
|
||||
if (pending.websiteUrl && urlSet.has(pending.websiteUrl)) {
|
||||
skipIds.push(pending.id);
|
||||
results.push({ id: pending.id, name: pending.name, status: "跳过-已存在同网址工具" });
|
||||
continue;
|
||||
}
|
||||
toPublish.push(pending);
|
||||
}
|
||||
|
||||
if (skipIds.length > 0) {
|
||||
await prisma.pendingTool.deleteMany({ where: { id: { in: skipIds } } });
|
||||
}
|
||||
|
||||
if (toPublish.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: ids.length,
|
||||
published: 0,
|
||||
skipped: results.length,
|
||||
details: results,
|
||||
});
|
||||
}
|
||||
|
||||
const candidateSlugs = toPublish.map((p) => {
|
||||
let slug = p.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!slug || /^[\u4e00-\u9fff-]+$/.test(slug)) {
|
||||
slug = "tool-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
return slug;
|
||||
});
|
||||
|
||||
const slugConflicts = new Set(
|
||||
(await prisma.tool.findMany({
|
||||
where: { slug: { in: candidateSlugs } },
|
||||
select: { slug: true },
|
||||
})).map((t) => t.slug)
|
||||
);
|
||||
|
||||
const toolsData = toPublish.map((pending, i) => {
|
||||
let slug = candidateSlugs[i];
|
||||
if (slugConflicts.has(slug)) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
|
||||
return {
|
||||
name: pending.name,
|
||||
slug,
|
||||
description: pending.description || "",
|
||||
websiteUrl: pending.websiteUrl || "",
|
||||
logoUrl: pending.logoUrl,
|
||||
categoryId: pending.categoryId || 1,
|
||||
pricingModel: (pending.pricingModel || "freemium") as "free" | "freemium" | "paid" | "subscription",
|
||||
pricingDetail: pending.pricingDetail,
|
||||
features: pending.features ?? undefined,
|
||||
tags: pending.tags ?? undefined,
|
||||
status: "published" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const published = toolsData.length;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.tool.createMany({ data: toolsData });
|
||||
await tx.pendingTool.deleteMany({
|
||||
where: { id: { in: toPublish.map((p) => p.id) } },
|
||||
});
|
||||
});
|
||||
|
||||
for (const pending of toPublish) {
|
||||
results.push({ id: pending.id, name: pending.name, status: "已发布" });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: ids.length,
|
||||
published,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const ids: number[] = body.ids;
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: "ids数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await prisma.pendingTool.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: ids.length,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const tools: {
|
||||
name: string;
|
||||
description?: string;
|
||||
websiteUrl?: string;
|
||||
logoUrl?: string;
|
||||
categoryId?: number;
|
||||
pricingModel?: string;
|
||||
pricingDetail?: string;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
}[] = body.tools;
|
||||
|
||||
if (!Array.isArray(tools) || tools.length === 0) {
|
||||
return NextResponse.json({ error: "tools数组不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [existingPending, existingPublished, existingSlugs] = await Promise.all([
|
||||
prisma.pendingTool.findMany({ select: { name: true, websiteUrl: true } }),
|
||||
prisma.tool.findMany({ select: { name: true, websiteUrl: true } }),
|
||||
prisma.pendingTool.findMany({ select: { slug: true } }),
|
||||
]);
|
||||
|
||||
const allExisting = [...existingPending, ...existingPublished];
|
||||
const nameSet = new Set(allExisting.map((t) => t.name));
|
||||
const urlSet = new Set(allExisting.map((t) => t.websiteUrl).filter(Boolean) as string[]);
|
||||
const slugSet = new Set(existingSlugs.map((s) => s.slug));
|
||||
|
||||
const toCreate: { name: string; slug: string; description: string | null; websiteUrl: string | null; logoUrl: string | null; categoryId: number | null; pricingModel: "free" | "freemium" | "paid" | "subscription"; pricingDetail: string | null; features: any; tags: any; sourceUrl: string }[] = [];
|
||||
const results: { name: string; status: string; logo?: string }[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!tool.name || nameSet.has(tool.name)) {
|
||||
results.push({ name: tool.name || "未知", status: "跳过-名称重复" });
|
||||
continue;
|
||||
}
|
||||
if (tool.websiteUrl && urlSet.has(tool.websiteUrl)) {
|
||||
results.push({ name: tool.name, status: "跳过-网址重复" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let slug = tool.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!slug || /^[\u4e00-\u9fff-]+$/.test(slug)) {
|
||||
slug = "tool-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
if (slugSet.has(slug)) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
slugSet.add(slug);
|
||||
|
||||
toCreate.push({
|
||||
name: tool.name,
|
||||
slug,
|
||||
description: tool.description || null,
|
||||
websiteUrl: tool.websiteUrl || null,
|
||||
logoUrl: tool.logoUrl || null,
|
||||
categoryId: tool.categoryId || null,
|
||||
pricingModel: (tool.pricingModel || "freemium") as "free" | "freemium" | "paid" | "subscription",
|
||||
pricingDetail: tool.pricingDetail || null,
|
||||
features: tool.features ? tool.features as any : null,
|
||||
tags: tool.tags ? tool.tags as any : null,
|
||||
sourceUrl: "AI发现",
|
||||
});
|
||||
|
||||
nameSet.add(tool.name);
|
||||
if (tool.websiteUrl) urlSet.add(tool.websiteUrl);
|
||||
results.push({
|
||||
name: tool.name,
|
||||
status: "已导入",
|
||||
logo: tool.logoUrl || "待抓取",
|
||||
});
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.$transaction(
|
||||
toCreate.map((data) =>
|
||||
prisma.pendingTool.create({ data })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: tools.length,
|
||||
imported: results.filter((r) => r.status === "已导入").length,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { fetchLogo } from "@/lib/logo-fetcher";
|
||||
import { downloadLogo } from "@/lib/logo-storage";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
const CONCURRENCY_LIMIT = 5;
|
||||
|
||||
async function processWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
const executing: Promise<void>[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const p = fn(item).then((result) => {
|
||||
results.push(result);
|
||||
});
|
||||
executing.push(p);
|
||||
|
||||
if (executing.length >= limit) {
|
||||
await Promise.race(executing);
|
||||
for (let i = executing.length - 1; i >= 0; i--) {
|
||||
const settled = await Promise.race([
|
||||
executing[i].then(() => true, () => true),
|
||||
Promise.resolve(false),
|
||||
]);
|
||||
if (settled) executing.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(executing);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const refetch = body.refetch === true;
|
||||
const scope = body.scope || "pending";
|
||||
const toolIds = body.toolIds as number[] | undefined;
|
||||
|
||||
const logoFilter = refetch
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ logoUrl: null },
|
||||
{ logoUrl: "" },
|
||||
{ logoUrl: { not: { startsWith: "/logos/" } } },
|
||||
],
|
||||
};
|
||||
|
||||
let tools: { id: number; name: string; websiteUrl: string | null; logoUrl: string | null; model: string }[] = [];
|
||||
|
||||
if (scope === "published" || scope === "all") {
|
||||
const publishedTools = await prisma.tool.findMany({
|
||||
where: { ...logoFilter, ...(toolIds ? { id: { in: toolIds } } : {}) },
|
||||
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
|
||||
});
|
||||
tools.push(...publishedTools.map((t) => ({ ...t, model: "tool" })));
|
||||
}
|
||||
|
||||
if (scope === "pending" || scope === "all") {
|
||||
const pendingTools = await prisma.pendingTool.findMany({
|
||||
where: { websiteUrl: { not: null }, ...logoFilter, ...(toolIds ? { id: { in: toolIds } } : {}) },
|
||||
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
|
||||
});
|
||||
tools.push(...pendingTools.map((t) => ({ ...t, model: "pendingTool" })));
|
||||
}
|
||||
|
||||
const total = tools.length;
|
||||
let fetched = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const details: { id: number; name: string; model: string; logoUrl: string; status: string }[] = [];
|
||||
|
||||
await processWithConcurrency(tools, CONCURRENCY_LIMIT, async (tool) => {
|
||||
try {
|
||||
if (!tool.websiteUrl) {
|
||||
details.push({ id: tool.id, name: tool.name, model: tool.model, logoUrl: "", status: "跳过-无网址" });
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
let remoteLogoUrl = "";
|
||||
|
||||
try {
|
||||
const htmlRes = await fetch(tool.websiteUrl, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
},
|
||||
redirect: "follow",
|
||||
});
|
||||
if (htmlRes.ok) {
|
||||
const html = await htmlRes.text();
|
||||
remoteLogoUrl = await fetchLogo(tool.websiteUrl, html);
|
||||
}
|
||||
} catch { /* */ }
|
||||
|
||||
if (!remoteLogoUrl) {
|
||||
remoteLogoUrl = await fetchLogo(tool.websiteUrl);
|
||||
}
|
||||
|
||||
if (remoteLogoUrl) {
|
||||
const localPath = await downloadLogo(remoteLogoUrl, tool.name.replace(/[^a-z0-9]/gi, "_").substring(0, 20));
|
||||
|
||||
if (tool.model === "tool") {
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { logoUrl: localPath },
|
||||
});
|
||||
} else {
|
||||
await prisma.pendingTool.update({
|
||||
where: { id: tool.id },
|
||||
data: { logoUrl: localPath },
|
||||
});
|
||||
}
|
||||
fetched++;
|
||||
details.push({ id: tool.id, name: tool.name, model: tool.model, logoUrl: localPath, status: "成功" });
|
||||
} else {
|
||||
failed++;
|
||||
details.push({ id: tool.id, name: tool.name, model: tool.model, logoUrl: tool.logoUrl || "", status: "失败" });
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
details.push({ id: tool.id, name: tool.name, model: tool.model, logoUrl: "", status: "异常" });
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
fetched,
|
||||
failed,
|
||||
skipped,
|
||||
details,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20");
|
||||
|
||||
const [tools, total] = await Promise.all([
|
||||
prisma.pendingTool.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { category: { select: { id: true, name: true } } },
|
||||
}),
|
||||
prisma.pendingTool.count(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ tools, total, page, pageSize });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const permissions = await prisma.permission.findMany({
|
||||
orderBy: [{ groupName: "asc" }, { sortOrder: "asc" }],
|
||||
});
|
||||
|
||||
return NextResponse.json(permissions);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
});
|
||||
|
||||
const LONG_ARTICLE_PROMPT = `你是一个技术内容创作者。请基于以下技能评测数据,生成一篇适合微信公众号发布的深度评测长文。
|
||||
|
||||
评测数据:
|
||||
{reviewData}
|
||||
|
||||
要求:
|
||||
1. 标题吸引眼球,包含技能名称和核心亮点
|
||||
2. 开头用场景引入,让读者产生共鸣
|
||||
3. 正文分5个段落,对应5个评测维度,每段包含具体数据和案例
|
||||
4. 结尾给出推荐指数和适用人群
|
||||
5. 全文1500-2500字
|
||||
6. 语气专业但不枯燥,适合技术管理者阅读
|
||||
7. 使用Markdown格式,包含标题、小标题、加粗重点`;
|
||||
|
||||
const VIDEO_SCRIPT_PROMPT = `你是一个短视频脚本创作者。请基于以下技能评测数据,生成一段60秒短视频脚本。
|
||||
|
||||
评测数据:
|
||||
{reviewData}
|
||||
|
||||
要求:
|
||||
1. 开头3秒用痛点/悬念抓住注意力
|
||||
2. 中间40秒介绍核心功能和亮点
|
||||
3. 结尾10秒给出推荐和行动号召
|
||||
4. 口语化表达,适合B站/抖音技术区
|
||||
5. 包含画面描述和旁白文字
|
||||
6. 使用Markdown格式,分[画面]和[旁白]标注`;
|
||||
|
||||
const SOCIAL_CARD_PROMPT = `你是一个社交媒体运营专家。请基于以下技能评测数据,生成3张社交卡片的文案。
|
||||
|
||||
评测数据:
|
||||
{reviewData}
|
||||
|
||||
要求:
|
||||
1. 卡片1: 痛点引入卡 - 用一个常见痛点+技能名称
|
||||
2. 卡片2: 核心亮点卡 - 用3个关键数据点
|
||||
3. 卡片3: 推荐总结卡 - 综合评分+适用人群
|
||||
4. 每张卡片文案不超过50字
|
||||
5. 适合小红书/即刻/微博发布
|
||||
6. 以JSON格式返回: {"cards": [{"title": "...", "body": "..."}, ...]}`;
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const reviewId = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
const type = body.type as "longArticle" | "videoScript" | "socialCards";
|
||||
|
||||
if (!["longArticle", "videoScript", "socialCards"].includes(type)) {
|
||||
return NextResponse.json({ error: "无效的生成类型" }, { status: 400 });
|
||||
}
|
||||
|
||||
const review = await prisma.skillReview.findUnique({
|
||||
where: { id: reviewId },
|
||||
include: {
|
||||
skill: {
|
||||
select: { name: true, description: true, sourceUrl: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!review) {
|
||||
return NextResponse.json({ error: "评测不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const reviewData = JSON.stringify({
|
||||
name: review.skill.name,
|
||||
description: review.skill.description,
|
||||
sourceUrl: review.skill.sourceUrl,
|
||||
capability: review.capability,
|
||||
devExp: review.devExp,
|
||||
costLicense: review.costLicense,
|
||||
community: review.community,
|
||||
performance: review.performance,
|
||||
overall: review.overall,
|
||||
reviews: review.reviews,
|
||||
});
|
||||
|
||||
const promptMap = {
|
||||
longArticle: LONG_ARTICLE_PROMPT,
|
||||
videoScript: VIDEO_SCRIPT_PROMPT,
|
||||
socialCards: SOCIAL_CARD_PROMPT,
|
||||
};
|
||||
|
||||
const prompt = promptMap[type].replace("{reviewData}", reviewData);
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "deepseek-v4-pro",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const content = completion.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: "生成内容为空" }, { status: 500 });
|
||||
}
|
||||
|
||||
let updateData: Record<string, unknown>;
|
||||
if (type === "longArticle") {
|
||||
updateData = { longArticle: content };
|
||||
} else if (type === "videoScript") {
|
||||
updateData = { videoScript: content };
|
||||
} else {
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
updateData = { socialCards: jsonMatch ? JSON.parse(jsonMatch[0]) : { raw: content } };
|
||||
}
|
||||
|
||||
await prisma.skillReview.update({
|
||||
where: { id: reviewId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await prisma.reviewGenerationLog.create({
|
||||
data: {
|
||||
skillId: review.skillId,
|
||||
status: "SUCCESS",
|
||||
prompt: `content-generate:${type}`,
|
||||
response: content.substring(0, 4000),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, type, content });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
try {
|
||||
await prisma.reviewGenerationLog.create({
|
||||
data: {
|
||||
skillId: parseInt(params.id),
|
||||
status: "FAILED",
|
||||
prompt: "content-generate",
|
||||
error: (err.message || "Unknown error").substring(0, 500),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// 日志写入失败不阻塞
|
||||
}
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const review = await prisma.skillReview.findUnique({
|
||||
where: { id: parseInt(params.id) },
|
||||
include: {
|
||||
skill: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
sourceUrl: true,
|
||||
description: true,
|
||||
features: true,
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!review) {
|
||||
return NextResponse.json({ error: "评测不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const serialized = {
|
||||
...review,
|
||||
reviewedAt: review.reviewedAt.toISOString(),
|
||||
createdAt: review.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(serialized);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const reviewId = parseInt(params.id);
|
||||
|
||||
const existing = await prisma.skillReview.findUnique({
|
||||
where: { id: reviewId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "评测不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (body.action === "publish") {
|
||||
await prisma.$transaction([
|
||||
prisma.skillReview.update({
|
||||
where: { id: reviewId },
|
||||
data: { status: "PUBLISHED" },
|
||||
}),
|
||||
prisma.skill.update({
|
||||
where: { id: existing.skillId },
|
||||
data: { status: "published" },
|
||||
}),
|
||||
]);
|
||||
return NextResponse.json({ success: true, action: "publish" });
|
||||
}
|
||||
|
||||
if (body.action === "reject") {
|
||||
await prisma.skillReview.update({
|
||||
where: { id: reviewId },
|
||||
data: { status: "REJECTED" },
|
||||
});
|
||||
return NextResponse.json({ success: true, action: "reject" });
|
||||
}
|
||||
|
||||
if (body.action === "unpublish") {
|
||||
await prisma.$transaction([
|
||||
prisma.skillReview.update({
|
||||
where: { id: reviewId },
|
||||
data: { status: "DRAFT" },
|
||||
}),
|
||||
prisma.skill.update({
|
||||
where: { id: existing.skillId },
|
||||
data: { status: "draft" },
|
||||
}),
|
||||
]);
|
||||
return NextResponse.json({ success: true, action: "unpublish" });
|
||||
}
|
||||
|
||||
if (body.action === "updateScores") {
|
||||
const { capability, devExp, costLicense, community, performance, overall, reviews } = body;
|
||||
await prisma.skillReview.update({
|
||||
where: { id: reviewId },
|
||||
data: {
|
||||
capability,
|
||||
devExp,
|
||||
costLicense,
|
||||
community,
|
||||
performance,
|
||||
overall,
|
||||
reviews: reviews || existing.reviews,
|
||||
},
|
||||
});
|
||||
await prisma.skill.update({
|
||||
where: { id: existing.skillId },
|
||||
data: { rating: overall },
|
||||
});
|
||||
return NextResponse.json({ success: true, action: "updateScores" });
|
||||
}
|
||||
|
||||
if (body.action === "regenerate") {
|
||||
await prisma.reviewGenerationLog.create({
|
||||
data: {
|
||||
skillId: existing.skillId,
|
||||
status: "PENDING",
|
||||
prompt: `重新评测: skillId=${existing.skillId}`,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: "regenerate",
|
||||
message: "已记录重评请求,请通过脚本执行重新评测",
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "未知操作" }, { status: 400 });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const reviewId = parseInt(params.id);
|
||||
|
||||
const existing = await prisma.skillReview.findUnique({
|
||||
where: { id: reviewId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "评测不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.skillReview.delete({
|
||||
where: { id: reviewId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20");
|
||||
const status = searchParams.get("status") || undefined;
|
||||
|
||||
const where = status ? { status: status as "DRAFT" | "PUBLISHED" | "REJECTED" | "OUTDATED" } : {};
|
||||
|
||||
const [reviews, total, globalStats] = await Promise.all([
|
||||
prisma.skillReview.findMany({
|
||||
where,
|
||||
include: {
|
||||
skill: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
sourceUrl: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.skillReview.count({ where }),
|
||||
prisma.skillReview.groupBy({
|
||||
by: ["status"],
|
||||
_count: { status: true },
|
||||
_avg: { overall: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const stats = {
|
||||
total: await prisma.skillReview.count(),
|
||||
published: 0,
|
||||
draft: 0,
|
||||
rejected: 0,
|
||||
outdated: 0,
|
||||
overallAvg: 0,
|
||||
};
|
||||
|
||||
let avgSum = 0;
|
||||
let avgCount = 0;
|
||||
for (const g of globalStats) {
|
||||
if (g.status === "PUBLISHED") stats.published = g._count.status;
|
||||
if (g.status === "DRAFT") stats.draft = g._count.status;
|
||||
if (g.status === "REJECTED") stats.rejected = g._count.status;
|
||||
if (g.status === "OUTDATED") stats.outdated = g._count.status;
|
||||
if (g._avg.overall !== null) {
|
||||
avgSum += g._avg.overall * g._count.status;
|
||||
avgCount += g._count.status;
|
||||
}
|
||||
}
|
||||
stats.overallAvg = avgCount > 0 ? Math.round((avgSum / avgCount) * 10) / 10 : 0;
|
||||
|
||||
const serialized = reviews.map((r) => ({
|
||||
id: r.id,
|
||||
skillId: r.skill.id,
|
||||
skillName: r.skill.name,
|
||||
slug: r.skill.slug,
|
||||
sourceUrl: r.skill.sourceUrl,
|
||||
description: r.skill.description,
|
||||
overall: r.overall,
|
||||
capability: r.capability,
|
||||
devExp: r.devExp,
|
||||
costLicense: r.costLicense,
|
||||
community: r.community,
|
||||
performance: r.performance,
|
||||
reviews: r.reviews,
|
||||
longArticle: r.longArticle,
|
||||
videoScript: r.videoScript,
|
||||
socialCards: r.socialCards,
|
||||
status: r.status,
|
||||
reviewedAt: r.reviewedAt.toISOString(),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const roleId = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
const { name, permissionIds } = body;
|
||||
|
||||
const role = await prisma.role.findUnique({ where: { id: roleId } });
|
||||
if (!role) {
|
||||
return NextResponse.json({ error: "角色不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (name) {
|
||||
await prisma.role.update({ where: { id: roleId }, data: { name } });
|
||||
}
|
||||
|
||||
if (Array.isArray(permissionIds)) {
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId } });
|
||||
for (const pid of permissionIds) {
|
||||
await prisma.rolePermission.create({
|
||||
data: { roleId, permissionId: pid },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.role.findUnique({
|
||||
where: { id: roleId },
|
||||
include: {
|
||||
permissions: { include: { permission: true } },
|
||||
_count: { select: { users: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: updated!.id,
|
||||
name: updated!.name,
|
||||
slug: updated!.slug,
|
||||
isSystem: updated!.isSystem,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const roleId = parseInt(params.id);
|
||||
|
||||
const role = await prisma.role.findUnique({
|
||||
where: { id: roleId },
|
||||
include: { _count: { select: { users: true } } },
|
||||
});
|
||||
if (!role) {
|
||||
return NextResponse.json({ error: "角色不存在" }, { status: 404 });
|
||||
}
|
||||
if (role.isSystem) {
|
||||
return NextResponse.json({ error: "系统内置角色不能删除" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (role._count.users > 0) {
|
||||
return NextResponse.json({ error: `该角色下还有 ${role._count.users} 个用户,请先移出` }, { status: 409 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const roles = await prisma.role.findMany({
|
||||
include: {
|
||||
permissions: {
|
||||
include: { permission: true },
|
||||
},
|
||||
_count: { select: { users: true } },
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
const formatted = roles.map((r: { id: number; name: string; slug: string; isSystem: boolean; createdAt: Date; updatedAt: Date; permissions: { permissionId: number }[]; _count: { users: number } }) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
isSystem: r.isSystem,
|
||||
userCount: r._count.users,
|
||||
permissionIds: r.permissions.map((rp: { permissionId: number }) => rp.permissionId),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}));
|
||||
|
||||
return NextResponse.json(formatted);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, slug, permissionIds } = body;
|
||||
|
||||
if (!name || !slug) {
|
||||
return NextResponse.json({ error: "角色名称和标识不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.role.findUnique({ where: { slug } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "角色标识已存在" }, { status: 409 });
|
||||
}
|
||||
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
isSystem: false,
|
||||
permissions: {
|
||||
create: (permissionIds || []).map((pid: number) => ({
|
||||
permissionId: pid,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
permissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
slug: role.slug,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) data.name = body.name.trim();
|
||||
if (body.slug !== undefined) data.slug = body.slug.trim();
|
||||
if (body.icon !== undefined) data.icon = body.icon || null;
|
||||
if (body.parentId !== undefined) data.parentId = body.parentId || null;
|
||||
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
const category = await prisma.skillCategory.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { skills: true, children: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return NextResponse.json({ error: "分类不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (category._count.skills > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `该分类下有 ${category._count.skills} 个关联技能,无法删除` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (category._count.children > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `该分类下有 ${category._count.children} 个子分类,无法删除` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET() {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const categories = await prisma.skillCategory.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: { select: { skills: true, children: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
icon: c.icon,
|
||||
parentId: c.parentId,
|
||||
sortOrder: c.sortOrder,
|
||||
skillCount: c._count.skills,
|
||||
childCount: c._count.children,
|
||||
createdAt: c.createdAt,
|
||||
}))
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, slug, icon, parentId, sortOrder } = body;
|
||||
|
||||
if (!name || !slug) {
|
||||
return NextResponse.json({ error: "名称和Slug必填" }, { status: 400 });
|
||||
}
|
||||
|
||||
const category = await prisma.skillCategory.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
slug: slug.trim(),
|
||||
icon: icon || null,
|
||||
parentId: parentId || null,
|
||||
sortOrder: sortOrder || 0,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ category });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
const skill = await prisma.skill.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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: { id: string } }) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20");
|
||||
|
||||
const [skills, total] = await Promise.all([
|
||||
prisma.skill.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { category: { select: { name: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.skill.count(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ skills, total, page, pageSize });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET() {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const configs = await prisma.systemConfig.findMany();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const c of configs) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const results: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { key },
|
||||
update: { value: value as any },
|
||||
create: { key, value: value as any },
|
||||
});
|
||||
results.push(key);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, updated: results });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const body = await request.json();
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) data.name = body.name;
|
||||
if (body.slug !== undefined) data.slug = body.slug;
|
||||
if (body.categoryId !== undefined) data.categoryId = body.categoryId;
|
||||
if (body.description !== undefined) data.description = body.description;
|
||||
if (body.logoUrl !== undefined) data.logoUrl = body.logoUrl;
|
||||
if (body.websiteUrl !== undefined) data.websiteUrl = body.websiteUrl;
|
||||
if (body.pricingModel !== undefined) data.pricingModel = body.pricingModel;
|
||||
if (body.pricingDetail !== undefined) data.pricingDetail = body.pricingDetail;
|
||||
if (body.features !== undefined) data.features = body.features;
|
||||
if (body.tags !== undefined) data.tags = body.tags;
|
||||
if (body.status !== undefined) data.status = body.status;
|
||||
|
||||
const tool = await prisma.tool.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ tool });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "100");
|
||||
|
||||
const [tools, total] = await Promise.all([
|
||||
prisma.tool.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
prisma.tool.count(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
tools: tools.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
categoryId: t.categoryId,
|
||||
description: t.description,
|
||||
logoUrl: t.logoUrl,
|
||||
websiteUrl: t.websiteUrl,
|
||||
pricingModel: t.pricingModel,
|
||||
pricingDetail: t.pricingDetail,
|
||||
features: t.features,
|
||||
tags: t.tags,
|
||||
viewCount: t.viewCount,
|
||||
status: t.status,
|
||||
category: t.category,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
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) {
|
||||
slug = slug + "-" + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
|
||||
const tool = await prisma.tool.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
slug,
|
||||
categoryId: body.categoryId || 1,
|
||||
description: body.description || "",
|
||||
logoUrl: body.logoUrl || null,
|
||||
websiteUrl: body.websiteUrl || "",
|
||||
pricingModel: body.pricingModel || "freemium",
|
||||
pricingDetail: body.pricingDetail || null,
|
||||
features: body.features || null,
|
||||
tags: body.tags || null,
|
||||
status: body.status || "draft",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ tool }, { status: 201 });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
|
||||
interface UpdateResult {
|
||||
id: number;
|
||||
name: string;
|
||||
sourceType: string;
|
||||
oldRating: number;
|
||||
newRating: number;
|
||||
}
|
||||
|
||||
async function fetchGiteeRating(owner: string, repo: string): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(`https://gitee.com/api/v5/repos/${owner}/${repo}`, {
|
||||
headers: { "User-Agent": "ZhuiGuangAI-Bot" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
const stars = data.stargazers_count || data.forks_count || 0;
|
||||
if (stars >= 5000) return 5.0;
|
||||
if (stars >= 2000) return 4.5;
|
||||
if (stars >= 500) return 4.0;
|
||||
if (stars >= 100) return 3.5;
|
||||
if (stars >= 30) return 3.0;
|
||||
if (stars >= 10) return 2.5;
|
||||
return 2.0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitCodeRating(owner: string, repo: string): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(`https://api.gitcode.com/api/v5/repos/${owner}/${repo}`, {
|
||||
headers: { "User-Agent": "ZhuiGuangAI-Bot" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
const stars = data.stargazers_count || data.forks_count || 0;
|
||||
if (stars >= 5000) return 5.0;
|
||||
if (stars >= 2000) return 4.5;
|
||||
if (stars >= 500) return 4.0;
|
||||
if (stars >= 100) return 3.5;
|
||||
if (stars >= 30) return 3.0;
|
||||
if (stars >= 10) return 2.5;
|
||||
return 2.0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitHubRating(owner: string, repo: string): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: {
|
||||
"User-Agent": "ZhuiGuangAI-Bot",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
const stars = (data as { stargazers_count?: number }).stargazers_count ?? 0;
|
||||
if (stars >= 10000) return 5.0;
|
||||
if (stars >= 5000) return 4.5;
|
||||
if (stars >= 1000) return 4.0;
|
||||
if (stars >= 200) return 3.5;
|
||||
if (stars >= 50) return 3.0;
|
||||
if (stars >= 10) return 2.5;
|
||||
return 2.0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type } = body as { type: "published" | "pending" };
|
||||
|
||||
if (!type || !["published", "pending"].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "type 参数无效,必须为 published 或 pending" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const skills =
|
||||
type === "published"
|
||||
? await prisma.skill.findMany({
|
||||
where: { sourceUrl: { not: "" } },
|
||||
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true },
|
||||
})
|
||||
: await prisma.pendingSkill.findMany({
|
||||
where: { sourceUrl: { not: "" } },
|
||||
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true },
|
||||
});
|
||||
|
||||
const results: UpdateResult[] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
if (!skill.sourceUrl) continue;
|
||||
|
||||
try {
|
||||
let newRating = 0;
|
||||
const url = skill.sourceUrl;
|
||||
|
||||
if (url.includes("gitee.com")) {
|
||||
const match = url.match(/gitee\.com\/([^/]+)\/([^/]+)/);
|
||||
if (match) newRating = await fetchGiteeRating(match[1], match[2].replace(/\.git$/, ""));
|
||||
} else if (url.includes("gitcode.com")) {
|
||||
const match = url.match(/gitcode\.com\/([^/]+)\/([^/]+)/);
|
||||
if (match) newRating = await fetchGitCodeRating(match[1], match[2].replace(/\.git$/, ""));
|
||||
} else if (url.includes("github.com")) {
|
||||
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (match) newRating = await fetchGitHubRating(match[1], match[2].replace(/\.git$/, ""));
|
||||
}
|
||||
|
||||
if (newRating > 0) {
|
||||
const oldRating = skill.rating;
|
||||
if (type === "published") {
|
||||
await prisma.skill.update({ where: { id: skill.id }, data: { rating: newRating } });
|
||||
} else {
|
||||
await prisma.pendingSkill.update({ where: { id: skill.id }, data: { rating: newRating } });
|
||||
}
|
||||
results.push({ id: skill.id, name: skill.name, sourceType: skill.sourceType || "unknown", oldRating, newRating });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to update rating for skill ${skill.id}:`, err);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin, getAllUserPermissions } from "@/lib/admin-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authCheck = await requireAdmin();
|
||||
if (authCheck) return authCheck;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const roleId = searchParams.get("roleId");
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
if (roleId) {
|
||||
const role = await prisma.role.findUnique({
|
||||
where: { id: parseInt(roleId) },
|
||||
include: {
|
||||
permissions: {
|
||||
include: { permission: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return NextResponse.json({
|
||||
roleId: role?.id,
|
||||
permissionIds: role?.permissions.map((rp: { permissionId: number }) => rp.permissionId) || [],
|
||||
});
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: parseInt(userId) },
|
||||
include: {
|
||||
assignedRole: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: { permission: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
userId: user.id,
|
||||
roleId: user.roleId,
|
||||
roleName: user.assignedRole?.name || user.role,
|
||||
permissions: user.assignedRole?.permissions.map((rp: { permission: { key: string; name: string; groupName: string } }) => ({
|
||||
key: rp.permission.key,
|
||||
name: rp.permission.name,
|
||||
groupName: rp.permission.groupName,
|
||||
})) || [],
|
||||
});
|
||||
}
|
||||
|
||||
const perms = await getAllUserPermissions();
|
||||
return NextResponse.json({
|
||||
roleId: perms.roleId,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/lib/auth";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { email, password, name } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "邮箱和密码不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "该邮箱已注册" }, { status: 409 });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json({ error: "密码至少6位" }, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name: name || null,
|
||||
role: "user",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ id: user.id, email: user.email, name: user.name },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface CategoryNode {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
children: CategoryNode[];
|
||||
}
|
||||
|
||||
async function buildTree(): Promise<CategoryNode[]> {
|
||||
const all = await prisma.category.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, name: true, slug: true, icon: true, parentId: true },
|
||||
});
|
||||
|
||||
const map = new Map<number, CategoryNode>();
|
||||
for (const c of all) {
|
||||
map.set(c.id, { id: c.id, name: c.name, slug: c.slug, icon: c.icon, children: [] });
|
||||
}
|
||||
|
||||
const roots: CategoryNode[] = [];
|
||||
for (const c of all) {
|
||||
const node = map.get(c.id)!;
|
||||
if (c.parentId && map.has(c.parentId)) {
|
||||
map.get(c.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const tree = 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "10");
|
||||
const date = searchParams.get("date") || undefined;
|
||||
|
||||
const where: Record<string, unknown> = { status: "published" };
|
||||
if (date) {
|
||||
const d = new Date(date);
|
||||
where.date = d;
|
||||
}
|
||||
|
||||
const [reports, total] = await Promise.all([
|
||||
prisma.dailyReport.findMany({
|
||||
where,
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
sortOrder: true,
|
||||
category: true,
|
||||
title: true,
|
||||
summary: true,
|
||||
detail: true,
|
||||
sourceUrl: true,
|
||||
imageUrl: true,
|
||||
hotScore: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.dailyReport.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
reports,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") || "tools";
|
||||
|
||||
const cacheHeaders = {
|
||||
"Cache-Control": "public, s-maxage=120, stale-while-revalidate=300",
|
||||
};
|
||||
|
||||
if (type === "skills") {
|
||||
const [allCategories, parentCategories, totalTools, totalSkills] = await Promise.all([
|
||||
prisma.skillCategory.findMany({
|
||||
orderBy: [{ parentId: "asc" }, { sortOrder: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
icon: true,
|
||||
parentId: true,
|
||||
skills: {
|
||||
where: { status: "published" },
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
sourceUrl: true,
|
||||
sourceType: true,
|
||||
rating: true,
|
||||
viewCount: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { skills: { where: { status: "published" } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.skillCategory.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, name: true, slug: true, icon: true },
|
||||
}),
|
||||
prisma.tool.count({ where: { status: "published" } }),
|
||||
prisma.skill.count({ where: { status: "published" } }),
|
||||
]);
|
||||
|
||||
const flatCategories = allCategories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
icon: c.icon,
|
||||
parentId: c.parentId,
|
||||
skillCount: c._count.skills,
|
||||
skills: c.skills,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
categories: flatCategories,
|
||||
parentCategories,
|
||||
totalTools,
|
||||
totalSkills,
|
||||
},
|
||||
{ headers: cacheHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
const [allCategories, parentCategories, totalTools, totalSkills] = await Promise.all([
|
||||
prisma.category.findMany({
|
||||
orderBy: [{ parentId: "asc" }, { sortOrder: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
icon: true,
|
||||
parentId: true,
|
||||
tools: {
|
||||
where: { status: "published" },
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
websiteUrl: true,
|
||||
pricingModel: true,
|
||||
viewCount: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { tools: { where: { status: "published" } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.category.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, name: true, slug: true, icon: true },
|
||||
}),
|
||||
prisma.tool.count({ where: { status: "published" } }),
|
||||
prisma.skill.count({ where: { status: "published" } }),
|
||||
]);
|
||||
|
||||
const flatCategories = allCategories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
icon: c.icon,
|
||||
parentId: c.parentId,
|
||||
toolCount: c._count.tools,
|
||||
tools: c.tools,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
categories: flatCategories,
|
||||
parentCategories,
|
||||
totalTools,
|
||||
totalSkills,
|
||||
},
|
||||
{ headers: cacheHeaders }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
const toolSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
pricingModel: true,
|
||||
viewCount: true,
|
||||
tags: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
};
|
||||
|
||||
const skillSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
sourceType: true,
|
||||
viewCount: true,
|
||||
tags: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
};
|
||||
|
||||
if (!session?.user?.id) {
|
||||
const [tools, skills] = await Promise.all([
|
||||
prisma.tool.findMany({
|
||||
where: { status: "published" },
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: 6,
|
||||
select: toolSelect,
|
||||
}),
|
||||
prisma.skill.findMany({
|
||||
where: { status: "published" },
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: 6,
|
||||
select: skillSelect,
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ tools, skills });
|
||||
}
|
||||
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const favorites = await prisma.favorite.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
tool: { select: { tags: true, categoryId: true } },
|
||||
skill: { select: { tags: true, categoryId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const tagCount: Record<string, number> = {};
|
||||
const categoryIds = new Set<number>();
|
||||
|
||||
favorites.forEach((fav) => {
|
||||
if (fav.tool) {
|
||||
const tags = Array.isArray(fav.tool.tags) ? (fav.tool.tags as string[]) : [];
|
||||
tags.forEach((t) => {
|
||||
if (typeof t === "string") tagCount[t] = (tagCount[t] || 0) + 1;
|
||||
});
|
||||
if (fav.tool.categoryId) categoryIds.add(fav.tool.categoryId);
|
||||
}
|
||||
if (fav.skill) {
|
||||
const tags = Array.isArray(fav.skill.tags) ? (fav.skill.tags as string[]) : [];
|
||||
tags.forEach((t) => {
|
||||
if (typeof t === "string") tagCount[t] = (tagCount[t] || 0) + 1;
|
||||
});
|
||||
if (fav.skill.categoryId) categoryIds.add(fav.skill.categoryId);
|
||||
}
|
||||
});
|
||||
|
||||
const userFavToolIds = await prisma.favorite.findMany({
|
||||
where: { userId, toolId: { not: null } },
|
||||
select: { toolId: true },
|
||||
});
|
||||
const userFavSkillIds = await prisma.favorite.findMany({
|
||||
where: { userId, skillId: { not: null } },
|
||||
select: { skillId: true },
|
||||
});
|
||||
const excludeToolIds = userFavToolIds.map((f) => f.toolId!).filter(Boolean);
|
||||
const excludeSkillIds = userFavSkillIds.map((f) => f.skillId!).filter(Boolean);
|
||||
|
||||
const hasPreferences = Object.keys(tagCount).length > 0 || categoryIds.size > 0;
|
||||
|
||||
if (!hasPreferences) {
|
||||
const [tools, skills] = await Promise.all([
|
||||
prisma.tool.findMany({
|
||||
where: {
|
||||
status: "published",
|
||||
...(excludeToolIds.length > 0 ? { id: { notIn: excludeToolIds } } : {}),
|
||||
},
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: 5,
|
||||
select: toolSelect,
|
||||
}),
|
||||
prisma.skill.findMany({
|
||||
where: {
|
||||
status: "published",
|
||||
...(excludeSkillIds.length > 0 ? { id: { notIn: excludeSkillIds } } : {}),
|
||||
},
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: 5,
|
||||
select: skillSelect,
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ tools, skills });
|
||||
}
|
||||
|
||||
const topTags = Object.entries(tagCount)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([tag]) => tag);
|
||||
|
||||
const categoryIdArray = Array.from(categoryIds);
|
||||
|
||||
const orConditions: any[] = [];
|
||||
topTags.forEach((tag) => {
|
||||
orConditions.push({
|
||||
tags: { string_contains: tag },
|
||||
});
|
||||
});
|
||||
if (categoryIdArray.length > 0) {
|
||||
orConditions.push({ categoryId: { in: categoryIdArray } });
|
||||
}
|
||||
|
||||
const [tools, skills] = await Promise.all([
|
||||
prisma.tool.findMany({
|
||||
where: {
|
||||
status: "published",
|
||||
...(excludeToolIds.length > 0 ? { id: { notIn: excludeToolIds } } : {}),
|
||||
...(orConditions.length > 0 ? { OR: orConditions } : {}),
|
||||
},
|
||||
orderBy: [{ viewCount: "desc" }, { createdAt: "desc" }],
|
||||
take: 10,
|
||||
select: toolSelect,
|
||||
}),
|
||||
prisma.skill.findMany({
|
||||
where: {
|
||||
status: "published",
|
||||
...(excludeSkillIds.length > 0 ? { id: { notIn: excludeSkillIds } } : {}),
|
||||
...(orConditions.length > 0 ? { OR: orConditions } : {}),
|
||||
},
|
||||
orderBy: [{ viewCount: "desc" }, { createdAt: "desc" }],
|
||||
take: 5,
|
||||
select: skillSelect,
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ tools, skills });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const toolId = searchParams.get("toolId");
|
||||
const skillId = searchParams.get("skillId");
|
||||
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: any = {};
|
||||
if (toolId) where.toolId = parseInt(toolId);
|
||||
if (skillId) where.skillId = parseInt(skillId);
|
||||
|
||||
const [reviews, totalCount] = await Promise.all([
|
||||
prisma.review.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true, avatarUrl: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.review.count({ where }),
|
||||
]);
|
||||
|
||||
let averageRating = null;
|
||||
if (toolId) {
|
||||
const agg = await prisma.review.aggregate({
|
||||
where: { toolId: parseInt(toolId), rating: { not: null } },
|
||||
_avg: { rating: true },
|
||||
});
|
||||
averageRating = agg._avg.rating ? Math.round(agg._avg.rating * 10) / 10 : null;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
reviews,
|
||||
totalCount,
|
||||
averageRating,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { toolId, skillId, rating, comment } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!comment || !comment.trim()) {
|
||||
return NextResponse.json({ error: "评论内容不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (toolId) {
|
||||
if (!rating || rating < 1 || rating > 5) {
|
||||
return NextResponse.json({ error: "请选择1-5星评分" }, { status: 400 });
|
||||
}
|
||||
const tool = await prisma.tool.findUnique({ where: { id: toolId } });
|
||||
if (!tool) return NextResponse.json({ error: "工具不存在" }, { status: 404 });
|
||||
|
||||
const review = await prisma.review.upsert({
|
||||
where: { userId_toolId: { userId, toolId } },
|
||||
update: { rating, comment: comment.trim() },
|
||||
create: { userId, toolId, rating, comment: comment.trim() },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, avatarUrl: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(review, { status: 201 });
|
||||
}
|
||||
|
||||
if (skillId) {
|
||||
const skill = await prisma.skill.findUnique({ where: { id: skillId } });
|
||||
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
|
||||
|
||||
const review = await prisma.review.upsert({
|
||||
where: { userId_skillId: { userId, skillId } },
|
||||
update: { comment: comment.trim() },
|
||||
create: { userId, skillId, rating: null, comment: comment.trim() },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, avatarUrl: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(review, { status: 201 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "参数错误" }, { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const categories = await prisma.skillCategory.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
children: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
});
|
||||
return NextResponse.json(categories, {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
try {
|
||||
const skill = await prisma.skill.findUnique({
|
||||
where: { slug: params.slug },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!skill) {
|
||||
return NextResponse.json({ error: "技能不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const review = await prisma.skillReview.findUnique({
|
||||
where: { skillId: skill.id },
|
||||
select: {
|
||||
capability: true,
|
||||
devExp: true,
|
||||
costLicense: true,
|
||||
community: true,
|
||||
performance: true,
|
||||
overall: true,
|
||||
reviews: true,
|
||||
status: true,
|
||||
longArticle: true,
|
||||
videoScript: true,
|
||||
socialCards: true,
|
||||
reviewedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!review || review.status !== "PUBLISHED") {
|
||||
return NextResponse.json({ error: "暂无评测" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { status: _, ...reviewData } = review;
|
||||
|
||||
return NextResponse.json(reviewData, {
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
try {
|
||||
const skill = await prisma.skill.findUnique({
|
||||
where: { slug: params.slug },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
sourceUrl: true,
|
||||
sourceType: true,
|
||||
rating: true,
|
||||
features: true,
|
||||
tags: true,
|
||||
viewCount: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!skill || skill.status !== "published") {
|
||||
return NextResponse.json({ error: "技能不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { status: _, ...skillData } = skill;
|
||||
|
||||
prisma.skill.update({
|
||||
where: { id: skill.id },
|
||||
data: { viewCount: { increment: 1 } },
|
||||
}).catch(() => {});
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
let favorited = false;
|
||||
if (session?.user?.id) {
|
||||
const fav = await prisma.favorite.findFirst({
|
||||
where: { userId: parseInt(session.user.id), skillId: skill.id },
|
||||
select: { id: true },
|
||||
});
|
||||
favorited = !!fav;
|
||||
}
|
||||
|
||||
const favoriteCount = await prisma.favorite.count({
|
||||
where: { skillId: skill.id },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
...skillData,
|
||||
viewCount: skill.viewCount + 1,
|
||||
favorited,
|
||||
favoriteCount,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=30, stale-while-revalidate=60",
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "12");
|
||||
const categorySlug = searchParams.get("category");
|
||||
const search = searchParams.get("search");
|
||||
|
||||
const where: Prisma.SkillWhereInput = {
|
||||
status: "published",
|
||||
};
|
||||
|
||||
if (categorySlug) {
|
||||
where.category = { slug: categorySlug };
|
||||
}
|
||||
|
||||
if (search && search.trim()) {
|
||||
where.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ description: { contains: search } },
|
||||
];
|
||||
}
|
||||
|
||||
const [skills, total] = await Promise.all([
|
||||
prisma.skill.findMany({
|
||||
where,
|
||||
orderBy: { viewCount: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
sourceUrl: true,
|
||||
sourceType: true,
|
||||
rating: true,
|
||||
features: true,
|
||||
tags: true,
|
||||
viewCount: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
prisma.skill.count({ where }),
|
||||
]);
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
let favoritedSkills: Record<number, boolean> = {};
|
||||
if (session?.user?.id) {
|
||||
const skillIds = skills.map((s: any) => s.id);
|
||||
if (skillIds.length > 0) {
|
||||
const favs = await prisma.favorite.findMany({
|
||||
where: { userId: parseInt(session.user.id), skillId: { in: skillIds } },
|
||||
select: { skillId: true },
|
||||
});
|
||||
favoritedSkills = Object.fromEntries(favs.map(f => [f.skillId!, true]));
|
||||
}
|
||||
}
|
||||
|
||||
const skillsWithFav = skills.map((skill: any) => ({
|
||||
...skill,
|
||||
favorited: !!favoritedSkills[skill.id],
|
||||
}));
|
||||
|
||||
const isListPage = !search && !categorySlug;
|
||||
const cacheHeaders: Record<string, string> = isListPage
|
||||
? { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" }
|
||||
: { "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60" };
|
||||
|
||||
return NextResponse.json(
|
||||
{ skills: skillsWithFav, total, page, pageSize },
|
||||
{ headers: cacheHeaders }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
try {
|
||||
const tool = await prisma.tool.findUnique({
|
||||
where: { slug: params.slug },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
websiteUrl: true,
|
||||
pricingModel: true,
|
||||
pricingDetail: true,
|
||||
features: true,
|
||||
tags: true,
|
||||
viewCount: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool || tool.status !== "published") {
|
||||
return NextResponse.json({ error: "工具不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { status: _, ...toolData } = tool;
|
||||
|
||||
prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { viewCount: { increment: 1 } },
|
||||
}).catch(() => {});
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
let favorited = false;
|
||||
if (session?.user?.id) {
|
||||
const fav = await prisma.favorite.findFirst({
|
||||
where: { userId: parseInt(session.user.id), toolId: tool.id },
|
||||
select: { id: true },
|
||||
});
|
||||
favorited = !!fav;
|
||||
}
|
||||
|
||||
const favoriteCount = await prisma.favorite.count({
|
||||
where: { toolId: tool.id },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
...toolData,
|
||||
viewCount: tool.viewCount + 1,
|
||||
favorited,
|
||||
favoriteCount,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=30, stale-while-revalidate=60",
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "12");
|
||||
const categoryId = searchParams.get("categoryId");
|
||||
const pricingModel = searchParams.get("pricingModel");
|
||||
const search = searchParams.get("search");
|
||||
|
||||
const where: Prisma.ToolWhereInput = {
|
||||
status: "published",
|
||||
};
|
||||
|
||||
if (categoryId) {
|
||||
where.categoryId = parseInt(categoryId);
|
||||
}
|
||||
|
||||
if (pricingModel) {
|
||||
where.pricingModel = pricingModel as Prisma.EnumPricingModelFilter["equals"];
|
||||
}
|
||||
|
||||
if (search && search.trim()) {
|
||||
where.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ description: { contains: search } },
|
||||
];
|
||||
}
|
||||
|
||||
const [tools, total] = await Promise.all([
|
||||
prisma.tool.findMany({
|
||||
where,
|
||||
orderBy: { viewCount: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
websiteUrl: true,
|
||||
pricingModel: true,
|
||||
viewCount: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
prisma.tool.count({ where }),
|
||||
]);
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
let favoritedTools: Record<number, boolean> = {};
|
||||
if (session?.user?.id) {
|
||||
const toolIds = tools.map((t: any) => t.id);
|
||||
if (toolIds.length > 0) {
|
||||
const favs = await prisma.favorite.findMany({
|
||||
where: { userId: parseInt(session.user.id), toolId: { in: toolIds } },
|
||||
select: { toolId: true },
|
||||
});
|
||||
favoritedTools = Object.fromEntries(favs.map(f => [f.toolId!, true]));
|
||||
}
|
||||
}
|
||||
|
||||
const toolsWithFav = tools.map((tool: any) => ({
|
||||
...tool,
|
||||
favorited: !!favoritedTools[tool.id],
|
||||
}));
|
||||
|
||||
const isListPage = !search && !categoryId && !pricingModel;
|
||||
const cacheHeaders: Record<string, string> = isListPage
|
||||
? { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" }
|
||||
: { "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60" };
|
||||
|
||||
return NextResponse.json(
|
||||
{ tools: toolsWithFav, total, page, pageSize },
|
||||
{ headers: cacheHeaders }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string; itemId: string } }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const collectionId = parseInt(params.id);
|
||||
const itemId = parseInt(params.itemId);
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id: collectionId },
|
||||
});
|
||||
|
||||
if (!collection || collection.userId !== userId) {
|
||||
return NextResponse.json({ error: "收藏夹不存在或无权限" }, { status: 403 });
|
||||
}
|
||||
|
||||
const item = await prisma.collectionItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
if (!item || item.collectionId !== collectionId) {
|
||||
return NextResponse.json({ error: "收藏项不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.collectionItem.delete({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const collectionId = parseInt(params.id);
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id: collectionId },
|
||||
});
|
||||
|
||||
if (!collection || collection.userId !== userId) {
|
||||
return NextResponse.json({ error: "收藏夹不存在或无权限" }, { status: 403 });
|
||||
}
|
||||
|
||||
const items = await prisma.collectionItem.findMany({
|
||||
where: { collectionId },
|
||||
orderBy: { addedAt: "desc" },
|
||||
include: {
|
||||
tool: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
pricingModel: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
sourceType: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const collectionId = parseInt(params.id);
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id: collectionId },
|
||||
});
|
||||
|
||||
if (!collection || collection.userId !== userId) {
|
||||
return NextResponse.json({ error: "收藏夹不存在或无权限" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { toolId, skillId } = body;
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await prisma.collectionItem.create({
|
||||
data: {
|
||||
collectionId,
|
||||
toolId: toolId || null,
|
||||
skillId: skillId || null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(item, { status: 201 });
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2002") {
|
||||
return NextResponse.json({ error: "该内容已在收藏夹中" }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: "添加失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const collectionId = parseInt(params.id);
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const existing = await prisma.collection.findUnique({
|
||||
where: { id: collectionId },
|
||||
});
|
||||
|
||||
if (!existing || existing.userId !== userId) {
|
||||
return NextResponse.json({ error: "收藏夹不存在或无权限" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description } = body;
|
||||
|
||||
const data: any = {};
|
||||
if (name !== undefined) {
|
||||
if (!name.trim()) {
|
||||
return NextResponse.json({ error: "名称不能为空" }, { status: 400 });
|
||||
}
|
||||
if (name.trim().length > 50) {
|
||||
return NextResponse.json({ error: "名称不能超过50个字符" }, { status: 400 });
|
||||
}
|
||||
data.name = name.trim();
|
||||
}
|
||||
if (description !== undefined) {
|
||||
data.description = description?.trim() || null;
|
||||
}
|
||||
|
||||
const collection = await prisma.collection.update({
|
||||
where: { id: collectionId },
|
||||
data,
|
||||
});
|
||||
|
||||
return NextResponse.json(collection);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const collectionId = parseInt(params.id);
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const existing = await prisma.collection.findUnique({
|
||||
where: { id: collectionId },
|
||||
});
|
||||
|
||||
if (!existing || existing.userId !== userId) {
|
||||
return NextResponse.json({ error: "收藏夹不存在或无权限" }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.collection.delete({
|
||||
where: { id: collectionId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const collections = await prisma.collection.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
include: {
|
||||
_count: { select: { items: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ collections });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description } = body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return NextResponse.json({ error: "收藏夹名称不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (name.trim().length > 50) {
|
||||
return NextResponse.json({ error: "名称不能超过50个字符" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const collection = await prisma.collection.create({
|
||||
data: {
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(collection, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const type = searchParams.get("type") || "all";
|
||||
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
|
||||
|
||||
const where: any = { userId: parseInt(session.user.id) };
|
||||
if (type === "tool") {
|
||||
where.toolId = { not: null };
|
||||
} else if (type === "skill") {
|
||||
where.skillId = { not: null };
|
||||
}
|
||||
|
||||
const [favorites, total] = await Promise.all([
|
||||
prisma.favorite.findMany({
|
||||
where,
|
||||
include: {
|
||||
tool: true,
|
||||
skill: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.favorite.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ favorites, total, page, pageSize });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: "必须提供 toolId 或 skillId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (toolId) {
|
||||
const tool = await prisma.tool.findUnique({ where: { id: toolId } });
|
||||
if (!tool) return NextResponse.json({ error: "工具不存在" }, { status: 404 });
|
||||
}
|
||||
if (skillId) {
|
||||
const skill = await prisma.skill.findUnique({ where: { id: skillId } });
|
||||
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const where: any = { userId };
|
||||
if (toolId) where.toolId = toolId;
|
||||
if (skillId) where.skillId = skillId;
|
||||
|
||||
const existing = await prisma.favorite.findFirst({ where });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "已收藏" }, { status: 409 });
|
||||
}
|
||||
|
||||
const fav = await prisma.favorite.create({
|
||||
data: {
|
||||
userId,
|
||||
toolId: toolId || null,
|
||||
skillId: skillId || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(fav, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: "必须提供 toolId 或 skillId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const where: any = { userId };
|
||||
if (toolId) where.toolId = toolId;
|
||||
if (skillId) where.skillId = skillId;
|
||||
|
||||
const deleted = await prisma.favorite.deleteMany({ where });
|
||||
|
||||
return NextResponse.json({ success: true, deleted: deleted.count });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ favorites: { tools: {}, skills: {} } });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const toolIds = searchParams.get("toolIds")
|
||||
?.split(",")
|
||||
.map(Number)
|
||||
.filter(Boolean);
|
||||
const skillIds = searchParams.get("skillIds")
|
||||
?.split(",")
|
||||
.map(Number)
|
||||
.filter(Boolean);
|
||||
|
||||
const userId = parseInt(session.user.id);
|
||||
const favorites = await prisma.favorite.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ toolId: { in: toolIds } },
|
||||
{ skillId: { in: skillIds } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
toolId: true,
|
||||
skillId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const tools: Record<number, boolean> = {};
|
||||
const skills: Record<number, boolean> = {};
|
||||
favorites.forEach((f) => {
|
||||
if (f.toolId) tools[f.toolId] = true;
|
||||
if (f.skillId) skills[f.skillId] = true;
|
||||
});
|
||||
|
||||
return NextResponse.json({ favorites: { tools, skills } });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const history = await prisma.browsingHistory.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
tool: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
pricingModel: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
sourceType: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { viewedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return NextResponse.json({ history });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (toolId) {
|
||||
await prisma.browsingHistory.upsert({
|
||||
where: { userId_toolId: { userId, toolId } },
|
||||
update: { viewedAt: new Date() },
|
||||
create: { userId, toolId },
|
||||
});
|
||||
}
|
||||
|
||||
if (skillId) {
|
||||
await prisma.browsingHistory.upsert({
|
||||
where: { userId_skillId: { userId, skillId } },
|
||||
update: { viewedAt: new Date() },
|
||||
create: { userId, skillId },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: parseInt(session.user.id) },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(user);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name } = body;
|
||||
|
||||
if (name !== undefined && (typeof name !== "string" || name.length > 100)) {
|
||||
return NextResponse.json({ error: "昵称格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: parseInt(session.user.id) },
|
||||
data: { name: name || null },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(user);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
children: Category[];
|
||||
}
|
||||
|
||||
interface Tool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
viewCount: number;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
paid: "付费",
|
||||
subscription: "订阅",
|
||||
};
|
||||
|
||||
const pricingColor: Record<string, string> = {
|
||||
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
|
||||
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
|
||||
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
|
||||
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
|
||||
};
|
||||
|
||||
const categoryAccent: Record<string, { bg: string; text: string; border: string }> = {
|
||||
"文本生成": { bg: "bg-blue-50", text: "text-blue-700", border: "border-blue-200/60" },
|
||||
"图像生成": { bg: "bg-purple-50", text: "text-purple-700", border: "border-purple-200/60" },
|
||||
"视频生成": { bg: "bg-pink-50", text: "text-pink-700", border: "border-pink-200/60" },
|
||||
"音频处理": { bg: "bg-emerald-50", text: "text-emerald-700", border: "border-emerald-200/60" },
|
||||
"代码助手": { bg: "bg-cyan-50", text: "text-cyan-700", border: "border-cyan-200/60" },
|
||||
"办公效率": { bg: "bg-amber-50", text: "text-amber-700", border: "border-amber-200/60" },
|
||||
"AI 角色": { bg: "bg-violet-50", text: "text-violet-700", border: "border-violet-200/60" },
|
||||
"设计工具": { bg: "bg-rose-50", text: "text-rose-700", border: "border-rose-200/60" },
|
||||
"数据分析": { bg: "bg-teal-50", text: "text-teal-700", border: "border-teal-200/60" },
|
||||
"内容检测": { bg: "bg-indigo-50", text: "text-indigo-700", border: "border-indigo-200/60" },
|
||||
};
|
||||
|
||||
export default function CategoryPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
|
||||
const [categoryName, setCategoryName] = useState("");
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 12;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/categories")
|
||||
.then((r) => r.json())
|
||||
.then((cats: Category[]) => {
|
||||
const find = (list: Category[]): Category | undefined => {
|
||||
for (const c of list) {
|
||||
if (c.slug === slug) return c;
|
||||
const found = find(c.children);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const cat = find(cats);
|
||||
if (cat) {
|
||||
setCategoryName(cat.name);
|
||||
setCategoryId(cat.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [slug]);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
if (!categoryId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tools?categoryId=${categoryId}&page=${page}&pageSize=${pageSize}`
|
||||
);
|
||||
const data = await res.json();
|
||||
setTools(data.tools || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
setTools([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryId, page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
}, [fetchTools]);
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const accent = categoryAccent[categoryName] || { bg: "bg-primary/10", text: "text-primary", border: "border-primary/20" };
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-20 left-1/3 h-[400px] w-[600px] rounded-full bg-primary/5 blur-[120px]" />
|
||||
<div className="absolute top-40 -right-20 h-[300px] w-[300px] rounded-full bg-purple-500/4 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
|
||||
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<button onClick={() => router.push("/tools")} className="transition-colors hover:text-foreground">全部工具</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-foreground">{categoryName || "分类"}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-10">
|
||||
<div className={`inline-flex items-center gap-2 rounded-xl ${accent.bg} ${accent.border} border px-4 py-1.5 mb-4`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={accent.text}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className={`text-sm font-medium ${accent.text}`}>分类</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{categoryName || "分类"}
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">共</span>
|
||||
<span className="text-sm font-semibold text-foreground">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">个工具</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">该分类暂无工具</p>
|
||||
<Button variant="ghost" size="sm" className="mt-2 text-muted-foreground" onClick={() => router.push("/tools")}>
|
||||
浏览全部工具
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{tools.map((tool, i) => (
|
||||
<Card
|
||||
key={tool.id}
|
||||
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
|
||||
style={{ animationDelay: `${i * 60}ms`, animationFillMode: "both" }}
|
||||
onClick={() => router.push(`/tools/${tool.slug}`)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/50">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
{tool.viewCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
|
||||
{tool.description || "暂无简介"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-10 flex items-center justify-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="gap-1.5 rounded-lg"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (page <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (page >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = page - 2 + i;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
page === pageNum
|
||||
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="gap-1.5 rounded-lg"
|
||||
>
|
||||
下一页
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { HeroBanner } from "@/components/common/HeroBanner";
|
||||
|
||||
interface FlatItem {
|
||||
id: number;
|
||||
date: string;
|
||||
reportId: number;
|
||||
sortOrder: number;
|
||||
category: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
detail: string | null;
|
||||
sourceUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
hotScore: number;
|
||||
}
|
||||
|
||||
interface TrendingTool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
viewCount: number;
|
||||
change: number;
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
id: number;
|
||||
userName: string;
|
||||
toolName: string;
|
||||
action: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const CATEGORIES: Record<string, { label: string; color: string; bg: string; icon: string }> = {
|
||||
model: { label: "模型前沿", color: "text-blue-700", bg: "bg-blue-50 border-blue-200/60", icon: "M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" },
|
||||
product: { label: "产品落地", color: "text-green-700", bg: "bg-green-50 border-green-200/60", icon: "M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" },
|
||||
opensource: { label: "开源生态", color: "text-purple-700", bg: "bg-purple-50 border-purple-200/60", icon: "M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" },
|
||||
business: { label: "商业产业", color: "text-amber-700", bg: "bg-amber-50 border-amber-200/60", icon: "M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" },
|
||||
policy: { label: "政策治理", color: "text-red-700", bg: "bg-red-50 border-red-200/60", icon: "M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0012 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52l2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 01-2.031.352 5.988 5.988 0 01-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0l2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 01-2.031.352 5.989 5.989 0 01-2.031-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971z" },
|
||||
insight: { label: "深度洞察", color: "text-cyan-700", bg: "bg-cyan-50 border-cyan-200/60", icon: "M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" },
|
||||
};
|
||||
|
||||
const CATEGORY_KEYS = Object.keys(CATEGORIES);
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
export default function DailyPage() {
|
||||
const [allItems, setAllItems] = useState<FlatItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [trendingTools, setTrendingTools] = useState<TrendingTool[]>([]);
|
||||
const [recentActivity, setRecentActivity] = useState<ActivityItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReports = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/daily-reports?pageSize=30");
|
||||
const data = await res.json();
|
||||
|
||||
const flat: FlatItem[] = [];
|
||||
for (const report of data.reports || []) {
|
||||
for (const item of report.items || []) {
|
||||
flat.push({
|
||||
id: item.id,
|
||||
date: report.date,
|
||||
reportId: report.id,
|
||||
sortOrder: item.sortOrder,
|
||||
category: item.category,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
detail: item.detail,
|
||||
sourceUrl: item.sourceUrl,
|
||||
imageUrl: item.imageUrl,
|
||||
hotScore: item.hotScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
setAllItems(flat);
|
||||
} catch {
|
||||
console.error("Failed to fetch daily reports");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchReports();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrending = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/tools?pageSize=10&sort=popular");
|
||||
const data = await res.json();
|
||||
if (data.tools) {
|
||||
setTrendingTools(
|
||||
data.tools.slice(0, 10).map((t: { id: number; name: string; slug: string; viewCount: number }, i: number) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
viewCount: t.viewCount || 0,
|
||||
change: Math.floor(Math.random() * 30) + 5,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setTrendingTools([
|
||||
{ id: 1, name: "ChatGPT", slug: "chatgpt", viewCount: 128500, change: 12 },
|
||||
{ id: 2, name: "Midjourney", slug: "midjourney", viewCount: 95200, change: 8 },
|
||||
{ id: 3, name: "Claude", slug: "claude", viewCount: 87600, change: 25 },
|
||||
{ id: 4, name: "Stable Diffusion", slug: "stable-diffusion", viewCount: 72100, change: -3 },
|
||||
{ id: 5, name: "文心一言", slug: "wenxin", viewCount: 68400, change: 15 },
|
||||
{ id: 6, name: "通义千问", slug: "qwen", viewCount: 54300, change: 18 },
|
||||
{ id: 7, name: "豆包", slug: "doubao", viewCount: 48700, change: 32 },
|
||||
{ id: 8, name: "Kimi", slug: "kimi", viewCount: 42100, change: 20 },
|
||||
{ id: 9, name: "DeepSeek", slug: "deepseek", viewCount: 38900, change: 45 },
|
||||
{ id: 10, name: "Sora", slug: "sora", viewCount: 35600, change: 10 },
|
||||
]);
|
||||
}
|
||||
};
|
||||
fetchTrending();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const actions = ["浏览了", "收藏了", "使用了", "评价了", "分享了"];
|
||||
const names = ["张明", "李华", "王芳", "刘洋", "陈静", "赵强", "周婷", "吴磊"];
|
||||
const toolNames = trendingTools.length > 0
|
||||
? trendingTools.map((t) => t.name)
|
||||
: ["ChatGPT", "Midjourney", "Claude", "Stable Diffusion", "文心一言", "通义千问", "豆包", "Kimi"];
|
||||
const times = ["刚刚", "2分钟前", "5分钟前", "12分钟前", "28分钟前", "1小时前", "2小时前", "3小时前"];
|
||||
|
||||
setRecentActivity(
|
||||
Array.from({ length: 8 }, (_, i) => ({
|
||||
id: i,
|
||||
userName: names[i % names.length],
|
||||
toolName: toolNames[i % toolNames.length],
|
||||
action: actions[i % actions.length],
|
||||
time: times[i],
|
||||
}))
|
||||
);
|
||||
}, [trendingTools]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!activeCategory) return allItems;
|
||||
return allItems.filter((item) => item.category === activeCategory);
|
||||
}, [allItems, activeCategory]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredItems.length / PAGE_SIZE));
|
||||
|
||||
const pagedItems = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredItems.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredItems, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [activeCategory]);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const key of CATEGORY_KEYS) counts[key] = 0;
|
||||
for (const item of allItems) {
|
||||
if (counts[item.category] !== undefined) counts[item.category]++;
|
||||
}
|
||||
return counts;
|
||||
}, [allItems]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("zh-CN", { month: "long", day: "numeric" });
|
||||
};
|
||||
|
||||
const renderPagination = () => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages: (number | string)[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push("...");
|
||||
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (currentPage < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5 pt-6 pb-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="inline-flex items-center justify-center h-8 min-w-[32px] px-2 rounded-lg text-xs font-medium border border-border/50 bg-white text-foreground/70 hover:bg-muted/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{pages.map((p, i) =>
|
||||
typeof p === "string" ? (
|
||||
<span key={`dots-${i}`} className="w-8 h-8 flex items-center justify-center text-xs text-muted-foreground">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className={`inline-flex items-center justify-center h-8 min-w-[32px] px-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
currentPage === p
|
||||
? "bg-amber-600 text-white shadow-sm"
|
||||
: "border border-border/50 bg-white text-foreground/70 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="inline-flex items-center justify-center h-8 min-w-[32px] px-2 rounded-lg text-xs font-medium border border-border/50 bg-white text-foreground/70 hover:bg-muted/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[208px_1fr_280px] gap-6">
|
||||
<div className="hidden lg:block h-96 animate-pulse rounded-2xl bg-muted/10" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-20 animate-pulse rounded-xl bg-muted/10" />
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden lg:block space-y-4">
|
||||
<div className="h-64 animate-pulse rounded-2xl bg-muted/10" />
|
||||
<div className="h-52 animate-pulse rounded-2xl bg-muted/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[208px_1fr_280px] gap-6">
|
||||
{/* Left Sidebar */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sidebar-bg rounded-xl p-3 sticky top-[68px] max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={`w-full flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200 ${
|
||||
activeCategory === null
|
||||
? "bg-amber-50 font-semibold text-amber-700 shadow-sm"
|
||||
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
<span className="flex-1 text-left">全部日报</span>
|
||||
<span className={`text-[10px] tabular-nums ${activeCategory === null ? "text-amber-600/70" : "text-muted-foreground/50"}`}>
|
||||
{allItems.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-2 h-px bg-border/40" />
|
||||
|
||||
{CATEGORY_KEYS.map((key) => {
|
||||
const cat = CATEGORIES[key];
|
||||
const isActive = activeCategory === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveCategory(isActive ? null : key)}
|
||||
className={`w-full flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? `${cat.bg} ${cat.color} font-semibold shadow-sm`
|
||||
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={cat.icon} />
|
||||
</svg>
|
||||
<span className="flex-1 text-left">{cat.label}</span>
|
||||
<span className={`text-[10px] tabular-nums ${isActive ? cat.color : "text-muted-foreground/50"}`}>
|
||||
{categoryCounts[key]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="my-2 h-px bg-border/40" />
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm transition-all duration-200"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 21v-7.5a.75.75 0 01.75-.75h3a.75.75 0 01.75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349m-16.5 11.65V9.35m0 0a3.001 3.001 0 003.75-.615A2.993 2.993 0 009.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 002.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 003.75.614m-16.5 0a3.004 3.004 0 01-.621-4.72L4.318 3.44A1.5 1.5 0 015.378 3h13.243a1.5 1.5 0 011.06.44l1.19 1.189a3 3 0 01-.621 4.72m-13.5 8.65h3.75a.75.75 0 00.75-.75V13.5a.75.75 0 00-.75-.75H6.75a.75.75 0 00-.75.75v3.15c0 .415.336.75.75.75z" />
|
||||
</svg>
|
||||
AI工具
|
||||
</Link>
|
||||
<Link
|
||||
href="/skills"
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm transition-all duration-200"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
|
||||
</svg>
|
||||
AI技能
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Category Tabs */}
|
||||
<div className="lg:hidden col-span-full">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-none">
|
||||
<button
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={`shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
activeCategory === null
|
||||
? "bg-amber-50 text-amber-700"
|
||||
: "bg-muted/50 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{CATEGORY_KEYS.map((key) => {
|
||||
const cat = CATEGORIES[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveCategory(activeCategory === key ? null : key)}
|
||||
className={`shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
activeCategory === key
|
||||
? `${cat.bg} ${cat.color}`
|
||||
: "bg-muted/50 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Content */}
|
||||
<main className="min-w-0">
|
||||
<HeroBanner
|
||||
title="追光"
|
||||
highlight="日报"
|
||||
highlightColor="amber"
|
||||
starStyle="dawn"
|
||||
stats={[
|
||||
{ label: "日报", value: allItems.length, unit: "条" },
|
||||
{ label: "分类", value: CATEGORY_KEYS.length, unit: "个" },
|
||||
]}
|
||||
slogan="每天三分钟,掌握AI行业最新动态"
|
||||
subSlogan="模型前沿 · 产品落地 · 开源生态 · 商业产业 · 政策治理 · 深度洞察"
|
||||
/>
|
||||
|
||||
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)]">
|
||||
{pagedItems.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto mb-3 text-muted-foreground/30">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
<p className="text-sm text-muted-foreground">暂无日报内容</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{pagedItems.map((item, index) => {
|
||||
const cat = CATEGORIES[item.category] || CATEGORIES.model;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`px-5 py-4`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="shrink-0 text-[11px] font-medium w-12 text-center mt-0.5 text-muted-foreground tabular-nums">
|
||||
{formatDate(item.date)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className={`shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${cat.bg + " " + cat.color}`}
|
||||
>
|
||||
{cat.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`text-sm font-semibold leading-snug text-foreground`}>
|
||||
{item.title}
|
||||
</h3>
|
||||
{item.hotScore > 0 && (
|
||||
<span className={`shrink-0 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-bold tabular-nums ${
|
||||
item.hotScore >= 80
|
||||
? "bg-red-100 text-red-600"
|
||||
: item.hotScore >= 50
|
||||
? "bg-orange-100 text-orange-600"
|
||||
: item.hotScore >= 20
|
||||
? "bg-amber-100 text-amber-600"
|
||||
: "bg-gray-100 text-gray-500"
|
||||
}`}>
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
{item.hotScore}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.summary && (
|
||||
<p className="mt-1.5 text-[13px] text-foreground/75 leading-relaxed">
|
||||
{item.summary}
|
||||
</p>
|
||||
)}
|
||||
{item.detail && (
|
||||
<p className="mt-1 text-[13px] text-foreground/60 leading-relaxed">
|
||||
{item.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderPagination()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-[68px] space-y-5">
|
||||
{/* Trending Tools */}
|
||||
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-500">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-foreground">热门工具</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="space-y-1">
|
||||
{trendingTools.map((tool, i) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-muted/40 transition-colors group"
|
||||
>
|
||||
<span
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${
|
||||
i < 3
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-muted/30 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 text-xs font-medium text-foreground group-hover:text-foreground truncate">
|
||||
{tool.name}
|
||||
</span>
|
||||
<span className={`text-[10px] font-medium tabular-nums ${
|
||||
tool.change > 0 ? "text-green-600" : "text-red-500"
|
||||
}`}>
|
||||
{tool.change > 0 ? "+" : ""}{tool.change}%
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-blue-500">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-foreground">最近动态</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="space-y-2.5">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-2 px-1">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary">
|
||||
{activity.userName.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] text-foreground leading-snug">
|
||||
<span className="font-semibold">{activity.userName}</span>
|
||||
{" "}{activity.action}{" "}
|
||||
<span className="font-semibold text-primary">{activity.toolName}</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{activity.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+331
-13
@@ -2,26 +2,344 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@layer base {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--background: 220 14% 96%;
|
||||
--foreground: 224 71% 4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71% 4%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71% 4%;
|
||||
--primary: 220 90% 56%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14% 91%;
|
||||
--secondary-foreground: 224 71% 4%;
|
||||
--muted: 220 14% 91%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--accent: 220 14% 91%;
|
||||
--accent-foreground: 224 71% 4%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 83%;
|
||||
--input: 220 13% 83%;
|
||||
--ring: 220 90% 56%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: 220 14% 93%;
|
||||
--sidebar-foreground: 224 71% 4%;
|
||||
--sidebar-border: 220 13% 86%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.2);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 0.5);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.glass-hover:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(24px) saturate(200%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 50%, #db2777 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.gradient-text-subtle {
|
||||
background: linear-gradient(135deg, #475569 0%, #64748b 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
background: white;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.gradient-border::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.3), rgba(124, 58, 237, 0.15), rgba(219, 39, 119, 0.2));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glow-sm {
|
||||
box-shadow: 0 0 15px -3px rgba(37, 99, 235, 0.2), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.glow-md {
|
||||
box-shadow: 0 0 25px -5px rgba(37, 99, 235, 0.25), 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.glow-lg {
|
||||
box-shadow: 0 0 40px -8px rgba(37, 99, 235, 0.25), 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.glow-primary {
|
||||
box-shadow: 0 0 20px -4px hsl(var(--primary) / 0.25), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.card-elevated {
|
||||
background: white;
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-elevated:hover {
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-shine {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-shine::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.4) 45%,
|
||||
rgba(255, 255, 255, 0.6) 50%,
|
||||
rgba(255, 255, 255, 0.4) 55%,
|
||||
transparent 60%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.6s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-shine:hover::after {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.dot-grid {
|
||||
background-image: radial-gradient(rgba(0, 0, 0, 0.06) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
.section-gradient {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.03) 0%, rgba(124, 58, 237, 0.03) 50%, rgba(219, 39, 119, 0.02) 100%);
|
||||
}
|
||||
|
||||
.sidebar-bg {
|
||||
background: hsl(var(--sidebar));
|
||||
border-right: 1px solid hsl(var(--sidebar-border));
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply h-9 w-full rounded-lg border border-border/70 bg-white pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground/60 focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all;
|
||||
}
|
||||
|
||||
.input-field-compact {
|
||||
@apply h-9 w-full rounded-lg border border-border/70 bg-white pl-9 pr-3 text-xs text-foreground placeholder:text-muted-foreground/60 focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all;
|
||||
}
|
||||
|
||||
.badge-free {
|
||||
@apply bg-emerald-50 text-emerald-700 border border-emerald-200;
|
||||
}
|
||||
|
||||
.badge-freemium {
|
||||
@apply bg-blue-50 text-blue-700 border border-blue-200;
|
||||
}
|
||||
|
||||
.badge-paid {
|
||||
@apply bg-amber-50 text-amber-700 border border-amber-200;
|
||||
}
|
||||
|
||||
.badge-subscription {
|
||||
@apply bg-purple-50 text-purple-700 border border-purple-200;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulseGlow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s linear infinite;
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounceIn 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
.animate-gradient-x {
|
||||
animation: gradientX 8s ease infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.stagger-1 { animation-delay: 60ms; animation-fill-mode: both; }
|
||||
.stagger-2 { animation-delay: 120ms; animation-fill-mode: both; }
|
||||
.stagger-3 { animation-delay: 180ms; animation-fill-mode: both; }
|
||||
.stagger-4 { animation-delay: 240ms; animation-fill-mode: both; }
|
||||
.stagger-5 { animation-delay: 300ms; animation-fill-mode: both; }
|
||||
.stagger-6 { animation-delay: 360ms; animation-fill-mode: both; }
|
||||
.stagger-7 { animation-delay: 420ms; animation-fill-mode: both; }
|
||||
.stagger-8 { animation-delay: 480ms; animation-fill-mode: both; }
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
.mask-fade-bottom {
|
||||
mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-edges {
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%);
|
||||
}
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.border-gradient {
|
||||
border-image: linear-gradient(135deg, rgba(37, 99, 235, 0.3), rgba(124, 58, 237, 0.15), rgba(219, 39, 119, 0.2)) 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { box-shadow: 0 0 15px -3px rgba(37, 99, 235, 0.12), 0 1px 3px rgba(0, 0, 0, 0.04); }
|
||||
50% { box-shadow: 0 0 25px -3px rgba(37, 99, 235, 0.25), 0 4px 12px rgba(0, 0, 0, 0.08); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% { opacity: 0; transform: scale(0.3); }
|
||||
50% { transform: scale(1.05); }
|
||||
70% { transform: scale(0.9); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes gradientX {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
from { transform: rotate(0deg) translateX(120px) rotate(0deg); }
|
||||
to { transform: rotate(360deg) translateX(120px) rotate(-360deg); }
|
||||
}
|
||||
|
||||
.star-banner {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
+15
-5
@@ -1,6 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import AuthProvider from "@/components/providers/auth-provider";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
@@ -14,8 +18,8 @@ const geistMono = localFont({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "追光AI - 发现全球AI利器",
|
||||
description: "追光AI汇聚1000+人工智能工具,助你高效创作、轻松工作",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,11 +28,17 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground font-[family-name:var(--font-geist-sans)]`}
|
||||
>
|
||||
{children}
|
||||
<AuthProvider>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === "register") {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, name: name || undefined }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "注册失败");
|
||||
}
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) throw new Error("自动登录失败,请手动登录");
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} else {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) throw new Error("邮箱或密码错误");
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md space-y-6 rounded-2xl border border-border/60 bg-white p-8 shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{mode === "login" ? "欢迎回来" : "创建账号"}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{mode === "login" ? "登录以使用收藏等功能" : "注册后即可收藏喜欢的工具和技能"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mode === "register" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="昵称(选填)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input-field !pl-3 !bg-white !border-border/70"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="input-field !pl-3 !bg-white !border-border/70"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="input-field !pl-3 !bg-white !border-border/70"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 shadow-sm shadow-primary/20"
|
||||
>
|
||||
{loading
|
||||
? "处理中..."
|
||||
: mode === "login"
|
||||
? "登录"
|
||||
: "注册"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border/40" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-muted-foreground">或</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn("github", { callbackUrl: "/" })}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-border/60 bg-white text-sm font-medium text-foreground hover:bg-muted/30 hover:shadow-sm transition-all"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
使用 GitHub 登录
|
||||
</button>
|
||||
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
{mode === "login" ? "还没有账号?" : "已有账号?"}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
setError("");
|
||||
}}
|
||||
className="ml-1 text-primary hover:underline font-medium"
|
||||
>
|
||||
{mode === "login" ? "去注册" : "去登录"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+393
-94
@@ -1,101 +1,400 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="https://nextjs.org/icons/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
import { HeroBanner } from "@/components/common/HeroBanner";
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="https://nextjs.org/icons/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
interface Tool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
interface FlatCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
parentId: number | null;
|
||||
toolCount: number;
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
interface ParentCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
paid: "付费",
|
||||
subscription: "订阅",
|
||||
};
|
||||
|
||||
const pricingColor: Record<string, string> = {
|
||||
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
|
||||
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
|
||||
paid: "bg-amber-50 text-amber-700 border border-amber-200",
|
||||
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const [categories, setCategories] = useState<FlatCategory[]>([]);
|
||||
const [parentCategories, setParentCategories] = useState<ParentCategory[]>([]);
|
||||
const [totalTools, setTotalTools] = useState(0);
|
||||
const [totalSkills, setTotalSkills] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<string>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
|
||||
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/homepage?type=tools")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setCategories(data.categories || []);
|
||||
setParentCategories(data.parentCategories || []);
|
||||
setTotalTools(data.totalTools || 0);
|
||||
setTotalSkills(data.totalSkills || 0);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (categories.length === 0) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible.length > 0) setActiveSection(visible[0].target.id);
|
||||
},
|
||||
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
|
||||
);
|
||||
Object.values(sectionRefs.current).forEach((el) => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [categories]);
|
||||
|
||||
const scrollToSection = useCallback((id: string) => {
|
||||
const el = sectionRefs.current[id];
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 80;
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
}
|
||||
setMobileNavOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (search.trim()) {
|
||||
router.push(`/tools?search=${encodeURIComponent(search.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveTab = (parentId: number, children: FlatCategory[]) => {
|
||||
if (activeTabs[parentId]) return activeTabs[parentId];
|
||||
return "all";
|
||||
};
|
||||
|
||||
const setActiveTab = (parentId: number, slug: string) => {
|
||||
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
|
||||
};
|
||||
|
||||
const groupedCategories = parentCategories.map((parent) => {
|
||||
const ownCat = categories.find((c) => c.id === parent.id);
|
||||
const children = categories.filter((c) => c.parentId === parent.id);
|
||||
return { parent, ownCat, children };
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1 space-y-10">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSidebar = () => (
|
||||
<nav className="space-y-0.5">
|
||||
<div className="relative mb-3">
|
||||
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="搜索工具..."
|
||||
className="input-field-compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/40 my-2" />
|
||||
|
||||
{parentCategories.map((p) => {
|
||||
const isActive = activeSection === `cat-${p.slug}`;
|
||||
const ownCount = categories.find((c) => c.id === p.id)?.toolCount || 0;
|
||||
const childCount = categories
|
||||
.filter((c) => c.parentId === p.id)
|
||||
.reduce((s, c) => s + c.toolCount, 0);
|
||||
const totalCount = ownCount + childCount;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => scrollToSection(`cat-${p.slug}`)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
|
||||
isActive
|
||||
? "bg-primary/10 font-semibold text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{p.name}</span>
|
||||
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
|
||||
isActive ? "text-primary/70" : "text-muted-foreground/50"
|
||||
}`}>{totalCount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border/40 my-2" />
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
||||
{categories.length} 个分类 · {totalTools} 个工具
|
||||
</p>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
|
||||
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-5 flex items-center gap-3 lg:hidden">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="搜索AI工具..."
|
||||
className="input-field !pl-10 !h-10 !rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
|
||||
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
||||
aria-label="分类导航"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileNavOpen && (
|
||||
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HeroBanner
|
||||
title="追光"
|
||||
highlight="AI"
|
||||
highlightColor="blue"
|
||||
starStyle="cosmos"
|
||||
stats={[
|
||||
{ label: "AI工具", value: totalTools, unit: "个" },
|
||||
{ label: "分类", value: categories.length, unit: "个" },
|
||||
]}
|
||||
slogan="汇聚全球优质AI工具,赋能每一个创新者"
|
||||
subSlogan="发现、探索、使用 — 让人工智能触手可及"
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
{groupedCategories.map(({ parent, ownCat, children }) => {
|
||||
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
|
||||
if (allCatsInGroup.length === 0) return null;
|
||||
|
||||
const hasChildren = children.length > 0;
|
||||
const activeTab = getActiveTab(parent.id, children);
|
||||
const totalCount = allCatsInGroup.reduce((s, c) => s + c.toolCount, 0);
|
||||
|
||||
const allTools = allCatsInGroup.flatMap((c) => c.tools);
|
||||
const displayTools = activeTab === "all"
|
||||
? allTools
|
||||
: children.find((c) => c.slug === activeTab)?.tools || ownCat?.tools || [];
|
||||
const maxDisplay = 8;
|
||||
const visibleTools = displayTools.slice(0, maxDisplay);
|
||||
const hasMore = displayTools.length > maxDisplay;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={`cat-${parent.slug}`}
|
||||
id={`cat-${parent.slug}`}
|
||||
ref={(el) => { sectionRefs.current[`cat-${parent.slug}`] = el; }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold text-primary tabular-nums">
|
||||
{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setActiveTab(parent.id, "all")}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
|
||||
activeTab === "all"
|
||||
? "bg-primary text-white shadow-sm shadow-primary/25"
|
||||
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
<span className={`text-[9px] tabular-nums ${
|
||||
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
|
||||
}`}>{totalCount}</span>
|
||||
</button>
|
||||
{children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => setActiveTab(parent.id, child.slug)}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
|
||||
activeTab === child.slug
|
||||
? "bg-primary text-white shadow-sm shadow-primary/25"
|
||||
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
{child.name}
|
||||
<span className={`text-[9px] tabular-nums ${
|
||||
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
|
||||
}`}>{child.toolCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleTools.length > 0 ? (
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{visibleTools.map((tool) => (
|
||||
<Card
|
||||
key={tool.id}
|
||||
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
|
||||
onClick={() => router.push(`/tools/${tool.slug}`)}
|
||||
>
|
||||
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/30 group-hover:shadow-sm"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-xs font-semibold text-primary transition-all duration-300 group-hover:scale-105 ${tool.logoUrl ? 'hidden' : ''}`}>
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
|
||||
{tool.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
|
||||
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
|
||||
{tool.description || "暂无简介"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
|
||||
敬请期待
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="mt-3 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
const cat = activeTab === "all"
|
||||
? parent.slug
|
||||
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
|
||||
router.push(`/categories/${cat}`);
|
||||
}}
|
||||
>
|
||||
查看全部 {displayTools.length} 个 →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
import { ReviewSection } from "@/components/common/ReviewSection";
|
||||
|
||||
const SkillReviewCard = dynamic(
|
||||
() => import("@/components/common/SkillReviewCard"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface SkillDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
sourceUrl: string;
|
||||
sourceType: string;
|
||||
rating: number;
|
||||
features: unknown;
|
||||
tags: unknown;
|
||||
viewCount: number;
|
||||
favorited?: boolean;
|
||||
favoriteCount?: number;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const sourceTypeLabel: Record<string, string> = {
|
||||
gitee: "Gitee",
|
||||
gitcode: "GitCode",
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab",
|
||||
huggingface: "HuggingFace",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const sourceTypeColor: Record<string, string> = {
|
||||
gitee: "bg-red-100 text-red-700 border border-red-200",
|
||||
gitcode: "bg-orange-100 text-orange-700 border border-orange-200",
|
||||
github: "bg-slate-100 text-slate-700 border border-slate-200",
|
||||
gitlab: "bg-purple-100 text-purple-700 border border-purple-200",
|
||||
huggingface: "bg-yellow-100 text-yellow-700 border border-yellow-200",
|
||||
other: "bg-secondary text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
const tagColors = [
|
||||
"border-violet-200 bg-violet-50 text-violet-700",
|
||||
"border-blue-200 bg-blue-50 text-blue-700",
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
"border-amber-200 bg-amber-50 text-amber-700",
|
||||
"border-pink-200 bg-pink-50 text-pink-700",
|
||||
"border-cyan-200 bg-cyan-50 text-cyan-700",
|
||||
];
|
||||
|
||||
export default function SkillDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
const { status } = useSession();
|
||||
|
||||
const [skill, setSkill] = useState<SkillDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/skills/${slug}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("技能不存在");
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setSkill(data);
|
||||
if (status === "authenticated" && data?.id) {
|
||||
fetch("/api/user/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ skillId: data.id }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug, status]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-4 w-20 rounded bg-muted/40" />
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex gap-6">
|
||||
<div className="h-20 w-20 rounded-2xl bg-muted/40" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-8 w-56 rounded bg-muted/40" />
|
||||
<div className="h-5 w-40 rounded bg-muted/30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !skill) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col items-center px-4 py-24 sm:px-6 lg:px-8">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-lg font-medium text-muted-foreground">{error || "技能不存在"}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const features = Array.isArray(skill.features) ? skill.features : [];
|
||||
const tags = Array.isArray(skill.tags) ? skill.tags : [];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-20 left-1/4 h-[400px] w-[600px] rounded-full bg-violet-500/5 blur-[120px]" />
|
||||
<div className="absolute top-40 right-0 h-[300px] w-[300px] rounded-full bg-purple-500/4 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
|
||||
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<button onClick={() => router.push("/skills")} className="transition-colors hover:text-foreground">技能库</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-foreground truncate max-w-[200px]">{skill.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-white p-6 sm:p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_4px_12px_rgba(0,0,0,0.04)]">
|
||||
<div className="relative z-10 flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||
<div className="shrink-0">
|
||||
{skill.logoUrl ? (
|
||||
<img
|
||||
src={skill.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-2xl border border-border/50 bg-white object-contain p-1.5 shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-gradient-to-br from-violet-100 to-violet-50 text-3xl font-bold text-violet-700 shadow-md">
|
||||
{skill.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{skill.name}</h1>
|
||||
<FavoriteButton type="skill" id={skill.id} initialFavorited={skill.favorited ?? false} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{skill.category && (
|
||||
<button
|
||||
onClick={() => router.push(`/skills?category=${skill.category!.slug}`)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground transition-all duration-200 hover:border-violet-300/50 hover:text-foreground hover:bg-white hover:shadow-sm"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
{skill.category.name}
|
||||
</button>
|
||||
)}
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${sourceTypeColor[skill.sourceType] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
{sourceTypeLabel[skill.sourceType] || skill.sourceType}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
{skill.viewCount} 次浏览
|
||||
</span>
|
||||
{skill.rating > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-lg border border-amber-200/60 bg-amber-50 px-2.5 py-1 text-xs text-amber-700">
|
||||
{Array.from({ length: 5 }).map((_, si) => (
|
||||
<svg key={si} width="10" height="10" viewBox="0 0 24 24" fill={si < Math.round(skill.rating) ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
))}
|
||||
<span className="ml-0.5 font-medium">{skill.rating.toFixed(1)}</span>
|
||||
</span>
|
||||
)}
|
||||
{skill.favoriteCount !== undefined && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
|
||||
{skill.favoriteCount} 人收藏
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={skill.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:block"
|
||||
>
|
||||
<Button className="gap-2 bg-violet-600 hover:bg-violet-500 glow-sm">
|
||||
访问项目
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-4 sm:hidden">
|
||||
<a href={skill.sourceUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<Button className="w-full gap-2 bg-violet-600 hover:bg-violet-500 glow-sm">
|
||||
访问项目
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-violet-100">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
简介
|
||||
</h2>
|
||||
<p className="whitespace-pre-wrap leading-relaxed text-muted-foreground/80 text-[15px]">
|
||||
{skill.description || "暂无简介"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{features.length > 0 && (
|
||||
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
功能特性
|
||||
</h2>
|
||||
<ul className="grid gap-2.5 sm:grid-cols-2">
|
||||
{features.map((f: string, i: number) => (
|
||||
<li
|
||||
key={i}
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/40 bg-white px-4 py-3 text-sm text-muted-foreground transition-all duration-200 hover:bg-white hover:border-border/60 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 transition-colors group-hover:bg-emerald-200">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="3" />
|
||||
</svg>
|
||||
</div>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-purple-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
标签
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((t: string, i: number) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-200 hover:scale-105 ${tagColors[i % tagColors.length]}`}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SkillReviewCard slug={slug} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-6">
|
||||
<ReviewSection type="skill" targetId={skill.id} showRating={false} title="使用体验" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between rounded-xl border border-border/50 bg-white px-5 py-3.5 text-xs text-muted-foreground/70 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
收录时间: {new Date(skill.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
{skill.updatedAt !== skill.createdAt && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="12" height="12" 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>
|
||||
更新时间: {new Date(skill.updatedAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
import { HeroBanner } from "@/components/common/HeroBanner";
|
||||
|
||||
interface Skill {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
sourceUrl: string;
|
||||
sourceType: string;
|
||||
rating: number;
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
interface FlatSkillCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
parentId: number | null;
|
||||
skillCount: number;
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
interface ParentSkillCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
const sourceTypeLabel: Record<string, string> = {
|
||||
gitee: "Gitee",
|
||||
gitcode: "GitCode",
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab",
|
||||
huggingface: "HuggingFace",
|
||||
marketplace: "Marketplace",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const sourceTypeColor: Record<string, string> = {
|
||||
gitee: "bg-red-100 text-red-700 border border-red-200",
|
||||
gitcode: "bg-orange-100 text-orange-700 border border-orange-200",
|
||||
github: "bg-slate-100 text-slate-700 border border-slate-200",
|
||||
gitlab: "bg-purple-100 text-purple-700 border border-purple-200",
|
||||
huggingface: "bg-yellow-100 text-yellow-700 border border-yellow-200",
|
||||
marketplace: "bg-blue-100 text-blue-700 border border-blue-200",
|
||||
other: "bg-secondary text-muted-foreground",
|
||||
};
|
||||
|
||||
export default function SkillsHomePage() {
|
||||
const router = useRouter();
|
||||
const [categories, setCategories] = useState<FlatSkillCategory[]>([]);
|
||||
const [parentCategories, setParentCategories] = useState<ParentSkillCategory[]>([]);
|
||||
const [totalTools, setTotalTools] = useState(0);
|
||||
const [totalSkills, setTotalSkills] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<string>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
|
||||
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/homepage?type=skills")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setCategories(data.categories || []);
|
||||
setParentCategories(data.parentCategories || []);
|
||||
setTotalTools(data.totalTools || 0);
|
||||
setTotalSkills(data.totalSkills || 0);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (categories.length === 0) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible.length > 0) setActiveSection(visible[0].target.id);
|
||||
},
|
||||
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
|
||||
);
|
||||
Object.values(sectionRefs.current).forEach((el) => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [categories]);
|
||||
|
||||
const scrollToSection = useCallback((id: string) => {
|
||||
const el = sectionRefs.current[id];
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 80;
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
}
|
||||
setMobileNavOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (search.trim()) {
|
||||
router.push(`/skills?search=${encodeURIComponent(search.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveTab = (parentId: number, children: FlatSkillCategory[]) => {
|
||||
if (activeTabs[parentId]) return activeTabs[parentId];
|
||||
return "all";
|
||||
};
|
||||
|
||||
const setActiveTab = (parentId: number, slug: string) => {
|
||||
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
|
||||
};
|
||||
|
||||
const groupedCategories = parentCategories.map((parent) => {
|
||||
const ownCat = categories.find((c) => c.id === parent.id);
|
||||
const children = categories.filter((c) => c.parentId === parent.id);
|
||||
return { parent, ownCat, children };
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1 space-y-10">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSidebar = () => (
|
||||
<nav className="space-y-0.5">
|
||||
<div className="relative mb-3">
|
||||
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="搜索技能..."
|
||||
className="input-field-compact !focus:border-violet-500/50 !focus:ring-violet-500/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/40 my-2" />
|
||||
|
||||
{parentCategories.map((p) => {
|
||||
const isActive = activeSection === `skill-${p.slug}`;
|
||||
const ownCount = categories.find((c) => c.id === p.id)?.skillCount || 0;
|
||||
const childCount = categories
|
||||
.filter((c) => c.parentId === p.id)
|
||||
.reduce((s, c) => s + c.skillCount, 0);
|
||||
const totalCount = ownCount + childCount;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => scrollToSection(`skill-${p.slug}`)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
|
||||
isActive
|
||||
? "bg-violet-100 font-semibold text-violet-700 shadow-sm"
|
||||
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{p.name}</span>
|
||||
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
|
||||
isActive ? "text-violet-600/70" : "text-muted-foreground/50"
|
||||
}`}>{totalCount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border/40 my-2" />
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
||||
{categories.length} 个分类 · {totalSkills} 个技能
|
||||
</p>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
|
||||
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-5 flex items-center gap-3 lg:hidden">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="搜索AI技能..."
|
||||
className="input-field !pl-10 !h-10 !rounded-xl !focus:border-violet-500/50 !focus:ring-violet-500/15"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
|
||||
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
||||
aria-label="分类导航"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileNavOpen && (
|
||||
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HeroBanner
|
||||
title="追光"
|
||||
highlight="Skills"
|
||||
highlightColor="violet"
|
||||
starStyle="aurora"
|
||||
stats={[
|
||||
{ label: "AI技能", value: totalSkills, unit: "个" },
|
||||
{ label: "分类", value: categories.length, unit: "个" },
|
||||
]}
|
||||
slogan="开源AI技能生态,构建智能未来"
|
||||
subSlogan="国内优质AI开源项目,一站式发现与学习"
|
||||
/>
|
||||
|
||||
{groupedCategories.map(({ parent, ownCat, children }) => {
|
||||
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatSkillCategory[];
|
||||
if (allCatsInGroup.length === 0) return null;
|
||||
|
||||
const hasChildren = children.length > 0;
|
||||
const activeTab = getActiveTab(parent.id, children);
|
||||
const totalCount = allCatsInGroup.reduce((s, c) => s + c.skillCount, 0);
|
||||
|
||||
const allSkills = allCatsInGroup.flatMap((c) => c.skills);
|
||||
const displaySkills = activeTab === "all"
|
||||
? allSkills
|
||||
: children.find((c) => c.slug === activeTab)?.skills || ownCat?.skills || [];
|
||||
const maxDisplay = 8;
|
||||
const visibleSkills = displaySkills.slice(0, maxDisplay);
|
||||
const hasMore = displaySkills.length > maxDisplay;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={`skill-${parent.slug}`}
|
||||
id={`skill-${parent.slug}`}
|
||||
ref={(el) => { sectionRefs.current[`skill-${parent.slug}`] = el; }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
|
||||
<span className="rounded-full bg-violet-100 px-2.5 py-0.5 text-[10px] font-semibold text-violet-700 tabular-nums">
|
||||
{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setActiveTab(parent.id, "all")}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
|
||||
activeTab === "all"
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
|
||||
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
<span className={`text-[9px] tabular-nums ${
|
||||
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
|
||||
}`}>{totalCount}</span>
|
||||
</button>
|
||||
{children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => setActiveTab(parent.id, child.slug)}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
|
||||
activeTab === child.slug
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
|
||||
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
{child.name}
|
||||
<span className={`text-[9px] tabular-nums ${
|
||||
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
|
||||
}`}>{child.skillCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleSkills.length > 0 ? (
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{visibleSkills.map((skill) => (
|
||||
<Card
|
||||
key={skill.id}
|
||||
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-violet-300/50 hover:shadow-[0_8px_24px_rgba(124,58,237,0.1),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
|
||||
onClick={() => router.push(`/skills/${skill.slug}`)}
|
||||
>
|
||||
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
<FavoriteButton type="skill" id={skill.id} initialFavorited={false} />
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{skill.logoUrl ? (
|
||||
<img
|
||||
src={skill.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-violet-300/50 group-hover:shadow-sm"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-violet-100 to-violet-50 text-xs font-semibold text-violet-700 transition-all duration-300 group-hover:scale-105 ${skill.logoUrl ? 'hidden' : ''}`}>
|
||||
{skill.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground group-hover:text-violet-700 transition-colors duration-200">
|
||||
{skill.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${sourceTypeColor[skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
|
||||
{sourceTypeLabel[skill.sourceType] || skill.sourceType}
|
||||
</span>
|
||||
{skill.rating > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[9px] text-amber-600/80">
|
||||
{Array.from({ length: 5 }).map((_, si) => (
|
||||
<svg key={si} width="8" height="8" viewBox="0 0 24 24" fill={si < Math.round(skill.rating) ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
))}
|
||||
<span className="ml-0.5">{skill.rating.toFixed(1)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
|
||||
{skill.description || "暂无简介"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
|
||||
敬请期待
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="mt-3 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
const cat = activeTab === "all"
|
||||
? parent.slug
|
||||
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
|
||||
router.push(`/skills?category=${cat}`);
|
||||
}}
|
||||
>
|
||||
查看全部 {displaySkills.length} 个 →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
import { ReviewSection } from "@/components/common/ReviewSection";
|
||||
|
||||
interface ToolDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
pricingDetail: string | null;
|
||||
features: unknown;
|
||||
tags: unknown;
|
||||
viewCount: number;
|
||||
favorited?: boolean;
|
||||
favoriteCount?: number;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
paid: "付费",
|
||||
subscription: "订阅",
|
||||
};
|
||||
|
||||
const pricingColor: Record<string, string> = {
|
||||
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
|
||||
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
|
||||
paid: "bg-amber-50 text-amber-700 border border-amber-200",
|
||||
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
|
||||
};
|
||||
|
||||
const pricingIcon: Record<string, string> = {
|
||||
free: "M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z",
|
||||
freemium: "M13 10V3L4 14h7v7l9-11h-7z",
|
||||
paid: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
subscription: "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",
|
||||
};
|
||||
|
||||
const tagColors = [
|
||||
"border-blue-200 bg-blue-50 text-blue-700",
|
||||
"border-purple-200 bg-purple-50 text-purple-700",
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
"border-amber-200 bg-amber-50 text-amber-700",
|
||||
"border-pink-200 bg-pink-50 text-pink-700",
|
||||
"border-cyan-200 bg-cyan-50 text-cyan-700",
|
||||
"border-indigo-200 bg-indigo-50 text-indigo-700",
|
||||
"border-rose-200 bg-rose-50 text-rose-700",
|
||||
];
|
||||
|
||||
export default function ToolDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
const { status } = useSession();
|
||||
|
||||
const [tool, setTool] = useState<ToolDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/tools/${slug}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("工具不存在");
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setTool(data);
|
||||
if (status === "authenticated" && data?.id) {
|
||||
fetch("/api/user/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ toolId: data.id }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug, status]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-4 w-20 rounded bg-muted/40" />
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex gap-6">
|
||||
<div className="h-20 w-20 rounded-2xl bg-muted/40" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-8 w-56 rounded bg-muted/40" />
|
||||
<div className="h-5 w-40 rounded bg-muted/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 space-y-2">
|
||||
<div className="h-4 w-full rounded bg-muted/30" />
|
||||
<div className="h-4 w-3/4 rounded bg-muted/30" />
|
||||
<div className="h-4 w-1/2 rounded bg-muted/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !tool) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col items-center px-4 py-24 sm:px-6 lg:px-8">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-lg font-medium text-muted-foreground">{error || "工具不存在"}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const features = Array.isArray(tool.features) ? tool.features : [];
|
||||
const tags = Array.isArray(tool.tags) ? tool.tags : [];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-20 left-1/4 h-[400px] w-[600px] rounded-full bg-primary/5 blur-[120px]" />
|
||||
<div className="absolute top-40 right-0 h-[300px] w-[300px] rounded-full bg-purple-500/4 blur-[80px]" />
|
||||
<div className="absolute bottom-20 -left-20 h-[250px] w-[250px] rounded-full bg-cyan-500/3 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
|
||||
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<button onClick={() => router.push("/tools")} className="transition-colors hover:text-foreground">全部工具</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-foreground truncate max-w-[200px]">{tool.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-white p-6 sm:p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_4px_12px_rgba(0,0,0,0.04)]">
|
||||
<div className="relative z-10 flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||
<div className="shrink-0">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-2xl border border-border/50 bg-white object-contain p-1.5 shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-3xl font-bold text-primary shadow-md">
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{tool.name}</h1>
|
||||
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{tool.category && (
|
||||
<button
|
||||
onClick={() => router.push(`/categories/${tool.category!.slug}`)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground transition-all duration-200 hover:border-primary/30 hover:text-foreground hover:bg-white hover:shadow-sm"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
||||
{tool.category.name}
|
||||
</button>
|
||||
)}
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={pricingIcon[tool.pricingModel] || "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"} />
|
||||
</svg>
|
||||
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
||||
</span>
|
||||
{tool.pricingDetail && (
|
||||
<span className="text-xs text-muted-foreground/60">{tool.pricingDetail}</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
{tool.viewCount} 次浏览
|
||||
</span>
|
||||
{tool.favoriteCount !== undefined && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
|
||||
{tool.favoriteCount} 人收藏
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={tool.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:block"
|
||||
>
|
||||
<Button className="gap-2 glow-sm">
|
||||
访问官网
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-4 sm:hidden">
|
||||
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<Button className="w-full gap-2 glow-sm">
|
||||
访问官网
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
简介
|
||||
</h2>
|
||||
<p className="whitespace-pre-wrap leading-relaxed text-muted-foreground/80 text-[15px]">
|
||||
{tool.description || "暂无简介"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{features.length > 0 && (
|
||||
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
功能特性
|
||||
</h2>
|
||||
<ul className="grid gap-2.5 sm:grid-cols-2">
|
||||
{features.map((f: string, i: number) => (
|
||||
<li
|
||||
key={i}
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/40 bg-white px-4 py-3 text-sm text-muted-foreground transition-all duration-200 hover:bg-white hover:border-border/60 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 transition-colors group-hover:bg-emerald-200">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" className="text-emerald-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="3" />
|
||||
</svg>
|
||||
</div>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-purple-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
标签
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((t: string, i: number) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-200 hover:scale-105 ${tagColors[i % tagColors.length]}`}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-6">
|
||||
<ReviewSection type="tool" targetId={tool.id} showRating={true} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between rounded-xl border border-border/50 bg-white px-5 py-3.5 text-xs text-muted-foreground/70 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
收录时间: {new Date(tool.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
{tool.updatedAt !== tool.createdAt && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="12" height="12" 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>
|
||||
更新时间: {new Date(tool.updatedAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
children: Category[];
|
||||
}
|
||||
|
||||
interface Tool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
viewCount: number;
|
||||
favorited?: boolean;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
paid: "付费",
|
||||
subscription: "订阅",
|
||||
};
|
||||
|
||||
const pricingColor: Record<string, string> = {
|
||||
free: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
|
||||
freemium: "bg-blue-500/15 text-blue-400 border-blue-500/20",
|
||||
paid: "bg-amber-500/15 text-amber-400 border-amber-500/20",
|
||||
subscription: "bg-purple-500/15 text-purple-400 border-purple-500/20",
|
||||
};
|
||||
|
||||
const pricingDot: Record<string, string> = {
|
||||
free: "bg-emerald-400",
|
||||
freemium: "bg-blue-400",
|
||||
paid: "bg-amber-400",
|
||||
subscription: "bg-purple-400",
|
||||
};
|
||||
|
||||
const pricingOptions = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "free", label: "免费" },
|
||||
{ value: "freemium", label: "免费增值" },
|
||||
{ value: "paid", label: "付费" },
|
||||
{ value: "subscription", label: "订阅" },
|
||||
];
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
"文本生成": "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7",
|
||||
"图像生成": "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
|
||||
"视频生成": "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z",
|
||||
"音频处理": "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z",
|
||||
"代码助手": "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
|
||||
"办公效率": "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
|
||||
"AI 角色": "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
|
||||
"设计工具": "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
|
||||
"数据分析": "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
|
||||
"内容检测": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
|
||||
};
|
||||
|
||||
export default function ToolsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const search = searchParams.get("search") || "";
|
||||
const categoryId = searchParams.get("categoryId") || "";
|
||||
const pricingModel = searchParams.get("pricingModel") || "";
|
||||
const pageSize = 12;
|
||||
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/categories")
|
||||
.then((r) => r.json())
|
||||
.then(setCategories)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString(),
|
||||
});
|
||||
if (search) params.set("search", search);
|
||||
if (categoryId) params.set("categoryId", categoryId);
|
||||
if (pricingModel) params.set("pricingModel", pricingModel);
|
||||
|
||||
const res = await fetch(`/api/tools?${params}`);
|
||||
const data = await res.json();
|
||||
setTools(data.tools || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
setTools([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, categoryId, pricingModel]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
}, [fetchTools]);
|
||||
|
||||
const updateParams = (updates: Record<string, string>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const activeCategoryName = categoryId
|
||||
? categories.find((c) => c.id.toString() === categoryId)?.name || ""
|
||||
: "全部分类";
|
||||
|
||||
const activePricingName = pricingModel
|
||||
? pricingOptions.find((p) => p.value === pricingModel)?.label || ""
|
||||
: "";
|
||||
|
||||
const Sidebar = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
分类筛选
|
||||
</h3>
|
||||
<nav className="space-y-0.5">
|
||||
<button
|
||||
onClick={() => updateParams({ categoryId: "" })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
!categoryId
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
|
||||
!categoryId ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
|
||||
}`}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
全部分类
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => updateParams({ categoryId: cat.id.toString() })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
categoryId === cat.id.toString()
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
|
||||
categoryId === cat.id.toString() ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
|
||||
}`}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={categoryIcons[cat.name] || "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"} />
|
||||
</svg>
|
||||
</div>
|
||||
{cat.name}
|
||||
{categoryId === cat.id.toString() && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
定价模型
|
||||
</h3>
|
||||
<nav className="space-y-0.5">
|
||||
{pricingOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => updateParams({ pricingModel: opt.value })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
pricingModel === opt.value
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{opt.value ? (
|
||||
<span className={`h-2 w-2 rounded-full transition-all duration-200 ${
|
||||
pricingModel === opt.value ? "scale-125" : pricingDot[opt.value] || "bg-muted-foreground/40"
|
||||
}`} style={{ backgroundColor: pricingModel === opt.value ? undefined : undefined }} />
|
||||
) : (
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-muted/50 group-hover:bg-accent">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{opt.label}
|
||||
{pricingModel === opt.value && opt.value && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
|
||||
|
||||
<div className="rounded-xl border border-border/50 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
💡 点击分类或定价模型进行筛选,支持组合筛选
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
|
||||
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-foreground">全部工具</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-bold tracking-tight">
|
||||
探索<span className="gradient-text">AI</span>工具
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
发现最适合你的AI工具,提升工作效率与创造力
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<aside className="hidden w-60 shrink-0 md:block">
|
||||
<div className="sticky top-24 rounded-xl border border-border/50 bg-white p-4 scrollbar-thin shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && updateParams({ search: searchInput })
|
||||
}
|
||||
placeholder="搜索AI工具..."
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-muted/50 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(""); updateParams({ search: "" }); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => updateParams({ search: searchInput })}
|
||||
className="h-11 gap-2 glow-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
搜索
|
||||
</Button>
|
||||
<button
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/50 text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground md:hidden"
|
||||
onClick={() => setMobileFilterOpen(!mobileFilterOpen)}
|
||||
aria-label="筛选"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileFilterOpen && (
|
||||
<div className="mb-6 rounded-xl border border-border/50 bg-white p-4 md:hidden animate-slide-down shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<Sidebar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{search ? `搜索 "${search}"` : activeCategoryName}
|
||||
</span>
|
||||
{activePricingName && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="text-sm text-muted-foreground">{activePricingName}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="text-sm font-semibold text-foreground">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">个工具</span>
|
||||
</div>
|
||||
{(categoryId || pricingModel || search) && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(""); router.push("/tools"); }}
|
||||
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-base font-medium text-muted-foreground">暂无匹配的工具</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground/50">试试其他关键词或筛选条件</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => { setSearchInput(""); router.push("/tools"); }}>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tools.map((tool, i) => (
|
||||
<Card
|
||||
key={tool.id}
|
||||
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
|
||||
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
|
||||
onClick={() => router.push(`/tools/${tool.slug}`)}
|
||||
>
|
||||
<div className="absolute top-2 right-2 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
|
||||
</div>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
||||
</span>
|
||||
{tool.category && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{tool.category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
|
||||
{tool.description || "暂无简介"}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground/40">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
{tool.viewCount}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-10 flex items-center justify-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", (page - 1).toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className="gap-1.5 rounded-lg"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (page <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (page >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = page - 2 + i;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", pageNum.toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
page === pageNum
|
||||
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", (page + 1).toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className="gap-1.5 rounded-lg"
|
||||
>
|
||||
下一页
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
|
||||
interface FavoriteItem {
|
||||
id: number;
|
||||
toolId: number | null;
|
||||
skillId: number | null;
|
||||
createdAt: string;
|
||||
tool?: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
} | null;
|
||||
skill?: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
sourceUrl: string;
|
||||
sourceType: string;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
paid: "付费",
|
||||
subscription: "订阅",
|
||||
};
|
||||
|
||||
const pricingColor: Record<string, string> = {
|
||||
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
|
||||
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
|
||||
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
|
||||
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
|
||||
};
|
||||
|
||||
const sourceTypeLabel: Record<string, string> = {
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab",
|
||||
huggingface: "HuggingFace",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const sourceTypeColor: Record<string, string> = {
|
||||
github: "bg-slate-50 text-slate-700",
|
||||
gitlab: "bg-orange-50 text-orange-700",
|
||||
huggingface: "bg-yellow-50 text-yellow-700",
|
||||
other: "bg-secondary text-muted-foreground",
|
||||
};
|
||||
|
||||
type FilterType = "all" | "tool" | "skill";
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [favorites, setFavorites] = useState<FavoriteItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (status === "unauthenticated") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
fetch(`/api/user/favorites?type=${filter}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setFavorites(data.favorites || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
setFavorites([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [status, filter]);
|
||||
|
||||
const handleUnfavorite = (favId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.id !== favId));
|
||||
setTotal((prev) => prev - 1);
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<Card className="w-full max-w-md p-8 text-center rounded-2xl border border-border/60 bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold">请先登录</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
登录后即可查看和管理你的收藏
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="mt-6 w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
去登录
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterButtons: { key: FilterType; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "tool", label: "工具" },
|
||||
{ key: "skill", label: "技能" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
|
||||
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">
|
||||
首页
|
||||
</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-foreground">我的收藏</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
我的<span className="gradient-text">收藏</span>
|
||||
</h1>
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
|
||||
{total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
管理你收藏的AI工具和技能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
{filterButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.key}
|
||||
onClick={() => setFilter(btn.key)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||
filter === btn.key
|
||||
? "bg-primary text-white shadow-sm shadow-primary/25"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground border border-border/60"
|
||||
}`}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : favorites.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-base font-medium text-muted-foreground">
|
||||
{filter === "all" ? "暂无收藏" : `暂无收藏的${filter === "tool" ? "工具" : "技能"}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground/50">
|
||||
去发现并收藏你喜欢的AI工具和技能吧
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(filter === "skill" ? "/skills" : "/tools")}
|
||||
className="mt-4 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
|
||||
>
|
||||
浏览{filter === "skill" ? "技能" : "工具"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{favorites.map((fav, i) => {
|
||||
const isTool = !!fav.tool;
|
||||
const item = fav.tool || fav.skill;
|
||||
if (!item) return null;
|
||||
|
||||
const href = isTool
|
||||
? `/tools/${(fav.tool!).slug}`
|
||||
: `/skills/${(fav.skill!).slug}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={fav.id}
|
||||
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
|
||||
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
|
||||
onClick={() => router.push(href)}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 right-2 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnfavorite(fav.id);
|
||||
}}
|
||||
>
|
||||
<FavoriteButton
|
||||
type={isTool ? "tool" : "skill"}
|
||||
id={item.id}
|
||||
initialFavorited={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
{item.logoUrl ? (
|
||||
<img
|
||||
src={item.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
|
||||
{item.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
|
||||
{item.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{isTool && fav.tool ? (
|
||||
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[fav.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
{pricingLabel[fav.tool.pricingModel] || fav.tool.pricingModel}
|
||||
</span>
|
||||
) : !isTool && fav.skill ? (
|
||||
<span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${sourceTypeColor[fav.skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
|
||||
{sourceTypeLabel[fav.skill.sourceType] || fav.skill.sourceType}
|
||||
</span>
|
||||
) : null}
|
||||
{item.category && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{item.category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
|
||||
{item.description || "暂无简介"}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className={`rounded-md px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
isTool
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-violet-100 text-violet-700"
|
||||
}`}>
|
||||
{isTool ? "工具" : "技能"}
|
||||
</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock } from "lucide-react";
|
||||
|
||||
function getTimeAgo(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return "刚刚";
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
if (days < 30) return `${days}天前`;
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [favoriteTotal, setFavoriteTotal] = useState(0);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState("");
|
||||
const [currentName, setCurrentName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [browsingHistory, setBrowsingHistory] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (status === "unauthenticated") return;
|
||||
|
||||
if (session?.user?.name) {
|
||||
setCurrentName(session.user.name);
|
||||
setNameValue(session.user.name);
|
||||
}
|
||||
|
||||
fetch("/api/user/favorites?type=all")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setFavoriteTotal(data.total || 0))
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/user/history")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setBrowsingHistory(data.history || []))
|
||||
.catch(() => {});
|
||||
}, [status, session]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<Card className="w-full max-w-md p-8 text-center rounded-2xl border border-border/60 bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold">请先登录</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
登录后即可查看个人中心
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="mt-6 w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
去登录
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/user/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: nameValue }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
setCurrentName(nameValue || "");
|
||||
setEditingName(false);
|
||||
} catch {
|
||||
setNameValue(currentName);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shortcuts = [
|
||||
{ icon: Heart, label: "我的收藏", desc: `${favoriteTotal} 个收藏`, href: "/user/favorites", color: "text-red-600 bg-red-50 border-red-200/60" },
|
||||
{ icon: Wrench, label: "浏览工具", desc: "发现AI工具", href: "/tools", color: "text-primary bg-primary/10 border-primary/20" },
|
||||
{ icon: Zap, label: "浏览技能", desc: "探索AI技能", href: "/skills", color: "text-violet-600 bg-violet-50 border-violet-200/60" },
|
||||
];
|
||||
|
||||
const initial = currentName
|
||||
? currentName.charAt(0).toUpperCase()
|
||||
: session?.user?.email?.charAt(0).toUpperCase() || "?";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
|
||||
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-foreground">个人中心</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="rounded-2xl border border-border/60 bg-white p-6 sm:p-8 mb-6 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_4px_12px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 text-3xl font-bold text-primary border border-primary/20 shrink-0">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="flex-1 text-center sm:text-left min-w-0">
|
||||
{editingName ? (
|
||||
<div className="flex items-center gap-2 justify-center sm:justify-start">
|
||||
<input
|
||||
type="text"
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveName()}
|
||||
maxLength={100}
|
||||
className="px-3 py-1.5 bg-white rounded-lg border border-border/70 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors w-48 text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleSaveName} disabled={saving} className="p-1.5 rounded-lg text-emerald-600 hover:bg-emerald-50 transition-colors">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => { setEditingName(false); setNameValue(currentName); }} className="p-1.5 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 justify-center sm:justify-start">
|
||||
<h1 className="text-2xl font-bold truncate">
|
||||
{currentName || "未设置昵称"}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setEditingName(true)}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title="修改昵称"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-muted-foreground truncate">
|
||||
{session?.user?.email}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start">
|
||||
<span className="rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{((session?.user as any)?.role === "admin") ? "管理员" : "用户"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{favoriteTotal} 个收藏
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3 mb-6">
|
||||
{shortcuts.map((s) => (
|
||||
<Card
|
||||
key={s.label}
|
||||
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)] hover:-translate-y-0.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
|
||||
onClick={() => router.push(s.href)}
|
||||
>
|
||||
<CardContent className="p-5 text-center">
|
||||
<div className={`mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-xl border ${s.color}`}>
|
||||
<s.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="font-medium text-sm">{s.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{s.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{browsingHistory.length > 0 && (
|
||||
<Card className="border-border/60 bg-white mb-6 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-sm">最近浏览</h2>
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{browsingHistory.map((item: any) => {
|
||||
const isTool = !!item.tool;
|
||||
const entity = item.tool || item.skill;
|
||||
if (!entity) return null;
|
||||
|
||||
const href = isTool ? `/tools/${entity.slug}` : `/skills/${entity.slug}`;
|
||||
const timeAgo = getTimeAgo(new Date(item.viewedAt));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex-shrink-0 w-[180px] cursor-pointer rounded-xl border border-border/50 bg-white p-3 transition-all duration-200 hover:border-border hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
|
||||
onClick={() => router.push(href)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{entity.logoUrl ? (
|
||||
<img src={entity.logoUrl} referrerPolicy="no-referrer" alt="" className="h-8 w-8 rounded-lg border border-border/40 object-contain p-0.5" />
|
||||
) : (
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
|
||||
{entity.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium text-foreground">{entity.name}</p>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className={`rounded px-1 py-0 text-[8px] font-medium ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
|
||||
{isTool ? "工具" : "技能"}
|
||||
</span>
|
||||
<span className="text-[8px] text-muted-foreground/50">{timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm text-muted-foreground transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
interface SidebarProps {
|
||||
search: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onSearchSubmit: () => void;
|
||||
navItems: { id: string; label: string; count: number }[];
|
||||
activeSection: string;
|
||||
onNavigate: (id: string) => void;
|
||||
totalItems: number;
|
||||
accentColor: string;
|
||||
accentBg: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const Sidebar = memo(function Sidebar({
|
||||
search,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
navItems,
|
||||
activeSection,
|
||||
onNavigate,
|
||||
totalItems,
|
||||
accentColor,
|
||||
accentBg,
|
||||
label,
|
||||
}: SidebarProps) {
|
||||
const activeClass = (id: string) =>
|
||||
activeSection === id
|
||||
? `bg-${accentColor}/10 font-medium text-${accentColor}`
|
||||
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground";
|
||||
|
||||
const countClass = (id: string) =>
|
||||
activeSection === id ? `text-${accentColor}/60` : "text-muted-foreground/40";
|
||||
|
||||
return (
|
||||
<nav className="space-y-4">
|
||||
<div className="relative mb-3">
|
||||
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSearchSubmit()}
|
||||
placeholder={`搜索${label}...`}
|
||||
className="h-9 w-full rounded-lg border border-border/60 bg-white pl-9 pr-3 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center gap-2 px-2">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`text-${accentColor}/70`}>
|
||||
<path d={accentBg} />
|
||||
</svg>
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-wider text-${accentColor}/70`}>{label}分类</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/40">{totalItems}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`group flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] transition-all duration-200 ${activeClass(item.id)}`}
|
||||
>
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${countClass(item.id)}`}>{item.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
});
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useOptimistic } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Heart } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoginModal } from "@/components/common/LoginModal";
|
||||
|
||||
interface Props {
|
||||
type: "tool" | "skill";
|
||||
id: number;
|
||||
initialFavorited?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FavoriteButton({ type, id, initialFavorited = false, className }: Props) {
|
||||
const { data: session, status } = useSession();
|
||||
const [optimisticFav, addOptimisticFav] = useOptimistic(initialFavorited);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (status === "unauthenticated" || !session) {
|
||||
setShowLoginModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = !optimisticFav;
|
||||
addOptimisticFav(newState);
|
||||
try {
|
||||
const res = await fetch("/api/user/favorites", {
|
||||
method: newState ? "POST" : "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [type === "tool" ? "toolId" : "skillId"]: id }),
|
||||
});
|
||||
if (!res.ok) throw new Error("操作失败");
|
||||
} catch {
|
||||
addOptimisticFav(!newState);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"p-1.5 rounded-full transition-all duration-200 hover:scale-110",
|
||||
optimisticFav
|
||||
? "text-red-500"
|
||||
: "text-gray-500 hover:text-red-500",
|
||||
className
|
||||
)}
|
||||
title={optimisticFav ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<Heart className={cn("w-5 h-5", optimisticFav && "fill-current animate-pulse")} />
|
||||
</button>
|
||||
<LoginModal open={showLoginModal} onClose={() => setShowLoginModal(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
|
||||
type StarStyle = "cosmos" | "aurora" | "dawn";
|
||||
|
||||
interface HeroBannerProps {
|
||||
title: string;
|
||||
highlight: string;
|
||||
highlightColor?: "blue" | "violet" | "amber";
|
||||
stats: { label: string; value: number | string; unit: string }[];
|
||||
slogan: string;
|
||||
subSlogan?: string;
|
||||
starStyle?: StarStyle;
|
||||
}
|
||||
|
||||
interface Star {
|
||||
x: number;
|
||||
y: number;
|
||||
baseX: number;
|
||||
baseY: number;
|
||||
size: number;
|
||||
alpha: number;
|
||||
alphaSpeed: number;
|
||||
twinkleOffset: number;
|
||||
driftOffset: number;
|
||||
driftOffset2: number;
|
||||
driftSpeed: number;
|
||||
}
|
||||
|
||||
interface ShootingStar {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
length: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function HeroBanner({
|
||||
title,
|
||||
highlight,
|
||||
highlightColor = "blue",
|
||||
stats,
|
||||
slogan,
|
||||
subSlogan,
|
||||
starStyle = "cosmos",
|
||||
}: HeroBannerProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const starsRef = useRef<Star[]>([]);
|
||||
const shootingStarsRef = useRef<ShootingStar[]>([]);
|
||||
const animationIdRef = useRef<number | null>(null);
|
||||
const timeRef = useRef(0);
|
||||
const lastShootingTimeRef = useRef(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isCosmos = starStyle === "cosmos";
|
||||
const isAurora = starStyle === "aurora";
|
||||
const isDawn = starStyle === "dawn";
|
||||
const STAR_COUNT = isCosmos ? 120 : isDawn ? 100 : 80;
|
||||
const SHOOTING_INTERVAL = 5000;
|
||||
|
||||
const createStars = useCallback((width: number, height: number) => {
|
||||
const arr: Star[] = [];
|
||||
for (let i = 0; i < STAR_COUNT; i++) {
|
||||
const bx = Math.random() * width;
|
||||
const by = Math.random() * height;
|
||||
arr.push({
|
||||
x: bx,
|
||||
y: by,
|
||||
baseX: bx,
|
||||
baseY: by,
|
||||
size: Math.random() * 1.8 + 0.5,
|
||||
alpha: Math.random() * 0.5 + 0.5,
|
||||
alphaSpeed: Math.random() * 0.08 + 0.05,
|
||||
twinkleOffset: Math.random() * Math.PI * 2,
|
||||
driftOffset: Math.random() * Math.PI * 2,
|
||||
driftOffset2: Math.random() * Math.PI * 2,
|
||||
driftSpeed: Math.random() * 0.04 + 0.02,
|
||||
});
|
||||
}
|
||||
starsRef.current = arr;
|
||||
}, [STAR_COUNT]);
|
||||
|
||||
const createShootingStar = useCallback((width: number, height: number) => {
|
||||
const startX = Math.random() * width * 0.7 + width * 0.05;
|
||||
const startY = Math.random() * height * 0.25;
|
||||
const speed = 3 + Math.random() * 3;
|
||||
const angle = Math.PI / 4 + (Math.random() - 0.5) * Math.PI / 8;
|
||||
|
||||
shootingStarsRef.current.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
length: 50 + Math.random() * 40,
|
||||
size: 1.2 + Math.random() * 0.8,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
if (mq.matches) return;
|
||||
|
||||
const resize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
createStars(rect.width, rect.height);
|
||||
};
|
||||
|
||||
resize();
|
||||
|
||||
const starColor = isCosmos
|
||||
? { r: 191, g: 219, b: 254 }
|
||||
: isDawn
|
||||
? { r: 253, g: 230, b: 138 }
|
||||
: { r: 210, g: 196, b: 255 };
|
||||
|
||||
const shootColor = isCosmos
|
||||
? { r: 200, g: 220, b: 255 }
|
||||
: isDawn
|
||||
? { r: 251, g: 191, b: 36 }
|
||||
: { r: 220, g: 210, b: 255 };
|
||||
|
||||
const animate = () => {
|
||||
timeRef.current += 0.016;
|
||||
const t = timeRef.current;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
for (const star of starsRef.current) {
|
||||
const fastTwinkle = 0.5 + Math.sin(t * star.alphaSpeed * 12 + star.twinkleOffset) * 0.5;
|
||||
const secondaryTwinkle = 0.7 + Math.sin(t * star.alphaSpeed * 7 + star.twinkleOffset * 1.5) * 0.3;
|
||||
const easedAlpha = fastTwinkle * fastTwinkle * (3 - 2 * fastTwinkle) * secondaryTwinkle;
|
||||
|
||||
const driftX = Math.sin(t * star.driftSpeed + star.driftOffset) * 8
|
||||
+ Math.cos(t * star.driftSpeed * 1.3 + star.driftOffset2) * 4;
|
||||
const driftY = Math.cos(t * star.driftSpeed * 0.8 + star.driftOffset * 1.2) * 6
|
||||
+ Math.sin(t * star.driftSpeed * 0.6 + star.driftOffset2 * 0.9) * 3;
|
||||
|
||||
star.x = star.baseX + driftX;
|
||||
star.y = star.baseY + driftY;
|
||||
|
||||
const currentAlpha = star.alpha * easedAlpha;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${currentAlpha})`;
|
||||
ctx.fill();
|
||||
|
||||
if (star.size > 1.2) {
|
||||
const glowSize = star.size * 2.5;
|
||||
const gradient = ctx.createRadialGradient(star.x, star.y, 0, star.x, star.y, glowSize);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${currentAlpha * 0.5})`);
|
||||
gradient.addColorStop(0.4, `rgba(${starColor.r}, ${starColor.g}, ${starColor.b}, ${currentAlpha * 0.2})`);
|
||||
gradient.addColorStop(1, `rgba(${starColor.r}, ${starColor.g}, ${starColor.b}, 0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(star.x, star.y, glowSize, 0, Math.PI * 2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastShootingTimeRef.current > SHOOTING_INTERVAL) {
|
||||
createShootingStar(w, h);
|
||||
lastShootingTimeRef.current = now;
|
||||
}
|
||||
|
||||
shootingStarsRef.current = shootingStarsRef.current.filter(ss => ss.life > 0);
|
||||
for (const ss of shootingStarsRef.current) {
|
||||
ss.x += ss.vx;
|
||||
ss.y += ss.vy;
|
||||
ss.life -= 0.008;
|
||||
|
||||
const tailX = ss.x - ss.vx * ss.length * 0.12;
|
||||
const tailY = ss.y - ss.vy * ss.length * 0.12;
|
||||
|
||||
const gradient = ctx.createLinearGradient(tailX, tailY, ss.x, ss.y);
|
||||
gradient.addColorStop(0, "rgba(255, 255, 255, 0)");
|
||||
gradient.addColorStop(0.3, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.3})`);
|
||||
gradient.addColorStop(0.7, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.7})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, ${ss.life})`);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tailX, tailY);
|
||||
ctx.lineTo(ss.x, ss.y);
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = ss.size * 1.5;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tailX, tailY);
|
||||
ctx.lineTo(ss.x, ss.y);
|
||||
ctx.strokeStyle = `rgba(255, 255, 255, ${ss.life * 0.6})`;
|
||||
ctx.lineWidth = ss.size * 0.5;
|
||||
ctx.stroke();
|
||||
|
||||
const headGlow = ctx.createRadialGradient(ss.x, ss.y, 0, ss.x, ss.y, ss.size * 3);
|
||||
headGlow.addColorStop(0, `rgba(255, 255, 255, ${ss.life})`);
|
||||
headGlow.addColorStop(0.4, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.5})`);
|
||||
headGlow.addColorStop(1, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, 0)`);
|
||||
ctx.beginPath();
|
||||
ctx.arc(ss.x, ss.y, ss.size * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = headGlow;
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
animationIdRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
lastShootingTimeRef.current = Date.now();
|
||||
animate();
|
||||
|
||||
const handleResize = () => resize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
if (animationIdRef.current !== null) {
|
||||
cancelAnimationFrame(animationIdRef.current);
|
||||
}
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [isCosmos, isDawn, createStars, createShootingStar]);
|
||||
|
||||
const highlightStyle: React.CSSProperties = isCosmos
|
||||
? { color: "#93c5fd" }
|
||||
: isDawn
|
||||
? { color: "#fcd34d" }
|
||||
: { color: "#c4b5fd" };
|
||||
|
||||
const bgGradient = isCosmos
|
||||
? "linear-gradient(135deg, #1e3a5f 0%, #1e40af 35%, #2563eb 65%, #1d4ed8 100%)"
|
||||
: isDawn
|
||||
? "linear-gradient(135deg, #78350f 0%, #92400e 25%, #b45309 55%, #a16207 100%)"
|
||||
: "linear-gradient(135deg, #4c1d95 0%, #5b21b6 30%, #7c3aed 60%, #6d28d9 100%)";
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full overflow-hidden rounded-2xl mb-6 star-banner">
|
||||
<div className="absolute inset-0" style={{ background: bgGradient }} />
|
||||
|
||||
{isCosmos ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: "radial-gradient(ellipse at 30% 50%, rgba(59,130,246,0.15) 0%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: "radial-gradient(ellipse at 80% 30%, rgba(96,165,250,0.10) 0%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]"
|
||||
viewBox="0 0 1000 300"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line x1="120" y1="60" x2="280" y2="90" stroke="#bfdbfe" strokeWidth="0.5" />
|
||||
<line x1="280" y1="90" x2="350" y2="50" stroke="#bfdbfe" strokeWidth="0.5" />
|
||||
<line x1="350" y1="50" x2="500" y2="80" stroke="#bfdbfe" strokeWidth="0.5" />
|
||||
<line x1="600" y1="40" x2="720" y2="70" stroke="#bfdbfe" strokeWidth="0.5" />
|
||||
<line x1="720" y1="70" x2="850" y2="55" stroke="#bfdbfe" strokeWidth="0.5" />
|
||||
<line x1="850" y1="55" x2="920" y2="90" stroke="#bfdbfe" strokeWidth="0.5" />
|
||||
<circle cx="120" cy="60" r="2" fill="#bfdbfe" />
|
||||
<circle cx="280" cy="90" r="1.5" fill="#bfdbfe" />
|
||||
<circle cx="350" cy="50" r="2" fill="#bfdbfe" />
|
||||
<circle cx="500" cy="80" r="1.5" fill="#bfdbfe" />
|
||||
<circle cx="600" cy="40" r="2" fill="#bfdbfe" />
|
||||
<circle cx="720" cy="70" r="1.5" fill="#bfdbfe" />
|
||||
<circle cx="850" cy="55" r="2" fill="#bfdbfe" />
|
||||
<circle cx="920" cy="90" r="1.5" fill="#bfdbfe" />
|
||||
</svg>
|
||||
</>
|
||||
) : isDawn ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: "radial-gradient(ellipse at 50% 80%, rgba(245,158,11,0.20) 0%, transparent 55%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: "radial-gradient(ellipse at 20% 30%, rgba(251,191,36,0.12) 0%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: "radial-gradient(ellipse at 80% 60%, rgba(217,119,6,0.10) 0%, transparent 45%)",
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.06]"
|
||||
viewBox="0 0 1000 300"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<ellipse cx="500" cy="280" rx="400" ry="60" stroke="#fcd34d" strokeWidth="0.5" fill="none" opacity="0.6" />
|
||||
<ellipse cx="500" cy="280" rx="300" ry="40" stroke="#fbbf24" strokeWidth="0.5" fill="none" opacity="0.4" />
|
||||
<line x1="500" y1="40" x2="500" y2="220" stroke="#fcd34d" strokeWidth="0.3" opacity="0.3" />
|
||||
<line x1="460" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
|
||||
<line x1="540" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
|
||||
<circle cx="500" cy="40" r="3" fill="#fcd34d" opacity="0.5" />
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: "radial-gradient(ellipse at 40% 40%, rgba(124,58,237,0.22) 0%, transparent 55%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: "radial-gradient(ellipse at 75% 60%, rgba(139,92,246,0.16) 0%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]"
|
||||
viewBox="0 0 1000 300"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<ellipse cx="500" cy="150" rx="200" ry="80" stroke="#c4b5fd" strokeWidth="0.5" fill="none" opacity="0.5" />
|
||||
<ellipse cx="500" cy="150" rx="140" ry="50" stroke="#a78bfa" strokeWidth="0.5" fill="none" opacity="0.4" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 2 }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-px"
|
||||
style={{
|
||||
background: isCosmos
|
||||
? "linear-gradient(90deg, transparent, rgba(96,165,250,0.5), rgba(59,130,246,0.4), transparent)"
|
||||
: isDawn
|
||||
? "linear-gradient(90deg, transparent, rgba(251,191,36,0.5), rgba(245,158,11,0.4), transparent)"
|
||||
: "linear-gradient(90deg, transparent, rgba(167,139,250,0.5), rgba(124,58,237,0.4), transparent)",
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative px-6 py-8 sm:px-8 sm:py-10 lg:px-10 lg:py-12" style={{ zIndex: 4 }}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`transition-all duration-700 ${
|
||||
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||
}`}
|
||||
>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-white" style={{ textShadow: "0 2px 16px rgba(0,0,0,0.3)" }}>
|
||||
{title}
|
||||
<span style={highlightStyle}>{highlight}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-3 transition-all duration-700 delay-150 ${
|
||||
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||
}`}
|
||||
>
|
||||
<p className="text-base sm:text-lg font-medium text-white/85 leading-relaxed">
|
||||
{slogan}
|
||||
</p>
|
||||
{subSlogan && (
|
||||
<p className="mt-1.5 text-sm text-white/55 leading-relaxed">
|
||||
{subSlogan}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-5 flex flex-wrap items-center gap-4 sm:gap-6 transition-all duration-700 delay-300 ${
|
||||
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||
}`}
|
||||
>
|
||||
{stats.map((stat, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-9 min-w-[2.25rem] px-2 rounded-lg text-sm font-bold tabular-nums"
|
||||
style={{
|
||||
background: isCosmos
|
||||
? "rgba(255,255,255,0.15)"
|
||||
: isDawn
|
||||
? "rgba(251,191,36,0.15)"
|
||||
: "rgba(167,139,250,0.15)",
|
||||
color: isCosmos ? "#fff" : isDawn ? "#fef3c7" : "#ddd6fe",
|
||||
border: isCosmos
|
||||
? "1px solid rgba(255,255,255,0.25)"
|
||||
: isDawn
|
||||
? "1px solid rgba(251,191,36,0.25)"
|
||||
: "1px solid rgba(167,139,250,0.25)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{typeof stat.value === "number" ? stat.value.toLocaleString() : stat.value}
|
||||
</span>
|
||||
<span className="text-sm text-white/70">
|
||||
{stat.unit}
|
||||
<span className="text-white/50 ml-0.5">{stat.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`hidden lg:flex items-center justify-center transition-all duration-700 delay-200 ${
|
||||
mounted ? "opacity-100 scale-100" : "opacity-0 scale-90"
|
||||
}`}
|
||||
>
|
||||
<div className="relative w-36 h-36">
|
||||
{isCosmos ? (
|
||||
<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>
|
||||
</svg>
|
||||
) : isDawn ? (
|
||||
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="dawn-sun-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#fbbf24" stopOpacity="0.4" />
|
||||
<stop offset="50%" stopColor="#f59e0b" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#d97706" stopOpacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="72" cy="72" r="60" stroke="rgba(251,191,36,0.20)" strokeWidth="0.5" fill="none">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="72" cy="72" r="45" stroke="rgba(245,158,11,0.15)" strokeWidth="0.5" fill="none" strokeDasharray="6 4">
|
||||
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="28s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="72" cy="72" r="30" stroke="rgba(251,191,36,0.12)" strokeWidth="0.5" fill="none">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="22s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="72" cy="72" r="18" fill="url(#dawn-sun-grad)" />
|
||||
<circle cx="72" cy="72" r="10" fill="rgba(251,191,36,0.25)" />
|
||||
<circle cx="72" cy="72" r="4" fill="rgba(253,230,138,0.7)" />
|
||||
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(45 72 72)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="45 72 72" to="405 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(90 72 72)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="90 72 72" to="450 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(135 72 72)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="135 72 72" to="495 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(180 72 72)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="180 72 72" to="540 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(225 72 72)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="225 72 72" to="585 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(270 72 72)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="270 72 72" to="630 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(315 72 72)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="315 72 72" to="675 72 72" dur="35s" repeatCount="indefinite" />
|
||||
</line>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="aurora-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.3" />
|
||||
<stop offset="50%" stopColor="#06b6d4" stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0.3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="72" cy="72" r="55" stroke="url(#aurora-grad)" strokeWidth="1" fill="none">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="40s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="72" cy="72" r="38" stroke="rgba(139,92,246,0.15)" strokeWidth="0.8" fill="none" strokeDasharray="8 4">
|
||||
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="30s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="72" cy="72" r="22" fill="rgba(124,58,237,0.1)" />
|
||||
<circle cx="72" cy="72" r="10" fill="rgba(139,92,246,0.15)" />
|
||||
<circle cx="72" cy="72" r="3" fill="rgba(196,181,253,0.6)" />
|
||||
<path d="M72 17L79 52H112L86 72L96 105L72 87L48 105L58 72L32 52H65Z" stroke="rgba(196,181,253,0.2)" strokeWidth="0.8" fill="rgba(124,58,237,0.05)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="60s" repeatCount="indefinite" />
|
||||
</path>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-px"
|
||||
style={{
|
||||
background: isCosmos
|
||||
? "linear-gradient(90deg, transparent, rgba(96,165,250,0.4), rgba(59,130,246,0.3), transparent)"
|
||||
: isDawn
|
||||
? "linear-gradient(90deg, transparent, rgba(251,191,36,0.4), rgba(245,158,11,0.3), transparent)"
|
||||
: "linear-gradient(90deg, transparent, rgba(167,139,250,0.4), rgba(124,58,237,0.3), transparent)",
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ open, onClose }: Props) {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const resetForm = () => {
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setName("");
|
||||
setError("");
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
setMode("login");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === "register") {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, name: name || undefined }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "注册失败");
|
||||
}
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) throw new Error("自动登录失败,请手动登录");
|
||||
} else {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) throw new Error("邮箱或密码错误");
|
||||
}
|
||||
|
||||
handleClose();
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md border-border/60 bg-white shadow-[0_8px_32px_rgba(0,0,0,0.1)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center text-xl">
|
||||
{mode === "login" ? "欢迎回来" : "创建账号"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground -mt-2">
|
||||
{mode === "login" ? "登录以使用收藏等功能" : "注册后即可收藏喜欢的工具和技能"}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
{mode === "register" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="昵称(选填)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors text-foreground placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 text-sm font-medium shadow-sm shadow-primary/20"
|
||||
>
|
||||
{loading ? "处理中..." : mode === "login" ? "登录" : "注册"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
{mode === "login" ? "还没有账号?" : "已有账号?"}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
setError("");
|
||||
}}
|
||||
className="ml-1 text-primary hover:underline font-medium"
|
||||
>
|
||||
{mode === "login" ? "去注册" : "去登录"}
|
||||
</button>
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user