CrateCodeSnippetsPanel
code
import { useCallback, useMemo, useState } from "react";
import {
useCreate,
useCustomMutation,
useDelete,
useList,
useUpdate,
type BaseRecord,
type HttpError,
} from "@refinedev/core";
import type {
CrateCodeSnippet,
CrateCodeSnippetCreateInput,
CrateCodeSnippetFormValues,
CrateCodeSnippetReorderInput,
CrateCodeSnippetUpdateInput,
} from "@repo/schemas";
import { ArrowDown, ArrowUp, Code2, Pencil, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { LoadingState } from "@/components/ui/loading-state";
import {
DataProviderCustomAction,
ResourceName,
} from "@/integrations/refine/dataProvider";
import { CrateCodeSnippetDialog } from "@/components/CrateCodeSnippetDialog";
import { DeleteCrateCodeSnippetDialog } from "@/components/DeleteCrateCodeSnippetDialog";
import { moveCrateCodeSnippet } from "./moveCrateCodeSnippet";
// 这上面的不看了
export type CrateCodeSnippetsPanelProps = {
crateId: string;
};
export const CrateCodeSnippetsPanel = function ({
crateId,
}: CrateCodeSnippetsPanelProps) {
const { t } = useTranslation();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSnippet, setEditingSnippet] =
useState<CrateCodeSnippet | null>(null);
const [deletingSnippet, setDeletingSnippet] =
useState<CrateCodeSnippet | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [expandedSnippetIds, setExpandedSnippetIds] = useState<ReadonlySet<string>>(new Set<string>());
const { result, query } = useList<CrateCodeSnippet>({
resource: ResourceName.crateCodeSnippets,
filters: [{ field: "crateId", operator: "eq", value: crateId }],
pagination: { currentPage: 1, pageSize: 0 },
queryOptions: { enabled: crateId.length > 0 },
});
const snippets = useMemo(
() => (result?.data ?? []) as CrateCodeSnippet[],
[result?.data],
);
// 这里是Refine的使用,和useList、useOne很像的
const createMutation = useCreate<
CrateCodeSnippet,
HttpError,
CrateCodeSnippetCreateInput
>();
const updateMutation = useUpdate<
CrateCodeSnippet,
HttpError,
CrateCodeSnippetUpdateInput
>();
const deleteMutation = useDelete<CrateCodeSnippet>();
const reorderMutation = useCustomMutation<
BaseRecord,
HttpError,
CrateCodeSnippetReorderInput
>();
const handleAdd = useCallback(() => {
setActionError(null);
setEditingSnippet(null);
setDialogOpen(true);
}, []);
const handleEdit = useCallback((snippet: CrateCodeSnippet) => {
setActionError(null);
setEditingSnippet(snippet);
setDialogOpen(true);
}, []);
const handleCloseDialog = useCallback(() => {
setDialogOpen(false);
setEditingSnippet(null);
setActionError(null);
}, []);
const handleSubmit = useCallback(
(values: CrateCodeSnippetFormValues) => {
setActionError(null);
if (editingSnippet) {
updateMutation.mutate(
{
resource: ResourceName.crateCodeSnippets,
id: editingSnippet.id,
values,
},
{
onSuccess: handleCloseDialog,
onError: (error) => setActionError(error.message),
},
);
return;
}
createMutation.mutate(
{
resource: ResourceName.crateCodeSnippets,
values: {
id: crypto.randomUUID(),
crateId,
...values,
sortOrder: snippets.length,
},
},
{
onSuccess: handleCloseDialog,
onError: (error) => setActionError(error.message),
},
);
},
[
crateId,
createMutation,
editingSnippet,
handleCloseDialog,
snippets.length,
updateMutation,
],
);
const handleDelete = useCallback(() => {
if (!deletingSnippet) return;
setActionError(null);
deleteMutation.mutate(
{
resource: ResourceName.crateCodeSnippets,
id: deletingSnippet.id,
},
{
onSuccess: () => setDeletingSnippet(null),
onError: (error) => setActionError(error.message),
},
);
}, [deleteMutation, deletingSnippet]);
const handleMove = useCallback(
(index: number, direction: -1 | 1) => {
const reordered = moveCrateCodeSnippet(snippets, index, direction);
if (reordered === snippets) return;
setActionError(null);
reorderMutation.mutate(
{
url: DataProviderCustomAction.reorderCrateCodeSnippets,
method: "post",
values: {
crateId,
snippetIds: reordered.map((snippet) => snippet.id),
},
},
{
onSuccess: () => {
void query.refetch();
},
onError: (error) => setActionError(error.message),
},
);
},
[crateId, query, reorderMutation, snippets],
);
const handleRetry = useCallback(() => {
void query.refetch();
}, [query]);
const handleOpenDelete = useCallback((snippet: CrateCodeSnippet) => {
setActionError(null);
setDeletingSnippet(snippet);
}, []);
const handleCancelDelete = useCallback(() => {
setDeletingSnippet(null);
}, []);
const handleToggleExpand = useCallback((snippetId: string) => {
setExpandedSnippetIds((current) => {
const next = new Set<string>(current)
if (next.has(snippetId)) {
next.delete(snippetId);
} else {
next.add(snippetId);
}
return next
})
}, [])
const isSaving =
createMutation.mutation.isPending || updateMutation.mutation.isPending;
const isReordering = reorderMutation.mutation.isPending;
return (
<section aria-labelledby="crate-code-snippets-heading" className="space-y-3">
<div className="rounded-[10px] border border-[#ececef] dark:border-border bg-card px-[17px] py-4">
{/* <div className="text-[13px] font-semibold mb-[3px]">{t("crateDetail.linkedPaths", "Linked Paths")}</div> */}
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-[13px] font-semibold" id="crate-code-snippets-heading">
{t("crateDetail.codeSnippets")}
</h2>
<p className="mt-1 text-xs text-muted-foreground">
{t("crateDetail.codeSnippetsDescription")}
</p>
</div>
<Button size="sm" type="button" variant="outline" onClick={handleAdd}>
<Plus aria-hidden="true" className="size-4" />
{t("crateDetail.addSnippet")}
</Button>
</div>
{actionError && (
<p className="text-sm text-destructive" role="alert">
{actionError}
</p>
)}
{query.isLoading ? (
<LoadingState className="py-10" />
) : query.isError ? (
<ErrorState
action={
<Button type="button" variant="outline" onClick={handleRetry}>
{t("common.retry")}
</Button>
}
className="py-10"
message={query.error.message}
title={t("crateDetail.snippetsLoadError")}
/>
) : snippets.length === 0 ? (
<EmptyState
action={
<Button size="sm" type="button" onClick={handleAdd}>
{t("crateDetail.addSnippet")}
</Button>
}
className="py-10"
description={t("crateDetail.noSnippetsDescription")}
icon={<Code2 aria-hidden="true" className="size-7" />}
title={t("crateDetail.noSnippets")}
/>
) : (
<div className="space-y-3 mt-4">
{snippets.map((snippet, index) => {
const isExpanded = expandedSnippetIds.has(snippet.id);
// const codeRegionId = `crate-code-snippet-${snippet.id}-code`;
return (
<Card key={snippet.id}>
<CardHeader className="gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold">{snippet.name}</h3>
<Badge>{snippet.language}</Badge>
</div>
{snippet.description && (
<p className="mt-1 text-xs text-muted-foreground">
{snippet.description}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
aria-label={t("crateDetail.moveSnippetUp", { name: snippet.name })}
className="size-8 p-0"
disabled={index === 0 || isReordering}
size="sm"
type="button"
variant="ghost"
onClick={() => handleMove(index, -1)}
>
<ArrowUp aria-hidden="true" className="size-4" />
</Button>
<Button
aria-label={t("crateDetail.moveSnippetDown", { name: snippet.name })}
className="size-8 p-0"
disabled={index === snippets.length - 1 || isReordering}
size="sm"
type="button"
variant="ghost"
onClick={() => handleMove(index, 1)}
>
<ArrowDown aria-hidden="true" className="size-4" />
</Button>
<Button
aria-label={t("crateDetail.editSnippetNamed", { name: snippet.name })}
className="size-8 p-0"
size="sm"
type="button"
variant="ghost"
onClick={() => handleEdit(snippet)}
>
<Pencil aria-hidden="true" className="size-4" />
</Button>
<Button
aria-label={t("crateDetail.deleteSnippetNamed", { name: snippet.name })}
className="size-8 p-0"
size="sm"
type="button"
variant="ghost"
onClick={() => handleOpenDelete(snippet)}
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
<Button
aria-label={t("crateDetail.deleteSnippetNamed", { name: snippet.name })}
className="size-8 p-0"
size="sm"
type="button"
variant="ghost"
onClick={() => handleToggleExpand(snippet.id)}
>
{isExpanded ? (<ChevronDown aria-hidden="true" className="size-4" />) : (<ChevronRight aria-hidden="true" className="size-4" />)}
</Button>
</div>
</CardHeader>
{isExpanded && (
<CardContent>
<pre className="max-h-80 overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
<code className="font-mono">{snippet.code}</code>
</pre>
</CardContent>
)}
</Card>
)
}
)}
</div>
)}
<CrateCodeSnippetDialog
initialData={editingSnippet}
isSubmitting={isSaving}
open={dialogOpen}
serverError={actionError}
onClose={handleCloseDialog}
onSubmit={handleSubmit}
/>
<DeleteCrateCodeSnippetDialog
isDeleting={deleteMutation.mutation.isPending}
snippet={deletingSnippet}
onCancel={handleCancelDelete}
onConfirm={handleDelete}
/>
</div>
</section>
);
};
解析
这个 CrateCodeSnippetsPanel 是上一段 CrateCodeSnippetDialog 的父组件和业务容器组件。
上一段 Dialog 主要负责:
展示表单
收集输入
执行 Zod 校验
把合法数据交给父组件
而这个父组件主要负责:
从后端获取代码片段
控制新增/编辑弹窗
调用新增、修改、删除、排序接口
展示列表状态
控制代码展开收起
把状态和回调传给 Dialog
整体关系可以理解为:
CrateCodeSnippetsPanel
├── 获取代码片段列表
├── 管理新增、编辑、删除、排序
├── 展示代码片段卡片
├── CrateCodeSnippetDialog
│ └── 新增/编辑表单
└── DeleteCrateCodeSnippetDialog
└── 删除确认弹窗
一、组件接收的参数
export type CrateCodeSnippetsPanelProps = {
crateId: string;
};
这个组件只接收一个 crateId。
export const CrateCodeSnippetsPanel = function ({
crateId,
}: CrateCodeSnippetsPanelProps) {
这里的含义是:
> 当前面板展示的是哪个 Crate 下面的代码片段。
后面查询、创建和排序时,都会用到这个 crateId。
例如:
Crate A
├── snippet 1
├── snippet 2
└── snippet 3
Crate B
├── snippet 4
└── snippet 5
传入不同的 crateId,组件就会加载不同的代码片段。
二、组件管理的状态
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSnippet, setEditingSnippet] =
useState<CrateCodeSnippet | null>(null);
const [deletingSnippet, setDeletingSnippet] =
useState<CrateCodeSnippet | null>(null);
const [actionError, setActionError] =
useState<string | null>(null);
const [expandedSnippetIds, setExpandedSnippetIds] =
useState<ReadonlySet<string>>(new Set<string>());
父组件一共管理了五类状态。
1. dialogOpen
const [dialogOpen, setDialogOpen] = useState(false);
控制新增/编辑 Dialog 是否打开。
false → 弹窗关闭
true → 弹窗打开
最终传给子组件:
<CrateCodeSnippetDialog open={dialogOpen} />
2. editingSnippet
const [editingSnippet, setEditingSnippet] =
useState<CrateCodeSnippet | null>(null);
表示当前正在编辑哪个代码片段。
新增时:
editingSnippet === null
编辑时:
editingSnippet === 某个代码片段对象
因此,这个状态不仅保存编辑数据,也承担了判断新增还是编辑的作用:
if (editingSnippet) {
// 修改
} else {
// 新增
}
它最终作为上一段 Dialog 的 initialData:
<CrateCodeSnippetDialog
initialData={editingSnippet}
/>
所以:
editingSnippet = null
→ Dialog 显示空表单
→ 新增模式
editingSnippet = snippet
→ Dialog 填充已有数据
→ 编辑模式
3. deletingSnippet
const [deletingSnippet, setDeletingSnippet] =
useState<CrateCodeSnippet | null>(null);
表示当前准备删除哪个代码片段。
null
→ 删除确认弹窗关闭
某个 snippet
→ 删除确认弹窗打开
它被传给:
<DeleteCrateCodeSnippetDialog
snippet={deletingSnippet}
/>
这里和编辑弹窗的设计略有不同:
- 编辑弹窗使用单独的
dialogOpen - 删除弹窗直接根据
deletingSnippet是否为空判断是否打开
4. actionError
const [actionError, setActionError] =
useState<string | null>(null);
用于保存新增、修改、删除、排序等操作产生的错误。
例如:
onError: (error) => setActionError(error.message)
页面中会统一展示:
{actionError && (
<p className="text-sm text-destructive" role="alert">
{actionError}
</p>
)}
它也会传给新增/编辑 Dialog:
serverError={actionError}
所以这个状态目前同时承担:
列表区域操作错误
表单提交错误
删除操作错误
排序操作错误
5. expandedSnippetIds
const [expandedSnippetIds, setExpandedSnippetIds] =
useState<ReadonlySet<string>>(new Set<string>());
这个集合保存所有已展开代码片段的 ID。
例如:
new Set([
"snippet-1",
"snippet-3",
])
表示:
snippet-1 已展开
snippet-2 未展开
snippet-3 已展开
使用 Set 的好处是检查某个 ID 是否存在很方便:
expandedSnippetIds.has(snippet.id)
三、获取代码片段列表
const { result, query } = useList<CrateCodeSnippet>({
resource: ResourceName.crateCodeSnippets,
filters: [{ field: "crateId", operator: "eq", value: crateId }],
pagination: { currentPage: 1, pageSize: 0 },
queryOptions: { enabled: crateId.length > 0 },
});
这里使用 Refine 的 useList 获取列表数据。
resource
resource: ResourceName.crateCodeSnippets
告诉 Refine:
> 我要操作的是 crateCodeSnippets 资源。
它最终会通过项目的 dataProvider 发起请求。
可以大致理解为:
useList
↓
dataProvider.getList
↓
GET /crate-code-snippets
具体 URL 取决于项目的 dataProvider 实现。
filters
filters: [
{
field: "crateId",
operator: "eq",
value: crateId,
},
]
表示筛选:
crateId 等于当前传入的 crateId
可以近似理解成:
SELECT *
FROM crate_code_snippets
WHERE crate_id = ?
所以这个面板只显示当前 Crate 的代码片段。
pagination
pagination: {
currentPage: 1,
pageSize: 0,
}
从代码意图看,它希望获取当前 Crate 下的全部代码片段。
不过 pageSize: 0 的具体行为,要看当前 Refine 版本和项目 dataProvider 的约定。有些实现可能把 0 当成“不分页”,有些实现可能需要使用其他配置。
queryOptions.enabled
queryOptions: {
enabled: crateId.length > 0,
}
只有 crateId 不为空时才发起查询。
crateId = ""
→ 不请求
crateId = "crate-123"
→ 发起请求
这样可以避免出现无效请求:
GET /crate-code-snippets?crateId=
四、处理查询结果
const snippets = useMemo(
() => (result?.data ?? []) as CrateCodeSnippet[],
[result?.data],
);
result?.data 是 Refine 返回的列表数据。
如果查询还没完成,它可能是 undefined,所以使用:
result?.data ?? []
确保 snippets 始终是数组。
结果可能是:
const snippets = [
{
id: "1",
name: "React Context",
language: "tsx",
code: "...",
},
{
id: "2",
name: "CSS Grid",
language: "css",
code: "...",
},
];
这里的 useMemo 不是非常必要,因为只是:
result?.data ?? []
计算成本很低。
它主要能保证:
> 当 result.data 没变化时,snippets 尽量保持相同引用。
不过是否真的需要,要结合子组件优化和后续依赖来看。
五、创建四种 Mutation
这个组件需要执行:
新增
修改
删除
排序
因此创建了四个 Mutation。
1. 新增 Mutation
const createMutation = useCreate<
CrateCodeSnippet,
HttpError,
CrateCodeSnippetCreateInput
>();
泛型分别描述:
CrateCodeSnippet
→ 创建成功后返回的数据类型
HttpError
→ 失败时的错误类型
CrateCodeSnippetCreateInput
→ 创建接口接收的数据类型
2. 修改 Mutation
const updateMutation = useUpdate<
CrateCodeSnippet,
HttpError,
CrateCodeSnippetUpdateInput
>();
用于修改已有代码片段。
3. 删除 Mutation
const deleteMutation = useDelete<CrateCodeSnippet>();
用于根据 ID 删除代码片段。
4. 排序 Mutation
const reorderMutation = useCustomMutation<
BaseRecord,
HttpError,
CrateCodeSnippetReorderInput
>();
排序不是普通的单条增删改查,而是一个自定义接口,所以使用:
useCustomMutation
这四个 Mutation 分别负责对应的接口操作。
六、打开新增弹窗
const handleAdd = useCallback(() => {
setActionError(null);
setEditingSnippet(null);
setDialogOpen(true);
}, []);
点击“添加代码片段”时执行。
执行顺序是:
setActionError(null);
清除上一次操作留下的错误。
setEditingSnippet(null);
明确进入新增模式。
setDialogOpen(true);
打开 Dialog。
最终状态:
editingSnippet = null
dialogOpen = true
子组件接收到:
<CrateCodeSnippetDialog
initialData={null}
open={true}
/>
于是展示空表单。
七、打开编辑弹窗
const handleEdit = useCallback((snippet: CrateCodeSnippet) => {
setActionError(null);
setEditingSnippet(snippet);
setDialogOpen(true);
}, []);
点击某个代码片段的编辑按钮时执行。
比如点击:
{
id: "snippet-1",
name: "React Context",
language: "tsx",
code: "...",
}
执行:
setEditingSnippet(snippet);
最终子组件收到:
<CrateCodeSnippetDialog
initialData={snippet}
open={true}
/>
上一段 Dialog 内部的:
getDefaultValues(initialData)
就会把这个代码片段的数据放到输入框中。
八、关闭新增/编辑弹窗
const handleCloseDialog = useCallback(() => {
setDialogOpen(false);
setEditingSnippet(null);
setActionError(null);
}, []);
关闭时会同时清理三个状态。
setDialogOpen(false);
关闭弹窗。
setEditingSnippet(null);
清除正在编辑的数据。
setActionError(null);
清除错误信息。
这样下次打开弹窗时,不会残留上一次编辑对象或错误。
九、最核心的 handleSubmit
const handleSubmit = useCallback(
(values: CrateCodeSnippetFormValues) => {
setActionError(null);
if (editingSnippet) {
// 修改
return;
}
// 新增
},
[...]
);
这个函数接收上一段 Dialog 校验完成后的表单数据。
比如:
values = {
name: "useContext 示例",
language: "tsx",
code: "const value = useContext(Context);",
description: "演示 React Context",
};
然后根据:
editingSnippet
判断是新增还是修改。
修改流程
if (editingSnippet) {
updateMutation.mutate(
{
resource: ResourceName.crateCodeSnippets,
id: editingSnippet.id,
values,
},
{
onSuccess: handleCloseDialog,
onError: (error) => setActionError(error.message),
},
);
return;
}
关键判断:
if (editingSnippet)
如果当前有正在编辑的数据,就调用修改接口。
发送的数据包括:
{
resource: ResourceName.crateCodeSnippets,
id: editingSnippet.id,
values,
}
例如:
{
resource: "crateCodeSnippets",
id: "snippet-123",
values: {
name: "修改后的名称",
language: "tsx",
code: "...",
description: "...",
},
}
成功后:
onSuccess: handleCloseDialog
关闭弹窗并清理状态。
失败后:
onError: (error) => setActionError(error.message)
保存错误,传给 Dialog 展示。
为什么修改时不需要传 crateId
修改接口已经知道:
id: editingSnippet.id
通常可以直接根据代码片段 ID 找到对应记录。
并且表单只允许修改:
name
language
code
description
所以这里直接传 values。
新增流程
如果:
editingSnippet === null
就执行:
createMutation.mutate(
{
resource: ResourceName.crateCodeSnippets,
values: {
id: crypto.randomUUID(),
crateId,
...values,
sortOrder: snippets.length,
},
},
{
onSuccess: handleCloseDialog,
onError: (error) => setActionError(error.message),
},
);
新增数据由几部分组成:
{
id: crypto.randomUUID(),
crateId,
...values,
sortOrder: snippets.length,
}
crypto.randomUUID()
id: crypto.randomUUID()
在前端生成一个 UUID,例如:
8c7f8502-5a3e-4aac-9583-d3d4be29e875
用作代码片段 ID。
crateId
crateId
说明新代码片段属于当前 Crate。
...values
...values
把表单数据展开:
{
name,
language,
code,
description,
}
sortOrder
sortOrder: snippets.length
新创建的数据被放到当前列表末尾。
如果已有三个元素:
索引 0
索引 1
索引 2
那么新元素:
sortOrder = 3
十、handleSubmit 的依赖数组
[
crateId,
createMutation,
editingSnippet,
handleCloseDialog,
snippets.length,
updateMutation,
]
因为回调函数内部使用了这些外部变量,所以它们出现在 useCallback 依赖数组中。
特别注意:
snippets.length
这里函数只需要当前列表长度,不需要整个 snippets 数组,所以依赖长度即可。
十一、删除流程
打开删除确认弹窗
const handleOpenDelete = useCallback((snippet: CrateCodeSnippet) => {
setActionError(null);
setDeletingSnippet(snippet);
}, []);
点击删除按钮时:
setDeletingSnippet(snippet)
删除 Dialog 收到非空的 snippet,于是打开。
取消删除
const handleCancelDelete = useCallback(() => {
setDeletingSnippet(null);
}, []);
把删除对象清空,确认弹窗关闭。
确认删除
const handleDelete = useCallback(() => {
if (!deletingSnippet) return;
setActionError(null);
deleteMutation.mutate(
{
resource: ResourceName.crateCodeSnippets,
id: deletingSnippet.id,
},
{
onSuccess: () => setDeletingSnippet(null),
onError: (error) => setActionError(error.message),
},
);
}, [deleteMutation, deletingSnippet]);
首先进行保护判断:
if (!deletingSnippet) return;
因为没有删除目标时,无法知道要删除哪个 ID。
然后调用:
deleteMutation.mutate({
resource: ResourceName.crateCodeSnippets,
id: deletingSnippet.id,
});
成功后:
setDeletingSnippet(null)
关闭删除确认弹窗。
失败后:
setActionError(error.message)
展示错误。
十二、移动和重新排序
const handleMove = useCallback(
(index: number, direction: -1 | 1) => {
const reordered = moveCrateCodeSnippet(
snippets,
index,
direction,
);
if (reordered === snippets) return;
setActionError(null);
reorderMutation.mutate(...);
},
[crateId, query, reorderMutation, snippets],
);
参数:
index
表示当前代码片段的位置。
direction: -1 | 1
只允许两个值:
-1 → 向上移动
1 → 向下移动
本地计算新顺序
const reordered = moveCrateCodeSnippet(
snippets,
index,
direction,
);
假设列表是:
A
B
C
执行:
handleMove(1, -1)
就是将 B 向上移动:
B
A
C
为什么判断引用相等
if (reordered === snippets) return;
moveCrateCodeSnippet 很可能在不能移动时,直接返回原数组。
例如:
第一个元素继续向上
最后一个元素继续向下
如果返回的还是原数组,就说明顺序没有变化,不需要调用后端。
向后端发送新的 ID 顺序
reorderMutation.mutate({
url: DataProviderCustomAction.reorderCrateCodeSnippets,
method: "post",
values: {
crateId,
snippetIds: reordered.map((snippet) => snippet.id),
},
});
假设排序后:
reordered = [
{ id: "B" },
{ id: "A" },
{ id: "C" },
];
发送:
{
crateId: "...",
snippetIds: ["B", "A", "C"],
}
后端根据这个数组更新顺序。
成功后:
void query.refetch();
重新请求列表,确保页面顺序与服务端一致。
失败后:
setActionError(error.message)
展示错误。
十三、重试列表查询
const handleRetry = useCallback(() => {
void query.refetch();
}, [query]);
当列表加载失败时,用户点击重试按钮,再次请求数据。
这里的:
void query.refetch();
表示开发者明确忽略这个 Promise 的返回值。
如果直接写:
query.refetch();
某些 ESLint 规则可能提示:
Promise 没有被 await 或处理
写成:
void query.refetch();
表示:
> 我知道它返回 Promise,但这里不需要等待结果。
十四、展开和收起代码
const handleToggleExpand = useCallback((snippetId: string) => {
setExpandedSnippetIds((current) => {
const next = new Set<string>(current);
if (next.has(snippetId)) {
next.delete(snippetId);
} else {
next.add(snippetId);
}
return next;
});
}, []);
这里使用了 React 状态的函数式更新。
setExpandedSnippetIds((current) => {
current 是最新的展开 ID 集合。
然后复制一份:
const next = new Set<string>(current);
为什么不能直接修改 current?
不推荐这样写:
current.add(snippetId);
return current;
因为 React 判断状态有没有变化,主要依赖引用。
如果返回原来的 Set:
return current;
引用没有变化,React 可能不会重新渲染。
所以这里创建新的 Set:
const next = new Set(current);
再修改并返回:
return next;
展开逻辑
if (next.has(snippetId)) {
next.delete(snippetId);
} else {
next.add(snippetId);
}
即:
已经展开 → 从 Set 删除 → 收起
没有展开 → 加入 Set → 展开
十五、组合请求状态
const isSaving =
createMutation.mutation.isPending ||
updateMutation.mutation.isPending;
const isReordering =
reorderMutation.mutation.isPending;
isSaving 同时表示:
正在新增
或
正在修改
因为这两种操作使用的是同一个表单 Dialog,所以可以合并成一个状态。
最终传给:
<CrateCodeSnippetDialog
isSubmitting={isSaving}
/>
如果正在保存,上一段 Dialog 中的按钮会:
<Button disabled={isSubmitting}>
从而防止重复提交。
isReordering 则用于禁用上下移动按钮。
十六、最外层 section
<section
aria-labelledby="crate-code-snippets-heading"
className="space-y-3"
>
aria-labelledby 指向:
<h2 id="crate-code-snippets-heading">
这表示:
> 这个 section 的标题是这个 h2。
有利于屏幕阅读器理解页面结构。
十七、顶部标题和添加按钮
<div className="flex items-center justify-between gap-3">
<div>
<h2>{t("crateDetail.codeSnippets")}</h2>
<p>{t("crateDetail.codeSnippetsDescription")}</p>
</div>
<Button onClick={handleAdd}>
<Plus aria-hidden="true" />
{t("crateDetail.addSnippet")}
</Button>
</div>
布局效果大致是:
代码片段 [ + 添加代码片段 ]
管理当前 Crate 下的代码示例
点击按钮执行:
handleAdd()
进入新增模式。
图标使用:
aria-hidden="true"
因为按钮已经有文字“添加代码片段”,图标只是装饰,屏幕阅读器不需要重复朗读。
十八、错误展示
{actionError && (
<p className="text-sm text-destructive" role="alert">
{actionError}
</p>
)}
只要 actionError 不为空,就展示错误。
role="alert"
会告诉辅助技术:
> 这是一条需要立即关注的动态信息。
需要注意,这个错误位于主面板中,而它又同时被传给表单 Dialog,因此某些错误可能会在两个地方同时出现。
十九、四种列表状态
这里使用了连续的三元表达式:
{query.isLoading ? (
<LoadingState />
) : query.isError ? (
<ErrorState />
) : snippets.length === 0 ? (
<EmptyState />
) : (
<代码片段列表 />
)}
这是典型的异步页面状态处理。
执行优先级是:
1. 正在加载
2. 加载失败
3. 数据为空
4. 正常展示列表
加载状态
<LoadingState className="py-10" />
请求还没完成时展示 Loading。
错误状态
<ErrorState
action={
<Button onClick={handleRetry}>
{t("common.retry")}
</Button>
}
message={query.error.message}
title={t("crateDetail.snippetsLoadError")}
/>
列表请求失败时:
- 展示错误标题
- 展示错误内容
- 提供重试按钮
空状态
<EmptyState
action={
<Button onClick={handleAdd}>
{t("crateDetail.addSnippet")}
</Button>
}
icon={<Code2 />}
title={t("crateDetail.noSnippets")}
description={t("crateDetail.noSnippetsDescription")}
/>
没有代码片段时,引导用户创建第一条记录。
正常状态
snippets.map((snippet, index) => {
遍历代码片段并渲染 Card。
二十、代码片段卡片
每个代码片段使用:
<Card key={snippet.id}>
key={snippet.id} 用于帮助 React 区分列表元素。
卡片头部展示:
<h3>{snippet.name}</h3>
<Badge>{snippet.language}</Badge>
例如:
React Context [tsx]
如果有描述:
{snippet.description && (
<p>{snippet.description}</p>
)}
只有描述不为空时才渲染。
二十一、向上和向下移动按钮
向上按钮:
<Button
disabled={index === 0 || isReordering}
onClick={() => handleMove(index, -1)}
>
两种情况下禁用:
当前是第一个元素
正在执行排序请求
向下按钮:
<Button
disabled={
index === snippets.length - 1 ||
isReordering
}
onClick={() => handleMove(index, 1)}
>
两种情况下禁用:
当前是最后一个元素
正在执行排序请求
这样 UI 就不会允许无效移动,也防止排序请求期间连续点击。
二十二、编辑按钮
<Button
aria-label={t("crateDetail.editSnippetNamed", {
name: snippet.name,
})}
onClick={() => handleEdit(snippet)}
>
<Pencil aria-hidden="true" />
</Button>
按钮只有图标,没有可见文字,所以需要:
aria-label="编辑 React Context"
让屏幕阅读器知道按钮的用途。
点击后把当前 snippet 传给:
handleEdit(snippet)
然后打开编辑 Dialog。
二十三、删除按钮
<Button
aria-label={t("crateDetail.deleteSnippetNamed", {
name: snippet.name,
})}
onClick={() => handleOpenDelete(snippet)}
>
<Trash2 aria-hidden="true" />
</Button>
点击后不会立即删除,而是:
setDeletingSnippet(snippet)
打开确认弹窗。
这是一个重要的交互保护:
点击删除
→ 打开确认弹窗
→ 用户确认
→ 才调用删除接口
二十四、展开按钮
<Button
onClick={() => handleToggleExpand(snippet.id)}
>
{isExpanded ? (
<ChevronDown />
) : (
<ChevronRight />
)}
</Button>
这里根据 isExpanded 切换图标:
未展开:ChevronRight →
已展开:ChevronDown ↓
然后根据状态决定是否显示代码:
{isExpanded && (
<CardContent>
<pre>
<code>{snippet.code}</code>
</pre>
</CardContent>
)}
代码使用:
<pre>
<code>{snippet.code}</code>
</pre>
这样能保留原始换行和空格。
同时:
max-h-80 overflow-auto
表示代码过长时限制最大高度,并出现滚动条。
二十五、父组件如何使用新增/编辑 Dialog
<CrateCodeSnippetDialog
initialData={editingSnippet}
isSubmitting={isSaving}
open={dialogOpen}
serverError={actionError}
onClose={handleCloseDialog}
onSubmit={handleSubmit}
/>
这正是父子组件连接的核心。
每个参数对应关系如下:
父组件状态或函数 子组件 Props
────────────────────────────────────────────
editingSnippet → initialData
isSaving → isSubmitting
dialogOpen → open
actionError → serverError
handleCloseDialog → onClose
handleSubmit → onSubmit
数据向下传递
父组件
editingSnippet
↓
子组件
initialData
↓
表单默认值
这是 React 的单向数据流。
事件向上传递
用户在子组件点击保存
↓
子组件完成 Zod 校验
↓
调用 props.onSubmit(values)
↓
执行父组件 handleSubmit
↓
父组件调用新增或修改接口
因此子组件并不知道接口地址,也不负责调用 Refine。
这是一种很清晰的职责分离:
Dialog
→ 管 UI 和表单
Panel
→ 管数据和业务
二十六、父组件如何使用删除 Dialog
<DeleteCrateCodeSnippetDialog
isDeleting={deleteMutation.mutation.isPending}
snippet={deletingSnippet}
onCancel={handleCancelDelete}
onConfirm={handleDelete}
/>
对应关系:
删除请求状态 → isDeleting
当前待删除对象 → snippet
取消函数 → onCancel
确认删除函数 → onConfirm
删除子组件只需要负责:
展示待删除代码片段信息
询问用户是否确认
触发 onCancel 或 onConfirm
实际删除接口仍然由父组件执行。
二十七、完整的新增流程
用户点击“添加代码片段”
↓
handleAdd
↓
editingSnippet = null
dialogOpen = true
↓
Dialog 打开空表单
↓
用户填写并提交
↓
Dialog 使用 Zod 校验
↓
调用父组件 handleSubmit(values)
↓
editingSnippet 是 null
↓
createMutation.mutate
↓
创建成功
↓
handleCloseDialog
二十八、完整的编辑流程
用户点击某个卡片的编辑按钮
↓
handleEdit(snippet)
↓
editingSnippet = snippet
dialogOpen = true
↓
Dialog 根据 initialData 填充表单
↓
用户修改并提交
↓
调用父组件 handleSubmit(values)
↓
editingSnippet 不为 null
↓
updateMutation.mutate
↓
更新成功
↓
handleCloseDialog
二十九、完整的删除流程
点击删除按钮
↓
handleOpenDelete(snippet)
↓
deletingSnippet = snippet
↓
删除确认 Dialog 打开
↓
用户确认
↓
handleDelete
↓
deleteMutation.mutate
↓
删除成功
↓
deletingSnippet = null
三十、完整的排序流程
点击向上或向下按钮
↓
handleMove(index, direction)
↓
moveCrateCodeSnippet 计算新数组
↓
发送排序后的 snippetIds
↓
后端更新 sortOrder
↓
query.refetch()
↓
重新获取最新顺序
三十一、这个组件的职责划分
这个父组件负责:
数据查询
创建请求
更新请求
删除请求
排序请求
请求状态
业务错误
弹窗开关
当前编辑对象
当前删除对象
列表渲染
展开收起
子组件 CrateCodeSnippetDialog 负责:
展示输入框
管理表单值
执行 Zod 校验
显示字段错误
触发 onSubmit
删除子组件负责:
展示删除确认
触发取消
触发确认
这种结构可以概括成:
父组件:聪明组件 / 容器组件
子组件:展示组件 / 表单组件
三十二、代码中值得注意的几个问题
1. 展开按钮的 aria-label 写错了
展开按钮目前使用的是:
aria-label={t("crateDetail.deleteSnippetNamed", {
name: snippet.name,
})}
也就是“删除某个代码片段”。
但这个按钮实际功能是展开和收起。
这会导致屏幕阅读器错误地朗读:
删除 React Context
实际上点击后却只是展开代码。
应该根据状态写成类似:
aria-label={
isExpanded
? t("crateDetail.collapseSnippetNamed", {
name: snippet.name,
})
: t("crateDetail.expandSnippetNamed", {
name: snippet.name,
})
}
这是当前代码中一个比较明确的无障碍 Bug。
2. actionError 承担了太多职责
目前:
const [actionError, setActionError] = useState<string | null>(null);
同时保存:
新增错误
编辑错误
删除错误
排序错误
并且既展示在主页面,也传给编辑 Dialog:
serverError={actionError}
可能出现这种情况:
排序失败
→ actionError 被赋值
→ 用户打开新增弹窗
→ 虽然 handleAdd 会清空,但其他时序下可能混用
更清晰的设计可以拆成:
const [formError, setFormError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [reorderError, setReorderError] = useState<string | null>(null);
不过当前代码通过每次操作前:
setActionError(null)
已经降低了错误串场的可能性。
3. 删除成功后的列表更新依赖 Refine 行为
删除成功时只做了:
setDeletingSnippet(null)
没有显式:
query.refetch()
这可能是因为 Refine 的 useDelete 会自动失效相关查询缓存并刷新列表。
如果项目的 invalidation 配置正常,这没有问题。
如果发现删除成功后页面没有更新,就需要:
onSuccess: () => {
setDeletingSnippet(null);
void query.refetch();
}
4. 新增时的 sortOrder
sortOrder: snippets.length
假设现有排序值一定连续:
0, 1, 2
那么新值 3 没问题。
但如果数据库中出现:
0, 5, 10
当前列表长度是 3,新记录就会得到:
sortOrder = 3
它可能插入中间,而不是末尾。
更稳妥的方式可能是:
const lastSortOrder = snippets.at(-1)?.sortOrder ?? -1;
sortOrder: lastSortOrder + 1
前提是 CrateCodeSnippet 中确实存在 sortOrder 字段并且列表已经按它排序。
5. 排序没有进行乐观更新
当前点击移动后:
点击按钮
→ 请求后端
→ 请求成功
→ refetch
→ 页面顺序变化
网络慢时,用户可能感觉按钮没有立即生效。
可以考虑:
先在页面更新顺序
再请求后端
失败时回滚
也就是乐观更新。
不过当前实现更简单,也能保证最终页面以服务端结果为准。
6. useMemo 可能不需要
const snippets = useMemo(
() => (result?.data ?? []) as CrateCodeSnippet[],
[result?.data],
);
这里的计算本身非常简单,可以直接写:
const snippets =
(result?.data ?? []) as CrateCodeSnippet[];
除非确实依赖稳定的数组引用,否则使用 useMemo 的收益有限。
三十三、最核心的理解
这个组件最关键的是三个状态:
dialogOpen
editingSnippet
deletingSnippet
其中新增和编辑复用了同一个 Dialog:
dialogOpen 决定是否打开
editingSnippet 决定新增还是编辑
最关键的判断是:
if (editingSnippet) {
updateMutation.mutate(...);
return;
}
createMutation.mutate(...);
最关键的父子通信是:
<CrateCodeSnippetDialog
initialData={editingSnippet}
open={dialogOpen}
onClose={handleCloseDialog}
onSubmit={handleSubmit}
/>
可以把它记成:
父组件把“数据和状态”传给 Dialog
Dialog 把“用户操作结果”传回父组件
父组件再调用后端接口
这正是 React 中非常典型的:
状态提升 + 单向数据流