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;
|
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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 -- 数据获取 hook:loading → 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 变化 = 手动 refresh;filters 变化 = 筛选刷新
|
||||||
|
}, [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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user