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:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,56 +56,62 @@ export function usePaginatedData<T, F = string>(
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filters, setFilters] = useState<F>(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<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) {
|
||||
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<T, F> extends PaginatedState<T> {
|
||||
setSearchText: (text: string) => void;
|
||||
filters: F;
|
||||
setFilters: (filters: F | ((prev: F) => F)) => void;
|
||||
refresh: (page?: number) => Promise<void>;
|
||||
refresh: (page?: number) => void;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Popconfirm
|
||||
title="确定删除此分类?"
|
||||
description="删除后不可恢复,关联文章将变为未分类"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
>
|
||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function ArticleManageList() {
|
||||
.catch((err) => console.warn('[ArticleManageList] 获取文章分类失败:', err));
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, version: number) => {
|
||||
const handleDelete = useCallback(async (id: string, version: number) => {
|
||||
try {
|
||||
await articleApi.delete(id, version);
|
||||
message.success('文章已删除');
|
||||
@@ -127,9 +127,9 @@ export default function ArticleManageList() {
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const handleSubmit = async (record: ArticleListItem) => {
|
||||
const handleSubmit = useCallback(async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.submit(record.id, record.version);
|
||||
message.success('已提交审核');
|
||||
@@ -137,9 +137,9 @@ export default function ArticleManageList() {
|
||||
} catch {
|
||||
message.error('提交审核失败');
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const handleApprove = async (record: ArticleListItem) => {
|
||||
const handleApprove = useCallback(async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.approve(record.id, record.version);
|
||||
message.success('审核通过,文章已发布');
|
||||
@@ -147,13 +147,13 @@ export default function ArticleManageList() {
|
||||
} catch {
|
||||
message.error('审核操作失败');
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const openRejectModal = (record: ArticleListItem) => {
|
||||
const openRejectModal = useCallback((record: ArticleListItem) => {
|
||||
setRejectingArticle(record);
|
||||
rejectForm.resetFields();
|
||||
setRejectModalOpen(true);
|
||||
};
|
||||
}, [rejectForm]);
|
||||
|
||||
const handleReject = async (values: { review_note: string }) => {
|
||||
if (!rejectingArticle) return;
|
||||
@@ -167,7 +167,7 @@ export default function ArticleManageList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = async (record: ArticleListItem) => {
|
||||
const handleUnpublish = useCallback(async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.unpublish(record.id, record.version);
|
||||
message.success('文章已撤回为草稿');
|
||||
@@ -175,9 +175,9 @@ export default function ArticleManageList() {
|
||||
} catch {
|
||||
message.error('撤回操作失败');
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const renderActions = (record: ArticleListItem) => (
|
||||
const renderActions = useCallback((record: ArticleListItem) => (
|
||||
<Space size={4} wrap>
|
||||
{record.status === 'draft' && (
|
||||
<>
|
||||
@@ -252,7 +252,7 @@ export default function ArticleManageList() {
|
||||
</AuthButton>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
), [navigate, handleSubmit, handleApprove, openRejectModal, handleUnpublish, handleDelete]);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
@@ -410,7 +410,7 @@ export default function ArticleManageList() {
|
||||
<Button size="small" type="text" icon={<EditOutlined />}
|
||||
onClick={() => { setCatEditing(record); catForm.setFieldsValue(record); setCatModalOpen(true); }} />
|
||||
<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 />
|
||||
</Popconfirm>
|
||||
@@ -522,7 +522,6 @@ export default function ArticleManageList() {
|
||||
</Button>
|
||||
</AuthButton>
|
||||
)}
|
||||
loading={loading}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activePageTab}
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function ArticleEditor() {
|
||||
setCoverImage(article.cover_image || '');
|
||||
setSlug(article.slug || '');
|
||||
setCategoryId(article.category_id);
|
||||
setSelectedTagIds(article.tags?.map((t) => 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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user