Files
zhuiguang-ai/src/app/(admin)/admin/tools/page.tsx
T

696 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}