v0.11.1: 权限体系重构 + AI管线优化 + 内容整理

This commit is contained in:
sgp
2026-05-22 18:36:41 +08:00
parent 7955d11c52
commit b33fe1e8e1
397 changed files with 30940 additions and 161 deletions
+589
View File
@@ -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>
);
}