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