From a37c3a947e880ca74c88c12214394c2f357ef32b Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 27 Apr 2026 11:29:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F=E9=A6=96?= =?UTF-8?q?=E9=A1=B5banner=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 47 ++++ src/pages/plant/banner/index.tsx | 432 +++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 src/api/banner.ts create mode 100644 src/pages/plant/banner/index.tsx diff --git a/src/api/banner.ts b/src/api/banner.ts new file mode 100644 index 0000000..c06c54b --- /dev/null +++ b/src/api/banner.ts @@ -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 }>('/plantBanner/list', data) +} + +// 创建轮播图 +export function createBanner(data: Partial) { + return post<{ msg: string }>('/plantBanner/create', data) +} + +// 更新轮播图 +export function updateBanner(data: Partial) { + 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') +} diff --git a/src/pages/plant/banner/index.tsx b/src/pages/plant/banner/index.tsx new file mode 100644 index 0000000..0e0e01c --- /dev/null +++ b/src/pages/plant/banner/index.tsx @@ -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([]) + 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('all') + + const [dialogOpen, setDialogOpen] = useState(false) + const [isEdit, setIsEdit] = useState(false) + const [formData, setFormData] = useState(defaultForm) + const [submitting, setSubmitting] = useState(false) + const [uploading, setUploading] = useState(false) + const fileRef = useRef(null) + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [toDelete, setToDelete] = useState(null) + + // Fetch + const fetchList = useCallback(async () => { + setLoading(true) + try { + const params: Record = { + 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) => { + 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 ( +
+ {/* Stats */} +
+ + + + 轮播图总数 + + + + +
{total}
+
+
+ + + + 已启用 + + + + +
{activeCount}
+
+
+ +
+ + {/* Table Card */} + + +
+ + 轮播图列表 + {total} + + 管理花园首页的轮播图,支持排序和启停 +
+
+
+ + { setSearchTerm(e.target.value); setPage(1) }} /> +
+ + +
+
+ + { setToDelete(b); setDeleteDialogOpen(true) }} /> + +
+ + {/* Create/Edit Dialog */} + + + {/* Delete Dialog */} + + + + 确认删除 + + 确定要删除轮播图「{toDelete?.title}」吗?此操作无法撤销。 + + + + + + + + +
+ ) +} + +// ==================== Sub Components ==================== + +function AddCard({ onClick }: { onClick: () => void }) { + return ( + + + 快速添加 +
+ +
+
+ +
New
+

点击添加新轮播图

+
+
+ ) +} + +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 ( +
+

正在加载...

+
+ ) + return ( + <> +
+ + + + 预览 + 标题 + 跳转链接 + 状态 + +
排序
+
+ +
+
+ + {banners.length === 0 ? ( + + +
+ +

暂无轮播图

+

点击"新增"添加第一张轮播图

+
+
+
+ ) : banners.map((b, i) => ( + + + {b.image ? ( + {b.title} + ) : ( +
+ +
+ )} +
+

{b.title}

+ + {b.targetUrl ? ( + {b.targetUrl} + ) : } + + + {b.isActive === 1 + ? 启用 + : 禁用} + + {b.sort} + + + + + + + onEdit(b)}> + 编辑 + + onDelete(b)}> + 删除 + + + + +
+ ))} +
+
+
+ {totalPages > 1 && ( +
+

+ 显示第 {(page - 1) * pageSize + 1}-{Math.min(page * pageSize, total)} 条,共 {total} 条 +

+
+ + {page} / {totalPages} + +
+
+ )} + + ) +} + +// ==================== Dialog ==================== + +interface BannerDialogProps { + open: boolean; onOpenChange: (v: boolean) => void; isEdit: boolean + formData: BannerFormData; setFormData: React.Dispatch> + submitting: boolean; uploading: boolean + fileRef: React.RefObject + onImageUpload: (e: React.ChangeEvent) => void + onSubmit: () => void +} + +function BannerDialog({ open, onOpenChange, isEdit, formData, setFormData, submitting, uploading, fileRef, onImageUpload, onSubmit }: BannerDialogProps) { + return ( + + + + +
+ {isEdit ? : } +
+ {isEdit ? '编辑轮播图' : '新增轮播图'} +
+ {isEdit ? '修改轮播图信息' : '上传图片并配置轮播图'} +
+
+ {/* Title */} +
+ + setFormData(p => ({ ...p, title: e.target.value }))} + placeholder="如:春日花园" className="h-10 bg-muted/30 focus:bg-background" /> +
+ {/* Image Upload */} +
+ + + {formData.imageUrl ? ( +
+ 预览 +
+ +
+
+ ) : ( +
fileRef.current?.click()}> + {uploading ? : } +

{uploading ? '上传中...' : '点击上传图片'}

+
+ )} +
+ {/* Status & Sort */} +
+
+ + +
+
+ + setFormData(p => ({ ...p, sort: parseInt(e.target.value) || 0 }))} + className="h-10 font-mono bg-muted/30 focus:bg-background" /> +
+
+ {/* Target URL */} +
+ + setFormData(p => ({ ...p, targetUrl: e.target.value }))} + placeholder="如:/pages/wiki/index" className="h-10 bg-muted/30 focus:bg-background" /> +
+
+ + + + +
+
+ ) +}