chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
115
admin/src/components/ui/skeleton.tsx
Normal file
115
admin/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// ============================================================
|
||||
// Skeleton 组件 — 替代全屏 spinner 的骨架屏
|
||||
// ============================================================
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function SkeletonBase({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-md bg-muted',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** 表格骨架屏 */
|
||||
export function TableSkeleton({
|
||||
rows = 5,
|
||||
cols = 5,
|
||||
hasToolbar = true,
|
||||
}: {
|
||||
rows?: number
|
||||
cols?: number
|
||||
hasToolbar?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{hasToolbar && (
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonBase className="h-9 w-[200px]" />
|
||||
<SkeletonBase className="h-9 w-[120px]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-muted/30 px-4 py-3">
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<SkeletonBase
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-4',
|
||||
i === 0 ? 'w-[120px]' : i === cols - 1 ? 'w-[80px]' : 'w-[100px]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIdx) => (
|
||||
<div
|
||||
key={rowIdx}
|
||||
className={cn(
|
||||
'px-4 py-3',
|
||||
rowIdx < rows - 1 && 'border-b border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length: cols }).map((_, colIdx) => (
|
||||
<SkeletonBase
|
||||
key={colIdx}
|
||||
className={cn(
|
||||
'h-4',
|
||||
colIdx === 0 ? 'w-[120px]' : colIdx === cols - 1 ? 'w-[80px]' : 'w-[100px]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonBase className="h-4 w-[140px]" />
|
||||
<div className="flex gap-2">
|
||||
<SkeletonBase className="h-8 w-[80px]" />
|
||||
<SkeletonBase className="h-8 w-[80px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 统计卡片骨架屏 */
|
||||
export function StatsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${count}`}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-6">
|
||||
<SkeletonBase className="h-4 w-[80px]" />
|
||||
<SkeletonBase className="mt-2 h-8 w-[100px]" />
|
||||
<SkeletonBase className="mt-1 h-3 w-[120px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 图表骨架屏 */
|
||||
export function ChartSkeleton({ height }: { height?: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<SkeletonBase className="h-5 w-[140px]" />
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<SkeletonBase className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SkeletonBase as Skeleton }
|
||||
63
admin/src/components/ui/state.tsx
Normal file
63
admin/src/components/ui/state.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { AlertCircle, Inbox } from 'lucide-react'
|
||||
|
||||
/** 统一的错误提示横幅 */
|
||||
export function ErrorBanner({
|
||||
message,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string
|
||||
onDismiss?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{message}</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="underline cursor-pointer shrink-0"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 统一的空状态占位 */
|
||||
export function EmptyState({
|
||||
message = '暂无数据',
|
||||
}: {
|
||||
message?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Inbox className="h-8 w-8" />
|
||||
<span className="text-sm">{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 统一的加载失败提示 + 重试 */
|
||||
export function ErrorRetry({
|
||||
message = '请求失败,请重试',
|
||||
onRetry,
|
||||
}: {
|
||||
message?: string
|
||||
onRetry: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<span className="text-sm">{message}</span>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user