696 lines
29 KiB
TypeScript
696 lines
29 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 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 [error, setError] = useState("");
|
||
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 [verifiedToolIds, setVerifiedToolIds] = useState<Set<number>>(new Set());
|
||
|
||
const fetchVerifiedTools = useCallback(async () => {
|
||
try {
|
||
const res = await fetch("/api/admin/tools/verify");
|
||
const data = await res.json();
|
||
if (data.verifiedTools) {
|
||
const ids = new Set<number>();
|
||
(data.verifiedTools as { toolId: number }[]).forEach((v) => ids.add(v.toolId));
|
||
setVerifiedToolIds(ids);
|
||
}
|
||
} catch { /* ignore */ }
|
||
}, []);
|
||
|
||
const fetchTools = useCallback(async () => {
|
||
setError("");
|
||
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 {
|
||
setError("加载工具列表失败,请稍后重试");
|
||
} 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();
|
||
fetchVerifiedTools();
|
||
}, [fetchTools, fetchCategories, fetchVerifiedTools]);
|
||
|
||
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 handleVerifyToggle = async (toolId: number) => {
|
||
try {
|
||
const res = await fetch("/api/admin/tools/verify", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ toolId }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
setVerifiedToolIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (data.verified) {
|
||
next.add(toolId);
|
||
} else {
|
||
next.delete(toolId);
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
} catch {
|
||
console.error("Verify toggle failed");
|
||
}
|
||
};
|
||
|
||
const publishedCount = tools.filter((t) => t.status === "published").length;
|
||
const draftCount = tools.filter((t) => t.status === "draft").length;
|
||
const totalViews = tools.reduce((sum, t) => sum + t.viewCount, 0);
|
||
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>
|
||
|
||
{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-100">
|
||
<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={() => fetchTools()}
|
||
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">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<th key={i} className="px-5 py-3.5">
|
||
<div className="h-3 w-14 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-5 py-3.5">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-9 w-9 animate-pulse rounded-lg bg-muted/30" />
|
||
<div className="space-y-1.5">
|
||
<div className="h-3 w-24 animate-pulse rounded bg-muted/20" />
|
||
<div className="h-2.5 w-40 animate-pulse rounded bg-muted/20" />
|
||
</div>
|
||
</div>
|
||
</td>
|
||
{Array.from({ length: 5 }).map((_, j) => (
|
||
<td key={j} className="px-5 py-3.5">
|
||
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</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 ? (
|
||
<LazyImage
|
||
src={tool.logoUrl}
|
||
referrerPolicy="no-referrer"
|
||
alt=""
|
||
width={48}
|
||
height={48}
|
||
className="h-9 w-9 rounded-lg border border-border/50 bg-white object-contain p-0.5"
|
||
/>
|
||
) : (
|
||
<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={() => handleVerifyToggle(tool.id)}
|
||
className={`gap-1.5 rounded-lg text-xs ${
|
||
verifiedToolIds.has(tool.id)
|
||
? "border-blue-300/60 bg-blue-50 text-blue-600 hover:bg-blue-100"
|
||
: "border-border/50"
|
||
}`}
|
||
title={verifiedToolIds.has(tool.id) ? "取消验证" : "标记为已验证"}
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
{verifiedToolIds.has(tool.id) ? "已验证" : "验证"}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
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>
|
||
);
|
||
}
|