feat: 小程序首页banner管理
This commit is contained in:
@@ -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')
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user