init: initial commit
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, Loader2, MoreHorizontal, GalleryHorizontalEnd, Eye, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getBannerList, saveBanner, updateBanner, deleteBanner } from '@/api/plant'
|
||||
import type { Banner } from '@/api/plant'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function BannerPage() {
|
||||
const [banners, setBanners] = useState<Banner[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [form, setForm] = useState({ id: '', title: '', imageId: '', imageUrl: '', sort: 0, isActive: 1, targetUrl: '' })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<Banner | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getBannerList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setBanners(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const handleAdd = () => { setIsEdit(false); setForm({ id: '', title: '', imageId: '', imageUrl: '', sort: 0, isActive: 1, targetUrl: '' }); setDialogOpen(true) }
|
||||
const handleEdit = (b: Banner) => { setIsEdit(true); setForm({ id: b.id, title: b.title, imageId: b.imageId, imageUrl: b.image?.url || '', sort: b.sort, isActive: b.isActive, targetUrl: b.targetUrl || '' }); setDialogOpen(true) }
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.title) return; setSubmitting(true)
|
||||
try { if (isEdit) await updateBanner(form as any); else await saveBanner(form as any); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!toDelete) return
|
||||
try { await deleteBanner(toDelete.id); setDeleteOpen(false); fetchList() } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><GalleryHorizontalEnd className="h-5 w-5 text-primary" />轮播图管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理花园首页的轮播图</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-[180px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">预览</TableHead><TableHead className="font-semibold">标题</TableHead>
|
||||
<TableHead className="font-semibold">状态</TableHead><TableHead className="font-semibold">排序</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{banners.map(b => (
|
||||
<TableRow key={b.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6">{b.image ? <img src={b.image.url} alt={b.title} className="h-12 w-24 rounded-lg object-cover border" /> : <div className="h-12 w-24 bg-muted/50 rounded-lg" />}</TableCell>
|
||||
<TableCell className="font-medium">{b.title}</TableCell>
|
||||
<TableCell>{b.isActive === 1 ? <Badge className="bg-green-500/10 text-green-700 border-green-500/20">启用</Badge> : <Badge variant="secondary">禁用</Badge>}</TableCell>
|
||||
<TableCell className="font-mono text-muted-foreground">{b.sort}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(b)}><Pencil className="mr-2 h-4 w-4" />编辑</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(b); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]"><DialogHeader><DialogTitle>{isEdit ? '编辑轮播图' : '新增轮播图'}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>标题 *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2"><Label>状态</Label><Select value={String(form.isActive)} onValueChange={v => setForm(f => ({ ...f, isActive: parseInt(v) }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="1">启用</SelectItem><SelectItem value="0">禁用</SelectItem></SelectContent></Select></div>
|
||||
<div className="grid gap-2"><Label>排序</Label><Input type="number" value={form.sort} onChange={e => setForm(f => ({ ...f, sort: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
<div className="grid gap-2"><Label>跳转链接</Label><Input value={form.targetUrl} onChange={e => setForm(f => ({ ...f, targetUrl: e.target.value }))} placeholder="/pages/wiki/index" /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.title}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除「{toDelete?.title}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Trash2, Loader2, MoreHorizontal, FileText, ThumbsUp, MessageCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getPostList, deletePost } from '@/api/plant'
|
||||
import type { Post } from '@/api/plant'
|
||||
|
||||
export default function PostPage() {
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<Post | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getPostList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setPosts(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><FileText className="h-5 w-5 text-primary" />帖子管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理社区用户发布的帖子</CardDescription>
|
||||
</div>
|
||||
<div className="relative"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-[200px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">标题</TableHead><TableHead className="font-semibold">话题</TableHead>
|
||||
<TableHead className="font-semibold">作者</TableHead><TableHead className="font-semibold">互动</TableHead>
|
||||
<TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{posts.map(p => (
|
||||
<TableRow key={p.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6 font-medium max-w-[200px] truncate">{p.title}</TableCell>
|
||||
<TableCell><Badge variant="secondary" className="text-xs">{p.topicTitle}</Badge></TableCell>
|
||||
<TableCell className="text-sm">{p.userName}</TableCell>
|
||||
<TableCell><div className="flex items-center gap-3 text-xs text-muted-foreground"><span className="flex items-center gap-1"><ThumbsUp className="h-3 w-3" />{p.likeCount}</span><span className="flex items-center gap-1"><MessageCircle className="h-3 w-3" />{p.commentCount}</span></div></TableCell>
|
||||
<TableCell>{p.status === 1 ? <Badge className="bg-green-500/10 text-green-700 border-green-500/20">正常</Badge> : <Badge variant="secondary">隐藏</Badge>}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end"><DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(p); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem></DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
{Math.ceil(total/10) > 1 && <div className="flex items-center justify-between pt-4"><p className="text-sm text-muted-foreground">共 {total} 条</p><div className="flex gap-2"><Button variant="outline" size="sm" disabled={page<=1} onClick={()=>setPage(p=>p-1)}>上一页</Button><Button variant="outline" size="sm" disabled={page>=Math.ceil(total/10)} onClick={()=>setPage(p=>p+1)}>下一页</Button></div></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除帖子「{toDelete?.title}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={async () => { if (toDelete) { await deletePost(toDelete.id); setDeleteOpen(false); fetchList() } }}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Trash2, Loader2, MoreHorizontal, MessageSquare } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getTopicList, saveTopic, deleteTopic } from '@/api/plant'
|
||||
import type { Topic } from '@/api/plant'
|
||||
|
||||
export default function TopicPage() {
|
||||
const [topics, setTopics] = useState<Topic[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState({ title: '', description: '', sort: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<Topic | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getTopicList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setTopics(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.title) return; setSubmitting(true)
|
||||
try { await saveTopic({ ...form, status: 1 }); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><MessageSquare className="h-5 w-5 text-primary" />话题管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理社区话题分类</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-[180px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Button onClick={() => { setForm({ title: '', description: '', sort: 0 }); setDialogOpen(true) }} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">话题</TableHead><TableHead className="font-semibold">描述</TableHead>
|
||||
<TableHead className="font-semibold">帖子数</TableHead><TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{topics.map(t => (
|
||||
<TableRow key={t.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6 font-medium">{t.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{t.description || '-'}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{t.postCount}</Badge></TableCell>
|
||||
<TableCell>{t.status === 1 ? <Badge className="bg-green-500/10 text-green-700 border-green-500/20">启用</Badge> : <Badge variant="secondary">禁用</Badge>}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end"><DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(t); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem></DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[420px]"><DialogHeader><DialogTitle>新增话题</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>标题 *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>描述</Label><Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.title}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除话题「{toDelete?.title}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={async () => { if (toDelete) { await deleteTopic(toDelete.id); setDeleteOpen(false); fetchList() } }}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import { Bot, Save, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
export default function AiConfigPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [config, setConfig] = useState({
|
||||
model: 'gpt-4o', temperature: 0.7, maxTokens: 2000, enabled: true,
|
||||
systemPrompt: '你是一个植物养护专家,帮助用户解答关于植物养护的各种问题。请用专业但友好的语气回答。',
|
||||
welcomeMsg: '你好!我是植物养护AI助手,有什么关于植物的问题都可以问我~ 🌿',
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2"><Bot className="h-6 w-6 text-primary" />AI 配置</h1>
|
||||
<p className="text-muted-foreground mt-1">配置 Plant 服务的 AI 助手参数</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-lg">模型参数</CardTitle><CardDescription>设置 AI 模型和推理参数</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><Label className="text-sm font-medium">启用 AI 助手</Label><p className="text-xs text-muted-foreground mt-0.5">开启后用户可以在小程序中使用 AI 问答</p></div>
|
||||
<Switch checked={config.enabled} onCheckedChange={v => setConfig(c => ({ ...c, enabled: v }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2"><Label>模型</Label><Select value={config.model} onValueChange={v => setConfig(c => ({ ...c, model: v }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="gpt-4o">GPT-4o</SelectItem><SelectItem value="gpt-4o-mini">GPT-4o Mini</SelectItem><SelectItem value="deepseek-v3">DeepSeek V3</SelectItem></SelectContent></Select></div>
|
||||
<div className="grid gap-2"><Label>Temperature</Label><Input type="number" step={0.1} min={0} max={2} value={config.temperature} onChange={e => setConfig(c => ({ ...c, temperature: parseFloat(e.target.value) || 0 }))} /></div>
|
||||
<div className="grid gap-2"><Label>Max Tokens</Label><Input type="number" value={config.maxTokens} onChange={e => setConfig(c => ({ ...c, maxTokens: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-lg">Prompt 模板</CardTitle><CardDescription>配置 AI 对话的系统提示词和欢迎语</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2"><Label>System Prompt</Label><Textarea value={config.systemPrompt} onChange={e => setConfig(c => ({ ...c, systemPrompt: e.target.value }))} rows={5} className="font-mono text-sm" /></div>
|
||||
<div className="grid gap-2"><Label>欢迎语</Label><Input value={config.welcomeMsg} onChange={e => setConfig(c => ({ ...c, welcomeMsg: e.target.value }))} /></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2 px-6 shadow-sm">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Trash2, Loader2, MoreHorizontal, Hash, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getExchangeItemList, saveExchangeItem, deleteExchangeItem, getExchangeOrderList } from '@/api/plant'
|
||||
import type { ExchangeItem, ExchangeOrder } from '@/api/plant'
|
||||
|
||||
export default function ExchangePage() {
|
||||
const [items, setItems] = useState<ExchangeItem[]>([])
|
||||
const [orders, setOrders] = useState<ExchangeOrder[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState('items')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', description: '', points: 100, stock: 10, sort: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getExchangeItemList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setItems(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getExchangeOrderList({ current: page, pageSize: 10 }); if (res?.data) { setOrders(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page])
|
||||
|
||||
useEffect(() => { if (tab === 'items') fetchItems(); else fetchOrders() }, [tab, fetchItems, fetchOrders])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name) return; setSubmitting(true)
|
||||
try { await saveExchangeItem({ ...form, status: 1 }); setDialogOpen(false); fetchItems() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const statusMap: Record<number, string> = { 0: '待处理', 1: '已完成', 2: '已过期' }
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><Hash className="h-5 w-5 text-primary" />积分兑换</CardTitle>
|
||||
<CardDescription>管理兑换商品和兑换订单</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={tab} onValueChange={v => { setTab(v); setPage(1) }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<TabsList><TabsTrigger value="items">商品管理</TabsTrigger><TabsTrigger value="orders">兑换订单</TabsTrigger></TabsList>
|
||||
{tab === 'items' && <Button onClick={() => { setForm({ name: '', description: '', points: 100, stock: 10, sort: 0 }); setDialogOpen(true) }} className="h-9 gap-2"><Plus className="h-4 w-4" />新增商品</Button>}
|
||||
</div>
|
||||
<TabsContent value="items">
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">商品</TableHead><TableHead className="font-semibold">积分</TableHead>
|
||||
<TableHead className="font-semibold">库存</TableHead><TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{items.map(item => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/30">
|
||||
<TableCell className="pl-6"><div className="flex items-center gap-3">{item.cover && <img src={item.cover} alt="" className="h-10 w-10 rounded-lg object-cover border" />}<div><p className="font-medium text-sm">{item.name}</p><p className="text-xs text-muted-foreground">{item.description}</p></div></div></TableCell>
|
||||
<TableCell className="font-mono font-semibold text-amber-600">{item.points}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{item.stock}</Badge></TableCell>
|
||||
<TableCell>{item.status === 1 ? <Badge className="bg-green-500/10 text-green-700">上架</Badge> : <Badge variant="secondary">下架</Badge>}</TableCell>
|
||||
<TableCell><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end"><DropdownMenuItem className="text-destructive" onClick={async () => { await deleteExchangeItem(item.id); fetchItems() }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem></DropdownMenuContent>
|
||||
</DropdownMenu></TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="orders">
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">商品</TableHead><TableHead className="font-semibold">用户</TableHead>
|
||||
<TableHead className="font-semibold">积分</TableHead><TableHead className="font-semibold">状态</TableHead><TableHead className="font-semibold">时间</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{orders.map(o => (
|
||||
<TableRow key={o.id} className="hover:bg-muted/30">
|
||||
<TableCell className="pl-6 font-medium">{o.itemName}</TableCell>
|
||||
<TableCell>{o.userName}</TableCell>
|
||||
<TableCell className="font-mono text-amber-600">{o.points}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{statusMap[o.status] || '未知'}</Badge></TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{new Date(o.createdAt).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[460px]"><DialogHeader><DialogTitle>新增兑换商品</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>名称 *</Label><Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>描述</Label><Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2"><Label>积分</Label><Input type="number" value={form.points} onChange={e => setForm(f => ({ ...f, points: parseInt(e.target.value) || 0 }))} /></div>
|
||||
<div className="grid gap-2"><Label>库存</Label><Input type="number" value={form.stock} onChange={e => setForm(f => ({ ...f, stock: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.name}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Pencil, Trash2, Loader2, FolderTree } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getWikiClassList, saveWikiClass, deleteWikiClass } from '@/api/plant'
|
||||
import type { PlantWikiClass } from '@/api/plant'
|
||||
|
||||
export default function WikiClassPage() {
|
||||
const [classes, setClasses] = useState<PlantWikiClass[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', sort: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<PlantWikiClass | null>(null)
|
||||
|
||||
const fetchList = async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getWikiClassList(); if (res?.data) setClasses(res.data) }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetchList() }, [])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name) return; setSubmitting(true)
|
||||
try { await saveWikiClass(form); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!toDelete) return
|
||||
try { await deleteWikiClass(toDelete.id); setDeleteOpen(false); fetchList() } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><FolderTree className="h-5 w-5 text-primary" />百科分类 <Badge variant="secondary" className="text-xs">{classes.length}</Badge></CardTitle>
|
||||
<CardDescription>管理植物百科的分类体系</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => { setForm({ name: '', sort: 0 }); setDialogOpen(true) }} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{classes.map(c => (
|
||||
<div key={c.id} className="flex items-center justify-between p-4 rounded-xl border border-border/50 hover:border-primary/30 hover:bg-primary/5 transition-all group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center"><FolderTree className="h-5 w-5 text-primary" /></div>
|
||||
<div><p className="font-medium text-sm">{c.name}</p><p className="text-xs text-muted-foreground">排序: {c.sort}</p></div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { setToDelete(c); setDeleteOpen(true) }}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]"><DialogHeader><DialogTitle>新增分类</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>名称 *</Label><Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>排序</Label><Input type="number" value={form.sort} onChange={e => setForm(f => ({ ...f, sort: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.name}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除分类「{toDelete?.name}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, Loader2, MoreHorizontal, Book, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getWikiList, saveWiki, updateWiki, deleteWiki, getWikiClassList } from '@/api/plant'
|
||||
import type { PlantWiki, PlantWikiClass } from '@/api/plant'
|
||||
|
||||
export default function WikiPage() {
|
||||
const [wikis, setWikis] = useState<PlantWiki[]>([])
|
||||
const [classes, setClasses] = useState<PlantWikiClass[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [classFilter, setClassFilter] = useState('all')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [form, setForm] = useState({ id: '', title: '', classId: '', content: '', cover: '', status: 1 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<PlantWiki | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = { current: page, pageSize: 10, keyword: search || undefined }
|
||||
if (classFilter !== 'all') params.classId = classFilter
|
||||
const res = await getWikiList(params)
|
||||
if (res?.data) { setWikis(res.data.list || []); setTotal(res.data.total || 0) }
|
||||
} catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search, classFilter])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
useEffect(() => { getWikiClassList().then(res => { if (res?.data) setClasses(res.data) }) }, [])
|
||||
|
||||
const handleAdd = () => { setIsEdit(false); setForm({ id: '', title: '', classId: classes[0]?.id || '', content: '', cover: '', status: 1 }); setDialogOpen(true) }
|
||||
const handleEdit = (w: PlantWiki) => { setIsEdit(true); setForm({ id: w.id, title: w.title, classId: w.classId, content: w.content, cover: w.cover || '', status: w.status }); setDialogOpen(true) }
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.title || !form.classId) return; setSubmitting(true)
|
||||
try { if (isEdit) await updateWiki(form); else await saveWiki(form); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!toDelete) return
|
||||
try { await deleteWiki(toDelete.id); setDeleteOpen(false); fetchList() } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / 10)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><Book className="h-5 w-5 text-primary" />植物百科 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理植物百科词条</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto flex-wrap">
|
||||
<div className="relative flex-1 sm:flex-none"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-full sm:w-[180px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Select value={classFilter} onValueChange={v => { setClassFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[130px] h-10"><SelectValue placeholder="分类" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">全部分类</SelectItem>{classes.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2 shadow-sm"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">封面</TableHead><TableHead className="font-semibold">标题</TableHead>
|
||||
<TableHead className="font-semibold">分类</TableHead><TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{wikis.length === 0 ? <TableRow><TableCell colSpan={5} className="h-32 text-center text-muted-foreground">暂无数据</TableCell></TableRow>
|
||||
: wikis.map(w => (
|
||||
<TableRow key={w.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6">{w.cover ? <img src={w.cover} alt="" className="h-10 w-16 rounded-lg object-cover border" /> : <div className="h-10 w-16 rounded-lg bg-muted/50 flex items-center justify-center"><Book className="h-4 w-4 text-muted-foreground/40" /></div>}</TableCell>
|
||||
<TableCell className="font-medium">{w.title}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{w.className}</Badge></TableCell>
|
||||
<TableCell>{w.status === 1 ? <Badge className="bg-green-500/10 text-green-700 border-green-500/20"><Eye className="h-3 w-3 mr-1" />已发布</Badge> : <Badge variant="secondary"><EyeOff className="h-3 w-3 mr-1" />草稿</Badge>}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(w)}><Pencil className="mr-2 h-4 w-4" />编辑</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(w); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && <div className="flex items-center justify-between pt-4"><p className="text-sm text-muted-foreground">共 {total} 条</p><div className="flex gap-2"><Button variant="outline" size="sm" disabled={page<=1} onClick={()=>setPage(p=>p-1)}>上一页</Button><span className="text-sm text-muted-foreground px-2">{page}/{totalPages}</span><Button variant="outline" size="sm" disabled={page>=totalPages} onClick={()=>setPage(p=>p+1)}>下一页</Button></div></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[560px]"><DialogHeader><DialogTitle>{isEdit ? '编辑百科' : '新增百科'}</DialogTitle><DialogDescription>编辑植物百科词条内容</DialogDescription></DialogHeader>
|
||||
<div className="grid gap-4 py-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="grid gap-2"><Label>标题 *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2"><Label>分类 *</Label><Select value={form.classId} onValueChange={v => setForm(f => ({ ...f, classId: v }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{classes.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="grid gap-2"><Label>状态</Label><Select value={String(form.status)} onValueChange={v => setForm(f => ({ ...f, status: parseInt(v) }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="1">已发布</SelectItem><SelectItem value="0">草稿</SelectItem></SelectContent></Select></div>
|
||||
</div>
|
||||
<div className="grid gap-2"><Label>封面URL</Label><Input value={form.cover} onChange={e => setForm(f => ({ ...f, cover: e.target.value }))} placeholder="图片地址" /></div>
|
||||
<div className="grid gap-2"><Label>内容</Label><Textarea value={form.content} onChange={e => setForm(f => ({ ...f, content: e.target.value }))} rows={6} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.title}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除</DialogTitle><DialogDescription>确定要删除「{toDelete?.title}」吗?</DialogDescription></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={()=>setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user