332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import Link from "next/link"
|
||
import { Search, RefreshCw, Trophy, Plus, Pencil, Trash2, Image as ImageIcon } from "lucide-react"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||
|
||
type ContentItem = {
|
||
id: string
|
||
type: string
|
||
title: string
|
||
link?: string
|
||
image?: string
|
||
sortOrder?: number
|
||
status?: string
|
||
createdAt?: string
|
||
}
|
||
|
||
const TYPE_LABELS: Record<string, string> = {
|
||
tag: "标签",
|
||
event: "赛事",
|
||
banner: "推荐位",
|
||
}
|
||
|
||
export default function AdminContentClient() {
|
||
const [items, setItems] = useState<ContentItem[]>([])
|
||
const [searchTerm, setSearchTerm] = useState("")
|
||
const [typeFilter, setTypeFilter] = useState<string>("")
|
||
const [loading, setLoading] = useState(true)
|
||
const [editOpen, setEditOpen] = useState(false)
|
||
const [editItem, setEditItem] = useState<ContentItem | null>(null)
|
||
const [formTitle, setFormTitle] = useState("")
|
||
const [formLink, setFormLink] = useState("")
|
||
const [formImage, setFormImage] = useState("")
|
||
const [formType, setFormType] = useState("tag")
|
||
const [formSortOrder, setFormSortOrder] = useState(0)
|
||
const [formStatus, setFormStatus] = useState("active")
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
useEffect(() => {
|
||
loadItems()
|
||
}, [typeFilter])
|
||
|
||
const loadItems = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const q = typeFilter ? `?type=${typeFilter}` : ""
|
||
const res = await fetch(`/api/admin/content/items${q}`)
|
||
const json = await res.json()
|
||
if (json.success && Array.isArray(json.data)) setItems(json.data)
|
||
else setItems([])
|
||
} catch (e) {
|
||
console.error(e)
|
||
setItems([])
|
||
}
|
||
setLoading(false)
|
||
}
|
||
|
||
const openAdd = () => {
|
||
setEditItem(null)
|
||
setFormTitle("")
|
||
setFormLink("")
|
||
setFormImage("")
|
||
setFormType("tag")
|
||
setFormSortOrder(0)
|
||
setFormStatus("active")
|
||
setEditOpen(true)
|
||
}
|
||
|
||
const openEdit = (item: ContentItem) => {
|
||
setEditItem(item)
|
||
setFormTitle(item.title ?? "")
|
||
setFormLink(item.link ?? "")
|
||
setFormImage(item.image ?? "")
|
||
setFormType(item.type ?? "tag")
|
||
setFormSortOrder(item.sortOrder ?? 0)
|
||
setFormStatus(item.status ?? "active")
|
||
setEditOpen(true)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSubmitting(true)
|
||
try {
|
||
if (editItem?.id) {
|
||
const res = await fetch(`/api/admin/content/items/${editItem.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title: formTitle,
|
||
link: formLink,
|
||
image: formImage,
|
||
type: formType,
|
||
sortOrder: formSortOrder,
|
||
status: formStatus,
|
||
}),
|
||
})
|
||
const json = await res.json()
|
||
if (json.success) {
|
||
setEditOpen(false)
|
||
loadItems()
|
||
}
|
||
} else {
|
||
const res = await fetch("/api/admin/content/items", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title: formTitle,
|
||
link: formLink,
|
||
image: formImage,
|
||
type: formType,
|
||
sortOrder: formSortOrder,
|
||
status: formStatus,
|
||
}),
|
||
})
|
||
const json = await res.json()
|
||
if (json.success) {
|
||
setEditOpen(false)
|
||
loadItems()
|
||
}
|
||
}
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (id: string) => {
|
||
if (!confirm("确定删除?")) return
|
||
try {
|
||
const res = await fetch(`/api/admin/content/items/${id}`, { method: "DELETE" })
|
||
const json = await res.json()
|
||
if (json.success) loadItems()
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
}
|
||
|
||
const filtered = items.filter(
|
||
(m) =>
|
||
(m.title ?? "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
(m.link ?? "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||
)
|
||
|
||
return (
|
||
<div className="p-4 lg:p-6 max-w-[1600px] mx-auto">
|
||
<h1 className="text-xl lg:text-2xl font-bold text-slate-800 mb-6">内容管理</h1>
|
||
<p className="text-slate-500 text-sm mb-4">首页内容标签、电竞赛事、推荐位(MongoDB contentItems)</p>
|
||
|
||
<Card className="admin-glass rounded-2xl overflow-hidden mb-6 border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4">
|
||
<div className="flex flex-wrap gap-4 items-center">
|
||
<div className="relative flex-1 min-w-[200px]">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||
<Input
|
||
placeholder="搜索标题、链接..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="bg-white/90 border-slate-200 text-slate-800 pl-10"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant={typeFilter === "" ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setTypeFilter("")}
|
||
className={typeFilter === "" ? "bg-indigo-500" : "border-slate-200"}
|
||
>
|
||
全部
|
||
</Button>
|
||
{(["tag", "event", "banner"] as const).map((t) => (
|
||
<Button
|
||
key={t}
|
||
variant={typeFilter === t ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setTypeFilter(t)}
|
||
className={typeFilter === t ? "bg-indigo-500" : "border-slate-200"}
|
||
>
|
||
{TYPE_LABELS[t]}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<Button variant="outline" onClick={loadItems} className="border-slate-200 text-slate-600">
|
||
<RefreshCw className="w-4 h-4 mr-2" />
|
||
刷新
|
||
</Button>
|
||
<Button onClick={openAdd} className="bg-indigo-500 text-white hover:bg-indigo-600">
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
新增
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-slate-800">{items.length}</p>
|
||
<p className="text-slate-500 text-sm">内容条数</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-emerald-600">{items.filter((m) => m.status === "active").length}</p>
|
||
<p className="text-slate-500 text-sm">已上线</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-amber-600">{items.filter((m) => m.type === "event").length}</p>
|
||
<p className="text-slate-500 text-sm">赛事</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardHeader>
|
||
<CardTitle className="text-slate-800 flex items-center gap-2">
|
||
<Trophy className="w-5 h-5 text-indigo-500" />
|
||
内容列表 ({filtered.length})
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<div className="text-center py-8 text-slate-500">加载中...</div>
|
||
) : filtered.length === 0 ? (
|
||
<div className="text-center py-8 text-slate-500">暂无内容,点击「新增」添加首页标签或电竞赛事</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-slate-200">
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">类型</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">标题</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">链接/图片</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">排序</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">状态</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4 w-32">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map((m) => (
|
||
<tr key={m.id} className="border-b border-slate-100 hover:bg-slate-50/80">
|
||
<td className="py-3 px-4">
|
||
<Badge variant="outline" className="border-indigo-200 text-indigo-700">
|
||
{TYPE_LABELS[m.type] ?? m.type}
|
||
</Badge>
|
||
</td>
|
||
<td className="py-3 px-4 text-slate-800 font-medium">{m.title}</td>
|
||
<td className="py-3 px-4 text-slate-600 text-sm truncate max-w-[200px]">
|
||
{m.link ? m.link : m.image ? <span className="flex items-center gap-1"><ImageIcon className="w-4 h-4" /> 已设</span> : "—"}
|
||
</td>
|
||
<td className="py-3 px-4 text-slate-600">{m.sortOrder ?? 0}</td>
|
||
<td className="py-3 px-4">
|
||
<Badge className={m.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-600"}>
|
||
{m.status === "active" ? "上线" : "下线"}
|
||
</Badge>
|
||
</td>
|
||
<td className="py-3 px-4 flex gap-1">
|
||
<Button variant="ghost" size="sm" onClick={() => openEdit(m)} className="text-slate-600 hover:text-indigo-600">
|
||
<Pencil className="w-4 h-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => handleDelete(m.id)} className="text-slate-600 hover:text-red-600">
|
||
<Trash2 className="w-4 h-4" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||
<DialogContent className="bg-white border-slate-200 text-slate-800 max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{editItem ? "编辑内容" : "新增内容"}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 pt-2">
|
||
<div>
|
||
<label className="text-sm text-slate-600">类型</label>
|
||
<select
|
||
value={formType}
|
||
onChange={(e) => setFormType(e.target.value)}
|
||
className="mt-1 w-full rounded-md border border-slate-200 px-3 py-2 text-slate-800"
|
||
>
|
||
<option value="tag">标签</option>
|
||
<option value="event">赛事</option>
|
||
<option value="banner">推荐位</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-slate-600">标题</label>
|
||
<Input value={formTitle} onChange={(e) => setFormTitle(e.target.value)} placeholder="标题" className="mt-1 border-slate-200" />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-slate-600">链接</label>
|
||
<Input value={formLink} onChange={(e) => setFormLink(e.target.value)} placeholder="选填" className="mt-1 border-slate-200" />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-slate-600">图片 URL</label>
|
||
<Input value={formImage} onChange={(e) => setFormImage(e.target.value)} placeholder="选填" className="mt-1 border-slate-200" />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-slate-600">排序(数字越小越靠前)</label>
|
||
<Input type="number" value={formSortOrder} onChange={(e) => setFormSortOrder(Number(e.target.value) || 0)} className="mt-1 border-slate-200" />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-slate-600">状态</label>
|
||
<select
|
||
value={formStatus}
|
||
onChange={(e) => setFormStatus(e.target.value)}
|
||
className="mt-1 w-full rounded-md border border-slate-200 px-3 py-2 text-slate-800"
|
||
>
|
||
<option value="active">上线</option>
|
||
<option value="offline">下线</option>
|
||
</select>
|
||
</div>
|
||
<Button onClick={handleSave} disabled={submitting || !formTitle.trim()} className="w-full">
|
||
{submitting ? "保存中..." : "保存"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|