This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
@@ -1,11 +1,11 @@
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 { 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,
@@ -15,49 +15,54 @@ import {
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"
} 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
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>
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
}[]
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
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
emptyIcon?: LucideIcon;
emptyDescription?: string;
loadingDescription?: string;
getId: (item: T) => string | number;
headerExtra?: React.ReactNode;
}
export function ManagePage<T>({
@@ -73,85 +78,85 @@ export function ManagePage<T>({
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = "暂无数据",
loadingDescription = "加载中",
emptyDescription = '暂无数据',
loadingDescription = '加载中',
getId,
headerExtra
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 [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)
}
setHoveredItem(item);
};
/** 选择处理 */
const handleSelect = (item: T) => {
const itemId = getId(item)
const selectedId = selectedItem ? getId(selectedItem) : null
const itemId = getId(item);
const selectedId = selectedItem ? getId(selectedItem) : null;
if (itemId === selectedId) {
setSelectedItem(null)
setEditData({})
setSelectedItem(null);
setEditData({});
} else {
setSelectedItem(item)
setEditData(getInitialEditData(item))
setSelectedItem(item);
setEditData(getInitialEditData(item));
}
setHoveredItem(null)
}
setHoveredItem(null);
};
/** 编辑数据处理 */
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
setEditData(prev => ({
setEditData((prev) => ({
...prev,
[field]: value
}))
}
[field]: value,
}));
};
/** 保存处理 */
const handleSave = async () => {
if (!selectedItem) return
if (!selectedItem) return;
try {
setSaving(true)
await onSave(selectedItem, editData)
toast.success('保存成功')
setSaving(true);
await onSave(selectedItem, editData);
toast.success('保存成功');
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误'
})
description: error instanceof Error ? error.message : '未知错误',
});
} finally {
setSaving(false)
setSaving(false);
}
}
};
/** 删除处理 */
const handleDeleteClick = (item: T) => {
setDeletingItem(item)
}
setDeletingItem(item);
};
const handleConfirmDelete = async () => {
if (!deletingItem || !onDelete) return
if (!deletingItem || !onDelete) return;
try {
await onDelete(deletingItem)
toast.success('删除成功')
setDeletingItem(null)
await onDelete(deletingItem);
toast.success('删除成功');
setDeletingItem(null);
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
setSelectedItem(null)
setEditData({})
setSelectedItem(null);
setEditData({});
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误'
})
description: error instanceof Error ? error.message : '未知错误',
});
}
}
};
/** 渲染内容 */
const renderContent = () => {
@@ -161,28 +166,25 @@ export function ManagePage<T>({
icon={ListRestart}
description={loadingDescription}
/>
)
);
}
if (error) {
return (
<div className="p-8 border border-dashed rounded-lg">
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
error={error}
onRetry={onReload}
className="justify-center"
className='justify-center'
/>
</div>
)
);
}
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription}
/>
)
<EmptyStateWithBorder icon={emptyIcon} description={emptyDescription} />
);
}
return (
@@ -196,27 +198,25 @@ export function ManagePage<T>({
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 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>
<h1 className='text-2xl font-semibold tracking-tight'>{title}</h1>
</div>
</div>
{headerExtra}
</div>
<div className="space-y-6">
<div className='space-y-6'>
<div>
<div className="mb-4">
<div className="font-semibold">配置列表</div>
<div className='mb-4'>
<div className='font-semibold'>配置列表</div>
</div>
{renderContent()}
</div>
@@ -228,12 +228,15 @@ export function ManagePage<T>({
editData,
onEditDataChange: handleEditDataChange,
onSave: handleSave,
saving
saving,
})}
</div>
</div>
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
<AlertDialog
open={!!deletingItem}
onOpenChange={(open) => !open && setDeletingItem(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
@@ -243,67 +246,73 @@ export function ManagePage<T>({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
<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
title?: string;
isEmpty: boolean;
emptyDescription?: string;
onSave?: () => void;
saving?: boolean;
children: React.ReactNode;
}
/** 详情面板 */
export function ManageDetailPanel({
title = "配置信息",
title = '配置信息',
isEmpty,
emptyDescription = "请选择配置查看详情",
emptyDescription = '请选择配置查看详情',
onSave,
saving = false,
children
children,
}: ManageDetailPanelProps) {
if (isEmpty) {
return (
<div className="space-y-4">
<div className="font-semibold mb-4">{title}</div>
<EmptyStateWithBorder
icon={Layers}
description={emptyDescription}
/>
<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 className='space-y-4 sticky top-6'>
<div>
<div className="flex items-center justify-between mb-4">
<div className="font-semibold">{title}</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"
size='sm'
className='px-3 h-7 text-xs'
>
{saving ? (<><Spinner /> 更新中</>) : '更新'}
{saving ? (
<>
<Spinner /> 更新中
</>
) : (
'更新'
)}
</Button>
)}
</div>
{children}
</div>
</div>
)
);
}
/** 表格面板 */
@@ -315,56 +324,61 @@ export function ManageTable<T>({
onSelect,
onHover,
onDelete,
getId
getId,
}: {
data: T[]
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
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">
<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">
<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 || '' }`}
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>}
{onDelete && (
<TableHead className='whitespace-nowrap text-center w-[120px]'>
操作
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody className="animate-in fade-in duration-200">
<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
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'
}`}
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)}
@@ -372,34 +386,34 @@ export function ManageTable<T>({
{columns.map((col, index) => (
<TableCell
key={index}
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
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">
<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"
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)
e.stopPropagation();
onDelete(item);
}}
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className='h-3.5 w-3.5' />
</Button>
</TableCell>
)}
</TableRow>
)
);
})}
</TableBody>
</table>
</div>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation='horizontal' />
</ScrollArea>
</div>
)
);
}
@@ -1,34 +1,34 @@
"use client"
'use client';
import * as React from "react"
import { useState } from "react"
import * as React from 'react';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} from '@/components/ui/dialog';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
} from '@/components/ui/input-otp';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
interface PasswordDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConfirm: (password: string) => void
loading?: boolean
title?: string
description?: string
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (password: string) => void;
loading?: boolean;
title?: string;
description?: string;
}
/**
* 密码输入对话框
*
*
* 用于敏感操作前的二次确认
*/
export function PasswordDialog({
@@ -36,35 +36,33 @@ export function PasswordDialog({
onOpenChange,
onConfirm,
loading = false,
title = "请输入密码",
description = "为了您的账户安全,请输入6位认证密码进行验证"
title = '请输入密码',
description = '为了您的账户安全,请输入6位认证密码进行验证',
}: PasswordDialogProps) {
const [password, setPassword] = useState("")
const [password, setPassword] = useState('');
const handleConfirm = () => {
if (password.length === 6) {
onConfirm(password)
onConfirm(password);
}
}
};
/* 当对话框关闭时重置密码*/
React.useEffect(() => {
if (!isOpen) {
setPassword("")
setPassword('');
}
}, [isOpen])
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="w-xs sm:w-md">
<DialogContent className='w-xs sm:w-md'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="py-2 flex justify-center">
<div className='py-2 flex justify-center'>
<InputOTP
maxLength={6}
value={password}
@@ -72,47 +70,47 @@ export function PasswordDialog({
disabled={loading}
onKeyDown={(e) => {
if (e.key === 'Enter' && password.length === 6 && !loading) {
handleConfirm()
handleConfirm();
}
}}
>
<InputOTPGroup className="gap-2">
<InputOTPSlot index={0} className="w-10 h-10 text-base" />
<InputOTPSlot index={1} className="w-10 h-10 text-base" />
<InputOTPSlot index={2} className="w-10 h-10 text-base" />
<InputOTPSlot index={3} className="w-10 h-10 text-base" />
<InputOTPSlot index={4} className="w-10 h-10 text-base" />
<InputOTPSlot index={5} className="w-10 h-10 text-base" />
<InputOTPGroup className='gap-2'>
<InputOTPSlot index={0} className='w-10 h-10 text-base' />
<InputOTPSlot index={1} className='w-10 h-10 text-base' />
<InputOTPSlot index={2} className='w-10 h-10 text-base' />
<InputOTPSlot index={3} className='w-10 h-10 text-base' />
<InputOTPSlot index={4} className='w-10 h-10 text-base' />
<InputOTPSlot index={5} className='w-10 h-10 text-base' />
</InputOTPGroup>
</InputOTP>
</div>
<div className="flex justify-end gap-2 pt-2">
<div className='flex justify-end gap-2 pt-2'>
<Button
variant="ghost"
variant='ghost'
onClick={() => onOpenChange(false)}
disabled={loading}
className="h-8 text-xs"
className='h-8 text-xs'
>
取消
</Button>
<Button
type="button"
className="bg-primary hover:bg-primary/90 h-8 text-xs"
type='button'
className='bg-primary hover:bg-primary/90 h-8 text-xs'
onClick={handleConfirm}
disabled={password.length !== 6 || loading}
>
{loading ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Spinner className='mr-2 h-4 w-4' />
验证中...
</>
) : (
"确认"
'确认'
)}
</Button>
</div>
</DialogContent>
</Dialog>
)
);
}