v0.11.1: 权限体系重构 + AI管线优化 + 内容整理
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user