763 lines
35 KiB
TypeScript
763 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
|
|
import { LazyImage } from "@/components/ui/LazyImage";
|
|
|
|
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 [error, setError] = useState("");
|
|
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 () => {
|
|
setError("");
|
|
try {
|
|
const res = await fetch(`/api/admin/pending-skills?page=${page}&pageSize=${pageSize}`);
|
|
const data = await res.json();
|
|
setSkills(data.skills || []);
|
|
setTotal(data.total || 0);
|
|
} catch {
|
|
setError("加载待审核技能失败,请稍后重试");
|
|
} 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>
|
|
)}
|
|
|
|
{error ? (
|
|
<div className="flex flex-col items-center justify-center py-24">
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
|
</svg>
|
|
</div>
|
|
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fetchSkills()}
|
|
className="mt-4 rounded-xl"
|
|
>
|
|
重新加载
|
|
</Button>
|
|
</div>
|
|
) : loading ? (
|
|
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border/50">
|
|
<th className="px-4 py-3.5 w-10">
|
|
<div className="h-4 w-4 animate-pulse rounded bg-muted/30 mx-auto" />
|
|
</th>
|
|
{Array.from({ length: 7 }).map((_, i) => (
|
|
<th key={i} className="px-4 py-3.5">
|
|
<div className="h-3 w-12 animate-pulse rounded bg-muted/30" />
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<tr key={i} className="border-b border-border/40">
|
|
<td className="px-4 py-3">
|
|
<div className="h-4 w-4 animate-pulse rounded bg-muted/20 mx-auto" />
|
|
</td>
|
|
{Array.from({ length: 7 }).map((_, j) => (
|
|
<td key={j} className="px-4 py-3">
|
|
<div className="h-3 animate-pulse rounded bg-muted/20" style={{ width: `${60 + Math.floor(Math.random() * 60)}%` }} />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : skills.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-24">
|
|
<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">技能名称</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 ? (
|
|
<LazyImage src={skill.logoUrl} referrerPolicy="no-referrer" alt="" width={48} height={48} className="h-6 w-6 rounded border border-border/50 object-contain p-0.5" />
|
|
) : (
|
|
<div className="flex h-6 w-6 items-center justify-center rounded border border-border/50 bg-gradient-to-br from-violet-500/15 to-violet-500/5 text-[9px] font-semibold text-violet-600">
|
|
{skill.name.charAt(0)}
|
|
</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>
|
|
);
|
|
}
|