mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
420 lines
12 KiB
TypeScript
420 lines
12 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;
|
|
icon?: LucideIcon;
|
|
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,
|
|
icon: Icon,
|
|
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 pb-2 mb-6 items-center justify-between'>
|
|
<div className='flex items-center gap-2'>
|
|
{Icon && <Icon className='size-5 text-primary' />}
|
|
<div>
|
|
<h1 className='text-2xl font-semibold tracking-tight'>{title}</h1>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|