|
|
@@ -596,9 +596,12 @@ const handleCreateSubmit = async (data: CreateRequest) => {
|
|
|
import { Skeleton } from '@/client/components/ui/skeleton';
|
|
|
```
|
|
|
|
|
|
-###### 完整骨架屏实现
|
|
|
+###### 骨架屏实现策略
|
|
|
+
|
|
|
+**1. 页面级加载(首次加载)**
|
|
|
+适用于首次进入页面时的完整加载,替换整个页面内容:
|
|
|
```tsx
|
|
|
-if (isLoading) {
|
|
|
+if (isLoading && !data) {
|
|
|
return (
|
|
|
<div className="space-y-4">
|
|
|
{/* 标题区域骨架 */}
|
|
|
@@ -652,9 +655,53 @@ if (isLoading) {
|
|
|
}
|
|
|
```
|
|
|
|
|
|
-###### 简化骨架屏(推荐)
|
|
|
+**2. 表格级加载(搜索/翻页)**
|
|
|
+适用于搜索、翻页等操作时的局部加载,保持页面结构不变:
|
|
|
```tsx
|
|
|
-if (isLoading) {
|
|
|
+<TableBody>
|
|
|
+ {isLoading ? (
|
|
|
+ // 加载状态 - 表格骨架屏
|
|
|
+ [...Array(5)].map((_, index) => (
|
|
|
+ <TableRow key={index}>
|
|
|
+ <TableCell><Skeleton className="w-8 h-8 rounded-full" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-6 w-12" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-6 w-10" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
|
+ <TableCell className="text-right">
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
+ <Skeleton className="h-8 w-8 rounded-md" />
|
|
|
+ <Skeleton className="h-8 w-8 rounded-md" />
|
|
|
+ <Skeleton className="h-8 w-8 rounded-md" />
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))
|
|
|
+ ) : data.length > 0 ? (
|
|
|
+ // 正常数据展示
|
|
|
+ data.map((item) => (
|
|
|
+ <TableRow key={item.id}>
|
|
|
+ {/* 数据行内容 */}
|
|
|
+ </TableRow>
|
|
|
+ ))
|
|
|
+ ) : (
|
|
|
+ // 空数据状态
|
|
|
+ <TableRow>
|
|
|
+ <TableCell colSpan={10} className="text-center py-8">
|
|
|
+ <p className="text-muted-foreground">暂无数据</p>
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ )}
|
|
|
+</TableBody>
|
|
|
+```
|
|
|
+
|
|
|
+**3. 简化骨架屏(推荐用于简单列表)**
|
|
|
+```tsx
|
|
|
+if (isLoading && !data) {
|
|
|
return (
|
|
|
<div className="space-y-4">
|
|
|
<div className="flex justify-between items-center">
|
|
|
@@ -681,15 +728,173 @@ if (isLoading) {
|
|
|
}
|
|
|
```
|
|
|
|
|
|
+**4. 区分加载状态的逻辑**
|
|
|
+```typescript
|
|
|
+// 使用React Query的isLoading和isFetching区分加载类型
|
|
|
+const { data, isLoading, isFetching } = useQuery({
|
|
|
+ queryKey: ['entities', searchParams],
|
|
|
+ queryFn: async () => {
|
|
|
+ const res = await entityClient.$get({
|
|
|
+ query: searchParams
|
|
|
+ });
|
|
|
+ if (res.status !== 200) throw new Error('获取列表失败');
|
|
|
+ return await res.json();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 首次加载:isLoading为true,data为undefined
|
|
|
+// 后台刷新:isFetching为true,data有值
|
|
|
+
|
|
|
+// 可以根据不同状态显示不同的骨架屏
|
|
|
+const showFullSkeleton = isLoading && !data; // 首次加载
|
|
|
+const showTableSkeleton = isFetching && data; // 搜索/翻页加载
|
|
|
+
|
|
|
##### 空数据状态
|
|
|
```tsx
|
|
|
-{users.length === 0 && !isLoading && (
|
|
|
- <div className="text-center py-8">
|
|
|
- <p className="text-muted-foreground">暂无数据</p>
|
|
|
- </div>
|
|
|
+{data?.length === 0 && !isLoading && (
|
|
|
+ <TableRow>
|
|
|
+ <TableCell colSpan={10} className="text-center py-8">
|
|
|
+ <p className="text-muted-foreground">暂无数据</p>
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
)}
|
|
|
```
|
|
|
|
|
|
+##### 最佳实践总结
|
|
|
+
|
|
|
+1. **首次加载**: 使用页面级骨架屏,替换整个页面内容
|
|
|
+2. **搜索/翻页**: 使用表格级骨架屏,保持页面结构不变
|
|
|
+3. **区分状态**: 使用 `isLoading` 和 `isFetching` 区分不同加载类型
|
|
|
+4. **空数据**: 在表格内部显示空数据状态,保持布局一致性
|
|
|
+5. **用户体验**: 避免全屏刷新,提供平滑的加载过渡
|
|
|
+
|
|
|
+##### Users.tsx 示例实现
|
|
|
+```tsx
|
|
|
+// 正确的方式:在TableBody内部处理加载状态
|
|
|
+<TableBody>
|
|
|
+ {isLoading ? (
|
|
|
+ // 搜索/翻页时的表格级骨架屏
|
|
|
+ [...Array(5)].map((_, index) => (
|
|
|
+ <TableRow key={index}>
|
|
|
+ <TableCell><Skeleton className="w-8 h-8 rounded-full" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-6 w-12" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-6 w-10" /></TableCell>
|
|
|
+ <TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
|
+ <TableCell className="text-right">
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
+ <Skeleton className="h-8 w-8 rounded-md" />
|
|
|
+ <Skeleton className="h-8 w-8 rounded-md" />
|
|
|
+ <Skeleton className="h-8 w-8 rounded-md" />
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))
|
|
|
+ ) : users.length > 0 ? (
|
|
|
+ // 正常数据展示
|
|
|
+ users.map((user) => (
|
|
|
+ <TableRow key={user.id}>
|
|
|
+ {/* 数据行内容 */}
|
|
|
+ </TableRow>
|
|
|
+ ))
|
|
|
+ ) : (
|
|
|
+ // 空数据状态
|
|
|
+ <TableRow>
|
|
|
+ <TableCell colSpan={10} className="text-center py-8">
|
|
|
+ <p className="text-muted-foreground">暂无数据</p>
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ )}
|
|
|
+</TableBody>
|
|
|
+
|
|
|
+// 错误的方式:避免在组件顶层返回骨架屏
|
|
|
+// if (isLoading) {
|
|
|
+// return <div>全屏骨架屏...</div>; // 这会导致搜索/翻页时全屏刷新
|
|
|
+// }
|
|
|
+```
|
|
|
+
|
|
|
+##### React Query 状态管理详解
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 使用React Query的完整状态管理
|
|
|
+const {
|
|
|
+ data, // 响应数据
|
|
|
+ isLoading, // 首次加载(无缓存数据)
|
|
|
+ isFetching, // 任何正在进行的请求
|
|
|
+ isError, // 请求失败
|
|
|
+ error, // 错误对象
|
|
|
+ refetch // 手动重新获取数据
|
|
|
+} = useQuery({
|
|
|
+ queryKey: ['users', searchParams], // 查询键,包含所有依赖参数
|
|
|
+ queryFn: async () => {
|
|
|
+ const res = await userClient.$get({
|
|
|
+ query: {
|
|
|
+ page: searchParams.page,
|
|
|
+ pageSize: searchParams.pageSize,
|
|
|
+ keyword: searchParams.keyword,
|
|
|
+ // 其他查询参数...
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (res.status !== 200) throw new Error('获取用户列表失败');
|
|
|
+ return await res.json();
|
|
|
+ },
|
|
|
+ keepPreviousData: true, // 保持旧数据直到新数据到达
|
|
|
+ staleTime: 5 * 60 * 1000, // 数据过期时间(5分钟)
|
|
|
+});
|
|
|
+
|
|
|
+// 状态组合示例
|
|
|
+const showFullLoading = isLoading && !data; // 首次加载,显示完整页面骨架
|
|
|
+const showTableLoading = isFetching && data; // 后台刷新,显示表格骨架
|
|
|
+const showError = isError; // 显示错误状态
|
|
|
+const showData = !isLoading && data; // 显示正常数据
|
|
|
+
|
|
|
+// 在组件中使用
|
|
|
+return (
|
|
|
+ <div>
|
|
|
+ {/* 页面标题和操作按钮区域 - 始终显示 */}
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <h1 className="text-2xl font-bold">用户管理</h1>
|
|
|
+ <Button onClick={() => setIsCreateModalOpen(true)}>
|
|
|
+ <Plus className="mr-2 h-4 w-4" />
|
|
|
+ 创建用户
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 搜索区域 - 始终显示 */}
|
|
|
+ <Card className="mt-4">
|
|
|
+ <CardContent className="pt-6">
|
|
|
+ <SearchForm onSearch={handleSearch} />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* 数据表格区域 */}
|
|
|
+ {showFullLoading && (
|
|
|
+ // 首次加载:完整页面骨架
|
|
|
+ <FullPageSkeleton />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {showTableLoading && (
|
|
|
+ // 后台刷新:表格骨架(保持页面结构)
|
|
|
+ <TableSkeleton />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {showError && (
|
|
|
+ // 错误状态
|
|
|
+ <ErrorState error={error} onRetry={refetch} />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {showData && (
|
|
|
+ // 正常数据展示
|
|
|
+ <DataTable data={data} />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+);
|
|
|
+```
|
|
|
+
|
|
|
### 3. 功能实现
|
|
|
|
|
|
#### 数据表格
|