mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 23:16:37 +08:00
48d414e197
swagger 移除 merchant swagger 改造首页内容为通用后台管理系统定位 - 修改首页标题从 'LINUX DO Credit' 改为 'Modern Platform' - 更新副标题为 '为二次开发而生' - 更新首页描述为通用平台的特点 - 更新首页特性标签为 '开箱即用、高度可扩展、工业级基建' - 修改展示卡片为技术栈和二次开发相关 - 更新开发者示例代码为通用的注册和 API Key 获取示例 - 更新页脚品牌名为 'Modern Platform' - 调整页脚导航链接为通用平台相关内容 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 去除遗留 裁剪 移除 /api/v1/user/pay-key 相关代码 - 删除后端 UpdatePayKey 处理器函数和 UpdatePayKeyRequest 结构体 - 删除 User 模型中的 PayKey 字段 - 删除 User.VerifyPayKey 方法 - 删除 EncryptPayKeyFailed 错误常量 - 删除 /api/v1/user/pay-key PUT 路由 - 删除 OAuth 返回中的 IsPayKey 字段 - 删除前端 UserService.updatePayKey 方法 - 删除前端所有支付密钥 UI 和逻辑 - 更新相关的导出和注释 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 去除遗留 api 修正 系统配置 前端裁剪 后端裁剪 init
397 lines
11 KiB
TypeScript
397 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 { ListRestart, Layers, LucideIcon, Trash2 } from "lucide-react"
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog"
|
|
import { TableHeader, TableRow, TableHead, TableBody, TableCell } 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) 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={handleDeleteClick}
|
|
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>
|
|
))}
|
|
<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>
|
|
))}
|
|
<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>
|
|
)
|
|
}
|