mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 15:06:36 +08:00
399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
import {useState} from "react"
|
|
import {toast} from "sonner"
|
|
import {Button} from "@/components/ui/button"
|
|
import {Spinner} from "@/components/ui/spinner"
|
|
import {ErrorInline} from "@/components/layout/error"
|
|
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
|
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
|
import {Layers, ListRestart, LucideIcon, Trash2} from "lucide-react"
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog"
|
|
import {TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
|
import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area"
|
|
|
|
|
|
interface ManagePageProps<T> {
|
|
title: string
|
|
data: T[]
|
|
loading: boolean
|
|
error: Error | null
|
|
onReload: () => void
|
|
|
|
/** 获取初始编辑数据 */
|
|
getInitialEditData: (item: T) => Partial<T>
|
|
onSave: (item: T, editData: Partial<T>) => Promise<void>
|
|
onDelete?: (item: T) => Promise<void>
|
|
|
|
/** 渲染表格 (Config-Driven) */
|
|
columns: {
|
|
header: string
|
|
cell: (item: T) => React.ReactNode
|
|
width?: string
|
|
align?: 'left' | 'center' | 'right'
|
|
className?: string
|
|
}[]
|
|
|
|
/** 渲染详情 */
|
|
renderDetail: (props: {
|
|
selected: T | null
|
|
hovered: T | null
|
|
editData: Partial<T>
|
|
onEditDataChange: (field: keyof T, value: T[keyof T]) => void
|
|
onSave: () => void
|
|
saving: boolean
|
|
}) => React.ReactNode
|
|
|
|
/** 空状态图标 */
|
|
emptyIcon?: LucideIcon
|
|
emptyDescription?: string
|
|
loadingDescription?: string
|
|
getId: (item: T) => string | number
|
|
headerExtra?: React.ReactNode
|
|
}
|
|
|
|
export function ManagePage<T>({
|
|
title,
|
|
data,
|
|
loading,
|
|
error,
|
|
onReload,
|
|
getInitialEditData,
|
|
onSave,
|
|
onDelete,
|
|
columns,
|
|
renderDetail,
|
|
emptyIcon = Layers,
|
|
emptyDescription = "暂无数据",
|
|
loadingDescription = "加载中",
|
|
getId,
|
|
headerExtra
|
|
}: ManagePageProps<T>) {
|
|
/** 悬停状态 */
|
|
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
|
|
const [selectedItem, setSelectedItem] = useState<T | null>(null)
|
|
const [editData, setEditData] = useState<Partial<T>>({})
|
|
const [saving, setSaving] = useState(false)
|
|
const [deletingItem, setDeletingItem] = useState<T | null>(null)
|
|
|
|
/** 悬停处理 */
|
|
const handleHover = (item: T | null) => {
|
|
setHoveredItem(item)
|
|
}
|
|
|
|
/** 选择处理 */
|
|
const handleSelect = (item: T) => {
|
|
const itemId = getId(item)
|
|
const selectedId = selectedItem ? getId(selectedItem) : null
|
|
|
|
if (itemId === selectedId) {
|
|
setSelectedItem(null)
|
|
setEditData({})
|
|
} else {
|
|
setSelectedItem(item)
|
|
setEditData(getInitialEditData(item))
|
|
}
|
|
setHoveredItem(null)
|
|
}
|
|
|
|
/** 编辑数据处理 */
|
|
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
|
|
setEditData(prev => ({
|
|
...prev,
|
|
[field]: value
|
|
}))
|
|
}
|
|
|
|
/** 保存处理 */
|
|
const handleSave = async () => {
|
|
if (!selectedItem) return
|
|
|
|
try {
|
|
setSaving(true)
|
|
await onSave(selectedItem, editData)
|
|
toast.success('保存成功')
|
|
} catch (error) {
|
|
toast.error('保存失败', {
|
|
description: error instanceof Error ? error.message : '未知错误'
|
|
})
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
/** 删除处理 */
|
|
const handleDeleteClick = (item: T) => {
|
|
setDeletingItem(item)
|
|
}
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (!deletingItem || !onDelete) return
|
|
try {
|
|
await onDelete(deletingItem)
|
|
toast.success('删除成功')
|
|
setDeletingItem(null)
|
|
// 如果删除的是当前选中的项,清除选中状态
|
|
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
|
|
setSelectedItem(null)
|
|
setEditData({})
|
|
}
|
|
} catch (error) {
|
|
toast.error('删除失败', {
|
|
description: error instanceof Error ? error.message : '未知错误'
|
|
})
|
|
}
|
|
}
|
|
|
|
/** 渲染内容 */
|
|
const renderContent = () => {
|
|
if (loading && (!data || data.length === 0)) {
|
|
return (
|
|
<LoadingStateWithBorder
|
|
icon={ListRestart}
|
|
description={loadingDescription}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-8 border border-dashed rounded-lg">
|
|
<ErrorInline
|
|
error={error}
|
|
onRetry={onReload}
|
|
className="justify-center"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!data || data.length === 0) {
|
|
return (
|
|
<EmptyStateWithBorder
|
|
icon={emptyIcon}
|
|
description={emptyDescription}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ManageTable
|
|
data={data}
|
|
columns={columns}
|
|
selected={selectedItem}
|
|
hovered={hoveredItem}
|
|
onSelect={handleSelect}
|
|
onHover={handleHover}
|
|
onDelete={onDelete ? handleDeleteClick : undefined}
|
|
getId={getId}
|
|
/>
|
|
)
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
<div className="py-6">
|
|
<div className="flex border-b border-border pb-2 mb-6 items-center justify-between">
|
|
<div className="text-2xl font-semibold">{title}</div>
|
|
{headerExtra}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<div className="mb-4">
|
|
<div className="font-semibold">配置列表</div>
|
|
</div>
|
|
{renderContent()}
|
|
</div>
|
|
|
|
<div>
|
|
{renderDetail({
|
|
selected: selectedItem,
|
|
hovered: hoveredItem,
|
|
editData,
|
|
onEditDataChange: handleEditDataChange,
|
|
onSave: handleSave,
|
|
saving
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>确认删除?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
此操作无法撤销。这将永久删除该配置。
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
|
|
确认删除
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 详情面板 props */
|
|
interface ManageDetailPanelProps {
|
|
title?: string
|
|
isEmpty: boolean
|
|
emptyDescription?: string
|
|
onSave?: () => void
|
|
saving?: boolean
|
|
children: React.ReactNode
|
|
}
|
|
|
|
/** 详情面板 */
|
|
export function ManageDetailPanel({
|
|
title = "配置信息",
|
|
isEmpty,
|
|
emptyDescription = "请选择配置查看详情",
|
|
onSave,
|
|
saving = false,
|
|
children
|
|
}: ManageDetailPanelProps) {
|
|
if (isEmpty) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="font-semibold mb-4">{title}</div>
|
|
<EmptyStateWithBorder
|
|
icon={Layers}
|
|
description={emptyDescription}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 sticky top-6">
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="font-semibold">{title}</div>
|
|
{onSave && (
|
|
<Button
|
|
onClick={onSave}
|
|
disabled={saving}
|
|
size="sm"
|
|
className="px-3 h-7 text-xs"
|
|
>
|
|
{saving ? (<><Spinner /> 更新中</>) : '更新'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 表格面板 */
|
|
export function ManageTable<T>({
|
|
data,
|
|
columns,
|
|
selected,
|
|
hovered,
|
|
onSelect,
|
|
onHover,
|
|
onDelete,
|
|
getId
|
|
}: {
|
|
data: T[]
|
|
columns: {
|
|
header: string
|
|
cell: (item: T) => React.ReactNode
|
|
width?: string
|
|
align?: 'left' | 'center' | 'right'
|
|
className?: string
|
|
}[]
|
|
selected: T | null
|
|
hovered: T | null
|
|
onSelect: (item: T) => void
|
|
onHover: (item: T | null) => void
|
|
onDelete?: (item: T) => void
|
|
getId: (item: T) => string | number
|
|
}) {
|
|
return (
|
|
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
|
<ScrollArea className="w-full">
|
|
<div className="relative w-full">
|
|
<table className="w-full caption-bottom text-sm">
|
|
<TableHeader>
|
|
<TableRow className="border-b border-dashed">
|
|
{columns.map((col, index) => (
|
|
<TableHead
|
|
key={index}
|
|
className={`whitespace-nowrap ${ col.width ? `w-[${ col.width }]` : '' } ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
|
|
>
|
|
{col.header}
|
|
</TableHead>
|
|
))}
|
|
{onDelete && <TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody className="animate-in fade-in duration-200">
|
|
{data.map((item) => {
|
|
const id = getId(item)
|
|
const isSelected = selected && getId(selected) === id
|
|
const isHovered = hovered && getId(hovered) === id
|
|
|
|
return (
|
|
<TableRow
|
|
key={id}
|
|
className={`border-b border-dashed cursor-pointer transition-colors ${ isSelected
|
|
? 'bg-primary/5 hover:bg-primary/10'
|
|
: isHovered
|
|
? 'bg-gray-100 hover:bg-muted/50'
|
|
: 'hover:bg-gray-100'
|
|
}`}
|
|
onMouseEnter={() => onHover(item)}
|
|
onMouseLeave={() => onHover(null)}
|
|
onClick={() => onSelect(item)}
|
|
>
|
|
{columns.map((col, index) => (
|
|
<TableCell
|
|
key={index}
|
|
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
|
|
>
|
|
{col.cell(item)}
|
|
</TableCell>
|
|
))}
|
|
{onDelete && (
|
|
<TableCell className="text-xs py-1 text-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onDelete(item)
|
|
}}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</table>
|
|
</div>
|
|
<ScrollBar orientation="horizontal" />
|
|
</ScrollArea>
|
|
</div>
|
|
)
|
|
}
|