fix(web): 文章编辑修复 + ESLint 合规

- 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
This commit is contained in:
iven
2026-05-26 01:13:59 +08:00
parent d7fb5da873
commit ba93e6585c
5 changed files with 72 additions and 66 deletions

View File

@@ -82,6 +82,7 @@ export interface ArticleCategory {
description?: string; description?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
version: number;
} }
export interface CreateCategoryReq { export interface CreateCategoryReq {
@@ -236,11 +237,11 @@ export const articleCategoryApi = {
return data.data; return data.data;
}, },
delete: async (id: string) => { delete: async (id: string, version: number) => {
const { data } = await client.delete<{ const { data } = await client.delete<{
success: boolean; success: boolean;
data: null; data: null;
}>(`/health/article-categories/${id}`); }>(`/health/article-categories/${id}`, { data: { version } });
return data.data; return data.data;
}, },
}; };

View File

@@ -56,56 +56,62 @@ export function usePaginatedData<T, F = string>(
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filters, setFilters] = useState<F>(defaultFilters); const [filters, setFilters] = useState<F>(defaultFilters);
const fetchFnRef = useRef(fetchFn); const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
const searchTextRef = useRef(searchText); const searchTextRef = useRef(searchText);
searchTextRef.current = searchText;
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
filtersRef.current = filters;
const stateRef = useRef(state); 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(() => { useEffect(() => {
fetchFnRef.current = fetchFn;
searchTextRef.current = searchText;
filtersRef.current = filters;
stateRef.current = state;
});
// 所有 fetch 统一走 useEffect通过 fetchTrigger 触发
const [fetchTrigger, setFetchTrigger] = useState(0);
const pendingPageRef = useRef<number | undefined>(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) { if (isFirstRender.current) {
isFirstRender.current = false; isFirstRender.current = false;
if (shouldAutoFetch) { if (!shouldAutoFetch) return;
refresh(1);
}
return;
} }
if (shouldAutoFetch) {
refresh(1); // eslint-disable-next-line react-hooks/set-state-in-effect -- 数据获取 hookloading → fetch → setState 是标准模式
} setState((s) => ({ ...s, loading: true }));
// refresh 每次渲染都稳定不放入依赖数组filters 变化触发重新 fetch
// eslint-disable-next-line react-hooks/exhaustive-deps let cancelled = false;
}, [shouldAutoFetch, filters]); 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 变化 = 手动 refreshfilters 变化 = 筛选刷新
}, [shouldAutoFetch, filters, fetchTrigger, pageSize]);
return { ...state, searchText, setSearchText, filters, setFilters, refresh }; return { ...state, searchText, setSearchText, filters, setFilters, refresh };
} }
@@ -115,5 +121,5 @@ interface PaginatedResult<T, F> extends PaginatedState<T> {
setSearchText: (text: string) => void; setSearchText: (text: string) => void;
filters: F; filters: F;
setFilters: (filters: F | ((prev: F) => F)) => void; setFilters: (filters: F | ((prev: F) => F)) => void;
refresh: (page?: number) => Promise<void>; refresh: (page?: number) => void;
} }

View File

@@ -57,7 +57,7 @@ export default function ArticleCategoryManage() {
setModalOpen(true); setModalOpen(true);
}; };
const openEditModal = (record: ArticleCategory) => { const openEditModal = useCallback((record: ArticleCategory) => {
setEditingCategory(record); setEditingCategory(record);
form.setFieldsValue({ form.setFieldsValue({
name: record.name, name: record.name,
@@ -67,7 +67,7 @@ export default function ArticleCategoryManage() {
description: record.description, description: record.description,
}); });
setModalOpen(true); setModalOpen(true);
}; }, [form]);
const closeModal = () => { const closeModal = () => {
setModalOpen(false); setModalOpen(false);
@@ -111,15 +111,15 @@ export default function ArticleCategoryManage() {
} }
}; };
const handleDelete = async (id: string) => { const handleDelete = useCallback(async (record: ArticleCategory) => {
try { try {
await articleCategoryApi.delete(id); await articleCategoryApi.delete(record.id, record.version ?? 0);
message.success('分类已删除'); message.success('分类已删除');
fetchCategories(); fetchCategories();
} catch { } catch {
message.error('删除失败,可能该分类下还有文章'); message.error('删除失败,可能该分类下还有文章');
} }
}; }, [fetchCategories]);
// 构建父分类选项(排除自身) // 构建父分类选项(排除自身)
const parentOptions = categories const parentOptions = categories
@@ -184,7 +184,7 @@ export default function ArticleCategoryManage() {
<Popconfirm <Popconfirm
title="确定删除此分类?" title="确定删除此分类?"
description="删除后不可恢复,关联文章将变为未分类" description="删除后不可恢复,关联文章将变为未分类"
onConfirm={() => handleDelete(record.id)} onConfirm={() => handleDelete(record)}
> >
<Button size="small" type="text" icon={<DeleteOutlined />} danger /> <Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm> </Popconfirm>

View File

@@ -119,7 +119,7 @@ export default function ArticleManageList() {
.catch((err) => console.warn('[ArticleManageList] 获取文章分类失败:', err)); .catch((err) => console.warn('[ArticleManageList] 获取文章分类失败:', err));
}, []); }, []);
const handleDelete = async (id: string, version: number) => { const handleDelete = useCallback(async (id: string, version: number) => {
try { try {
await articleApi.delete(id, version); await articleApi.delete(id, version);
message.success('文章已删除'); message.success('文章已删除');
@@ -127,9 +127,9 @@ export default function ArticleManageList() {
} catch { } catch {
message.error('删除失败'); message.error('删除失败');
} }
}; }, [refresh]);
const handleSubmit = async (record: ArticleListItem) => { const handleSubmit = useCallback(async (record: ArticleListItem) => {
try { try {
await articleApi.submit(record.id, record.version); await articleApi.submit(record.id, record.version);
message.success('已提交审核'); message.success('已提交审核');
@@ -137,9 +137,9 @@ export default function ArticleManageList() {
} catch { } catch {
message.error('提交审核失败'); message.error('提交审核失败');
} }
}; }, [refresh]);
const handleApprove = async (record: ArticleListItem) => { const handleApprove = useCallback(async (record: ArticleListItem) => {
try { try {
await articleApi.approve(record.id, record.version); await articleApi.approve(record.id, record.version);
message.success('审核通过,文章已发布'); message.success('审核通过,文章已发布');
@@ -147,13 +147,13 @@ export default function ArticleManageList() {
} catch { } catch {
message.error('审核操作失败'); message.error('审核操作失败');
} }
}; }, [refresh]);
const openRejectModal = (record: ArticleListItem) => { const openRejectModal = useCallback((record: ArticleListItem) => {
setRejectingArticle(record); setRejectingArticle(record);
rejectForm.resetFields(); rejectForm.resetFields();
setRejectModalOpen(true); setRejectModalOpen(true);
}; }, [rejectForm]);
const handleReject = async (values: { review_note: string }) => { const handleReject = async (values: { review_note: string }) => {
if (!rejectingArticle) return; if (!rejectingArticle) return;
@@ -167,7 +167,7 @@ export default function ArticleManageList() {
} }
}; };
const handleUnpublish = async (record: ArticleListItem) => { const handleUnpublish = useCallback(async (record: ArticleListItem) => {
try { try {
await articleApi.unpublish(record.id, record.version); await articleApi.unpublish(record.id, record.version);
message.success('文章已撤回为草稿'); message.success('文章已撤回为草稿');
@@ -175,9 +175,9 @@ export default function ArticleManageList() {
} catch { } catch {
message.error('撤回操作失败'); message.error('撤回操作失败');
} }
}; }, [refresh]);
const renderActions = (record: ArticleListItem) => ( const renderActions = useCallback((record: ArticleListItem) => (
<Space size={4} wrap> <Space size={4} wrap>
{record.status === 'draft' && ( {record.status === 'draft' && (
<> <>
@@ -252,7 +252,7 @@ export default function ArticleManageList() {
</AuthButton> </AuthButton>
)} )}
</Space> </Space>
); ), [navigate, handleSubmit, handleApprove, openRejectModal, handleUnpublish, handleDelete]);
const columns = useMemo(() => [ const columns = useMemo(() => [
{ {
@@ -410,7 +410,7 @@ export default function ArticleManageList() {
<Button size="small" type="text" icon={<EditOutlined />} <Button size="small" type="text" icon={<EditOutlined />}
onClick={() => { setCatEditing(record); catForm.setFieldsValue(record); setCatModalOpen(true); }} /> onClick={() => { setCatEditing(record); catForm.setFieldsValue(record); setCatModalOpen(true); }} />
<Popconfirm title="确定删除?" onConfirm={async () => { <Popconfirm title="确定删除?" onConfirm={async () => {
try { await articleCategoryApi.delete(record.id); message.success('已删除'); fetchCategories(); } catch { message.error('删除失败'); } try { await articleCategoryApi.delete(record.id, record.version ?? 0); message.success('已删除'); fetchCategories(); } catch { message.error('删除失败'); }
}}> }}>
<Button size="small" type="text" icon={<DeleteOutlined />} danger /> <Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm> </Popconfirm>
@@ -522,7 +522,6 @@ export default function ArticleManageList() {
</Button> </Button>
</AuthButton> </AuthButton>
)} )}
loading={loading}
> >
<Tabs <Tabs
activeKey={activePageTab} activeKey={activePageTab}

View File

@@ -100,7 +100,7 @@ export default function ArticleEditor() {
setCoverImage(article.cover_image || ''); setCoverImage(article.cover_image || '');
setSlug(article.slug || ''); setSlug(article.slug || '');
setCategoryId(article.category_id); setCategoryId(article.category_id);
setSelectedTagIds(article.tags?.map((t) => t.id) || []); setSelectedTagIds(article.tags?.map((t) => t.id).filter(Boolean) || []);
setSortOrder(article.sort_order); setSortOrder(article.sort_order);
setIsPublic(article.is_public ?? true); setIsPublic(article.is_public ?? true);
setVersion(article.version); setVersion(article.version);
@@ -230,7 +230,7 @@ export default function ArticleEditor() {
cover_image: coverImage || undefined, cover_image: coverImage || undefined,
slug: slug || undefined, slug: slug || undefined,
category_id: categoryId, category_id: categoryId,
tag_ids: selectedTagIds, tag_ids: selectedTagIds.filter(Boolean),
sort_order: sortOrder, sort_order: sortOrder,
is_public: isPublic, is_public: isPublic,
version, version,
@@ -246,7 +246,7 @@ export default function ArticleEditor() {
cover_image: coverImage || undefined, cover_image: coverImage || undefined,
slug: slug || undefined, slug: slug || undefined,
category_id: categoryId, category_id: categoryId,
tag_ids: selectedTagIds, tag_ids: selectedTagIds.filter(Boolean),
sort_order: sortOrder, sort_order: sortOrder,
is_public: isPublic, is_public: isPublic,
}); });
@@ -279,7 +279,7 @@ export default function ArticleEditor() {
cover_image: coverImage || undefined, cover_image: coverImage || undefined,
slug: slug || undefined, slug: slug || undefined,
category_id: categoryId, category_id: categoryId,
tag_ids: selectedTagIds, tag_ids: selectedTagIds.filter(Boolean),
sort_order: sortOrder, sort_order: sortOrder,
is_public: isPublic, is_public: isPublic,
version, version,
@@ -295,7 +295,7 @@ export default function ArticleEditor() {
cover_image: coverImage || undefined, cover_image: coverImage || undefined,
slug: slug || undefined, slug: slug || undefined,
category_id: categoryId, category_id: categoryId,
tag_ids: selectedTagIds, tag_ids: selectedTagIds.filter(Boolean),
sort_order: sortOrder, sort_order: sortOrder,
is_public: isPublic, is_public: isPublic,
}); });