feat: 小程序首页banner管理

This commit is contained in:
Blizzard
2026-04-27 11:29:52 +08:00
parent 4a5c189dbd
commit a37c3a947e
2 changed files with 479 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
import { get, post, type PageResult, type PageParams } from '@/lib/request'
import type { SystemOss } from './system'
// ==================== 轮播图管理 ====================
export interface Banner {
id: string
title: string
imageId: string
image?: SystemOss
sort: number
isActive: number // 1:启用 2:禁用
targetUrl: string
createdAt?: string
updatedAt?: string
createdAtStr?: string
}
export interface BannerListParams extends PageParams {
title?: string
isActive?: number
}
// 分页获取轮播图列表
export function getBannerList(data: BannerListParams) {
return post<{ data: PageResult<Banner> }>('/plantBanner/list', data)
}
// 创建轮播图
export function createBanner(data: Partial<Banner>) {
return post<{ msg: string }>('/plantBanner/create', data)
}
// 更新轮播图
export function updateBanner(data: Partial<Banner>) {
return post<{ msg: string }>('/plantBanner/update', data)
}
// 删除轮播图
export function deleteBanner(id: string) {
return post<{ msg: string }>('/plantBanner/delete', { id })
}
// 获取启用的轮播图(C端用)
export function getActiveBanners() {
return get<{ data: { list: Banner[] } }>('/plantBanner/activeList')
}
+432
View File
@@ -0,0 +1,432 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Plus, Search, MoreHorizontal, Pencil, Trash2, Image, Loader2,
ArrowUpDown, Eye, EyeOff, Upload, GalleryHorizontalEnd
} 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 { cn } from '@/lib/utils'
import {
getBannerList, createBanner, updateBanner, deleteBanner,
type Banner,
} from '@/api/banner'
import { uploadFile } from '@/api/system'
// ==================== Form Types ====================
interface BannerFormData {
id?: string
title: string
imageId: string
imageUrl: string
sort: number
isActive: number
targetUrl: string
}
const defaultForm: BannerFormData = {
title: '', imageId: '', imageUrl: '', sort: 0, isActive: 1, targetUrl: '',
}
// ==================== Main Component ====================
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 [pageSize] = useState(10)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [dialogOpen, setDialogOpen] = useState(false)
const [isEdit, setIsEdit] = useState(false)
const [formData, setFormData] = useState<BannerFormData>(defaultForm)
const [submitting, setSubmitting] = useState(false)
const [uploading, setUploading] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [toDelete, setToDelete] = useState<Banner | null>(null)
// Fetch
const fetchList = useCallback(async () => {
setLoading(true)
try {
const params: Record<string, unknown> = {
current: page, pageSize, title: searchTerm || undefined,
}
if (statusFilter !== 'all') params.isActive = parseInt(statusFilter)
const res = await getBannerList(params as any)
if (res?.data) { setBanners(res.data.list || []); setTotal(res.data.total || 0) }
} catch (e) { console.error(e) }
finally { setLoading(false) }
}, [page, pageSize, searchTerm, statusFilter])
useEffect(() => { fetchList() }, [fetchList])
const activeCount = banners.filter(b => b.isActive === 1).length
// Handlers
const handleAdd = () => { setIsEdit(false); setFormData(defaultForm); setDialogOpen(true) }
const handleEdit = (b: Banner) => {
setIsEdit(true)
setFormData({
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 handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (!file) return
setUploading(true)
try {
const res = await uploadFile(file)
const oss = (res as any).data?.file
if (oss) setFormData(p => ({ ...p, imageId: oss.id, imageUrl: oss.url }))
} catch (err) { console.error(err) }
finally { setUploading(false); if (fileRef.current) fileRef.current.value = '' }
}
const handleSubmit = async () => {
if (!formData.title || !formData.imageId) return
setSubmitting(true)
try {
const data = {
title: formData.title, imageId: formData.imageId,
sort: formData.sort, isActive: formData.isActive,
targetUrl: formData.targetUrl,
}
if (isEdit && formData.id) await updateBanner({ ...data, id: formData.id })
else await createBanner(data)
setDialogOpen(false); fetchList()
} catch (e) { console.error(e) }
finally { setSubmitting(false) }
}
const handleDelete = async () => {
if (!toDelete) return
try { await deleteBanner(toDelete.id); setDeleteDialogOpen(false); fetchList() }
catch (e) { console.error(e) }
}
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-6 animate-fadeIn">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-l-4 border-l-primary shadow-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
<GalleryHorizontalEnd className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{total}</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
<Eye className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeCount}</div>
</CardContent>
</Card>
<AddCard onClick={handleAdd} />
</div>
{/* Table Card */}
<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">
<Badge variant="secondary" className="font-normal 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-[200px] h-10 bg-muted/30"
value={searchTerm} onChange={e => { setSearchTerm(e.target.value); setPage(1) }} />
</div>
<Select value={statusFilter} onValueChange={v => { setStatusFilter(v); setPage(1) }}>
<SelectTrigger className="w-[120px] h-10"><SelectValue placeholder="状态" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleAdd} className="h-10 gap-2 shadow-sm">
<Plus className="h-4 w-4" /><span className="hidden sm:inline"></span>
</Button>
</div>
</CardHeader>
<CardContent>
<BannerTable loading={loading} banners={banners} page={page} pageSize={pageSize}
total={total} totalPages={totalPages} setPage={setPage}
onEdit={handleEdit} onDelete={b => { setToDelete(b); setDeleteDialogOpen(true) }} />
</CardContent>
</Card>
{/* Create/Edit Dialog */}
<BannerDialog open={dialogOpen} onOpenChange={setDialogOpen} isEdit={isEdit}
formData={formData} setFormData={setFormData} submitting={submitting}
uploading={uploading} fileRef={fileRef} onImageUpload={handleImageUpload}
onSubmit={handleSubmit} />
{/* Delete Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{toDelete?.title}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}></Button>
<Button variant="destructive" onClick={handleDelete}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ==================== Sub Components ====================
function AddCard({ onClick }: { onClick: () => void }) {
return (
<Card className="border-l-4 border-l-purple-500 shadow-sm group cursor-pointer bg-gradient-to-br from-background to-purple-50/50 hover:to-purple-100/50"
onClick={onClick}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-purple-600"></CardTitle>
<div className="bg-purple-100 p-1 rounded-full group-hover:scale-110 transition-transform">
<Plus className="h-4 w-4 text-purple-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-purple-700">New</div>
<p className="text-xs text-purple-600/80 mt-1"></p>
</CardContent>
</Card>
)
}
interface BannerTableProps {
loading: boolean; banners: Banner[]; page: number; pageSize: number
total: number; totalPages: number; setPage: (p: number | ((p: number) => number)) => void
onEdit: (b: Banner) => void; onDelete: (b: Banner) => void
}
function BannerTable({ loading, banners, page, pageSize, total, totalPages, setPage, onEdit, onDelete }: BannerTableProps) {
if (loading) return (
<div className="flex flex-col items-center justify-center py-16 space-y-4 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin text-primary" /><p className="text-sm font-medium">...</p>
</div>
)
return (
<>
<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">
<div className="flex items-center gap-1"><ArrowUpDown className="h-3.5 w-3.5" /></div>
</TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{banners.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-48 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Image className="h-10 w-10 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm">"新增"</p>
</div>
</TableCell>
</TableRow>
) : banners.map((b, i) => (
<TableRow key={b.id} className={cn("group hover:bg-muted/30 transition-colors", i % 2 === 1 && "bg-muted/5")}>
<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 rounded-lg bg-muted/50 flex items-center justify-center">
<Image className="h-5 w-5 text-muted-foreground/40" />
</div>
)}
</TableCell>
<TableCell><p className="font-medium">{b.title}</p></TableCell>
<TableCell>
{b.targetUrl ? (
<span className="text-xs text-blue-600 truncate max-w-[200px] block">{b.targetUrl}</span>
) : <span className="text-muted-foreground text-xs"></span>}
</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><span className="font-mono text-sm text-muted-foreground">{b.sort}</span></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={() => onEdit(b)}>
<Pencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onDelete(b)}>
<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">
{(page - 1) * pageSize + 1}-{Math.min(page * pageSize, total)} {total}
</p>
<div className="flex items-center 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>
)}
</>
)
}
// ==================== Dialog ====================
interface BannerDialogProps {
open: boolean; onOpenChange: (v: boolean) => void; isEdit: boolean
formData: BannerFormData; setFormData: React.Dispatch<React.SetStateAction<BannerFormData>>
submitting: boolean; uploading: boolean
fileRef: React.RefObject<HTMLInputElement | null>
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
onSubmit: () => void
}
function BannerDialog({ open, onOpenChange, isEdit, formData, setFormData, submitting, uploading, fileRef, onImageUpload, onSubmit }: BannerDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
<div className={cn("p-2 rounded-lg", isEdit ? "bg-primary/10 text-primary" : "bg-purple-500/10 text-purple-600")}>
{isEdit ? <Pencil className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
</div>
{isEdit ? '编辑轮播图' : '新增轮播图'}
</DialogTitle>
<DialogDescription>{isEdit ? '修改轮播图信息' : '上传图片并配置轮播图'}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 max-h-[60vh] overflow-y-auto pr-1">
{/* Title */}
<div className="grid gap-2">
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider"> *</Label>
<Input value={formData.title} onChange={e => setFormData(p => ({ ...p, title: e.target.value }))}
placeholder="如:春日花园" className="h-10 bg-muted/30 focus:bg-background" />
</div>
{/* Image Upload */}
<div className="grid gap-2">
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider"> *</Label>
<input type="file" accept="image/*" ref={fileRef} className="hidden" onChange={onImageUpload} />
{formData.imageUrl ? (
<div className="relative group">
<img src={formData.imageUrl} alt="预览" className="w-full h-40 object-cover rounded-lg border" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
<Button variant="secondary" size="sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
</Button>
</div>
</div>
) : (
<div className="border-2 border-dashed rounded-lg h-40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => fileRef.current?.click()}>
{uploading ? <Loader2 className="h-8 w-8 animate-spin text-primary" /> : <Upload className="h-8 w-8 text-muted-foreground/40" />}
<p className="text-sm text-muted-foreground">{uploading ? '上传中...' : '点击上传图片'}</p>
</div>
)}
</div>
{/* Status & Sort */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider"></Label>
<Select value={String(formData.isActive)} onValueChange={v => setFormData(p => ({ ...p, isActive: parseInt(v) }))}>
<SelectTrigger className="h-10"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider"> ()</Label>
<Input type="number" min={0} value={formData.sort}
onChange={e => setFormData(p => ({ ...p, sort: parseInt(e.target.value) || 0 }))}
className="h-10 font-mono bg-muted/30 focus:bg-background" />
</div>
</div>
{/* Target URL */}
<div className="grid gap-2">
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider"></Label>
<Input value={formData.targetUrl} onChange={e => setFormData(p => ({ ...p, targetUrl: e.target.value }))}
placeholder="如:/pages/wiki/index" className="h-10 bg-muted/30 focus:bg-background" />
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting} className="h-10 px-6"></Button>
<Button onClick={onSubmit} disabled={submitting || !formData.title || !formData.imageId} className="h-10 px-6 min-w-[80px]">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}