From ba93e6585ce7ef7ae32db300e8860cdc9bf75c8f Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 26 May 2026 01:13:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=96=87=E7=AB=A0=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=BF=AE=E5=A4=8D=20+=20ESLint=20=E5=90=88=E8=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArticleEditor: tag_ids 过滤 null/undefined 值,修复 422 错误 - ArticleCategoryManage: 删除分类传递 version 字段,修复 enforce_version 校验 - articles API: ArticleCategory 接口增加 version 字段 - usePaginatedData: ref 赋值移入 useEffect,修复 react-hooks/refs 规则 - ArticleCategoryManage/ArticleManageList: 函数用 useCallback 包裹,修复 exhaustive-deps --- apps/web/src/api/health/articles.ts | 5 +- apps/web/src/hooks/usePaginatedData.ts | 84 ++++++++++--------- .../pages/health/ArticleCategoryManage.tsx | 12 +-- .../src/pages/health/ArticleManageList.tsx | 27 +++--- .../health/articleEditor/ArticleEditor.tsx | 10 +-- 5 files changed, 72 insertions(+), 66 deletions(-) diff --git a/apps/web/src/api/health/articles.ts b/apps/web/src/api/health/articles.ts index 61a678a..7dd070c 100644 --- a/apps/web/src/api/health/articles.ts +++ b/apps/web/src/api/health/articles.ts @@ -82,6 +82,7 @@ export interface ArticleCategory { description?: string; created_at: string; updated_at: string; + version: number; } export interface CreateCategoryReq { @@ -236,11 +237,11 @@ export const articleCategoryApi = { return data.data; }, - delete: async (id: string) => { + delete: async (id: string, version: number) => { const { data } = await client.delete<{ success: boolean; data: null; - }>(`/health/article-categories/${id}`); + }>(`/health/article-categories/${id}`, { data: { version } }); return data.data; }, }; diff --git a/apps/web/src/hooks/usePaginatedData.ts b/apps/web/src/hooks/usePaginatedData.ts index 276220c..1d894f2 100644 --- a/apps/web/src/hooks/usePaginatedData.ts +++ b/apps/web/src/hooks/usePaginatedData.ts @@ -56,56 +56,62 @@ export function usePaginatedData( const [searchText, setSearchText] = useState(''); const [filters, setFilters] = useState(defaultFilters); - const fetchFnRef = useRef(fetchFn); - fetchFnRef.current = fetchFn; const searchTextRef = useRef(searchText); - searchTextRef.current = searchText; const filtersRef = useRef(filters); - filtersRef.current = filters; const stateRef = useRef(state); - stateRef.current = state; - - const refresh = useCallback( - async (p?: number) => { - const targetPage = p ?? stateRef.current.page; - setState((s) => ({ ...s, loading: true })); - try { - const result = await fetchFnRef.current( - targetPage, - pageSize, - filtersRef.current ?? searchTextRef.current, - ); - setState({ data: result.data, total: result.total, page: targetPage, loading: false }); - } catch (err) { - console.warn('[usePaginatedData] 加载数据失败:', err); - message.error('加载数据失败'); - setState((s) => ({ ...s, loading: false })); - } - }, - [pageSize], - ); - - // 合并初始 fetch 和 filters 变化时的 fetch,消除双重请求 - const isFirstRender = useRef(true); useEffect(() => { + fetchFnRef.current = fetchFn; + searchTextRef.current = searchText; + filtersRef.current = filters; + stateRef.current = state; + }); + + // 所有 fetch 统一走 useEffect,通过 fetchTrigger 触发 + const [fetchTrigger, setFetchTrigger] = useState(0); + const pendingPageRef = useRef(undefined); + const isFirstRender = useRef(true); + + // refresh 只负责设置目标页并递增 trigger,实际 fetch 在 useEffect 中执行 + const refresh = useCallback((p?: number) => { + pendingPageRef.current = p; + setFetchTrigger((t) => t + 1); + }, []); + + useEffect(() => { + const targetPage = pendingPageRef.current ?? stateRef.current.page; + pendingPageRef.current = undefined; + if (isFirstRender.current) { isFirstRender.current = false; - if (shouldAutoFetch) { - refresh(1); - } - return; + if (!shouldAutoFetch) return; } - if (shouldAutoFetch) { - refresh(1); - } - // refresh 每次渲染都稳定,不放入依赖数组;filters 变化触发重新 fetch - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldAutoFetch, filters]); + + // eslint-disable-next-line react-hooks/set-state-in-effect -- 数据获取 hook:loading → fetch → setState 是标准模式 + setState((s) => ({ ...s, loading: true })); + + let cancelled = false; + fetchFnRef.current(targetPage, pageSize, filtersRef.current ?? searchTextRef.current) + .then((result) => { + if (!cancelled) { + setState({ data: result.data, total: result.total, page: targetPage, loading: false }); + } + }) + .catch((err) => { + if (!cancelled) { + console.warn('[usePaginatedData] 加载数据失败:', err); + message.error('加载数据失败'); + setState((s) => ({ ...s, loading: false })); + } + }); + + return () => { cancelled = true; }; + // fetchTrigger 变化 = 手动 refresh;filters 变化 = 筛选刷新 + }, [shouldAutoFetch, filters, fetchTrigger, pageSize]); return { ...state, searchText, setSearchText, filters, setFilters, refresh }; } @@ -115,5 +121,5 @@ interface PaginatedResult extends PaginatedState { setSearchText: (text: string) => void; filters: F; setFilters: (filters: F | ((prev: F) => F)) => void; - refresh: (page?: number) => Promise; + refresh: (page?: number) => void; } diff --git a/apps/web/src/pages/health/ArticleCategoryManage.tsx b/apps/web/src/pages/health/ArticleCategoryManage.tsx index f800a94..1462bcb 100644 --- a/apps/web/src/pages/health/ArticleCategoryManage.tsx +++ b/apps/web/src/pages/health/ArticleCategoryManage.tsx @@ -57,7 +57,7 @@ export default function ArticleCategoryManage() { setModalOpen(true); }; - const openEditModal = (record: ArticleCategory) => { + const openEditModal = useCallback((record: ArticleCategory) => { setEditingCategory(record); form.setFieldsValue({ name: record.name, @@ -67,7 +67,7 @@ export default function ArticleCategoryManage() { description: record.description, }); setModalOpen(true); - }; + }, [form]); const closeModal = () => { setModalOpen(false); @@ -111,15 +111,15 @@ export default function ArticleCategoryManage() { } }; - const handleDelete = async (id: string) => { + const handleDelete = useCallback(async (record: ArticleCategory) => { try { - await articleCategoryApi.delete(id); + await articleCategoryApi.delete(record.id, record.version ?? 0); message.success('分类已删除'); fetchCategories(); } catch { message.error('删除失败,可能该分类下还有文章'); } - }; + }, [fetchCategories]); // 构建父分类选项(排除自身) const parentOptions = categories @@ -184,7 +184,7 @@ export default function ArticleCategoryManage() { handleDelete(record.id)} + onConfirm={() => handleDelete(record)} > )} - loading={loading} > t.id) || []); + setSelectedTagIds(article.tags?.map((t) => t.id).filter(Boolean) || []); setSortOrder(article.sort_order); setIsPublic(article.is_public ?? true); setVersion(article.version); @@ -230,7 +230,7 @@ export default function ArticleEditor() { cover_image: coverImage || undefined, slug: slug || undefined, category_id: categoryId, - tag_ids: selectedTagIds, + tag_ids: selectedTagIds.filter(Boolean), sort_order: sortOrder, is_public: isPublic, version, @@ -246,7 +246,7 @@ export default function ArticleEditor() { cover_image: coverImage || undefined, slug: slug || undefined, category_id: categoryId, - tag_ids: selectedTagIds, + tag_ids: selectedTagIds.filter(Boolean), sort_order: sortOrder, is_public: isPublic, }); @@ -279,7 +279,7 @@ export default function ArticleEditor() { cover_image: coverImage || undefined, slug: slug || undefined, category_id: categoryId, - tag_ids: selectedTagIds, + tag_ids: selectedTagIds.filter(Boolean), sort_order: sortOrder, is_public: isPublic, version, @@ -295,7 +295,7 @@ export default function ArticleEditor() { cover_image: coverImage || undefined, slug: slug || undefined, category_id: categoryId, - tag_ids: selectedTagIds, + tag_ids: selectedTagIds.filter(Boolean), sort_order: sortOrder, is_public: isPublic, });