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;
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;
},
};

View File

@@ -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 -- 数据获取 hookloading → 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 变化 = 手动 refreshfilters 变化 = 筛选刷新
}, [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;
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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,
});