Compare commits

..

2 Commits

Author SHA1 Message Date
iven
ba93e6585c 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
2026-05-26 01:13:59 +08:00
iven
d7fb5da873 feat(health): 积分规则查重 — 同租户同事件类型不可重复创建
- 新增迁移 m20260526_000163:points_rule (tenant_id, event_type) 部分唯一索引(排除软删除行)
- 后端 create_rule 添加 event_type 查重,重复时返回 400 Validation 错误
- 前端 PointsRuleList 提取后端错误消息展示给用户
2026-05-26 01:09:21 +08:00
9 changed files with 132 additions and 68 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

@@ -142,8 +142,9 @@ export default function PointsRuleList() {
setModalOpen(false);
form.resetFields();
fetchData();
} catch {
message.error(editing ? '更新失败' : '创建失败');
} catch (err: unknown) {
const apiMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
message.error(apiMsg || (editing ? '更新失败' : '创建失败'));
}
};

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

View File

@@ -59,6 +59,20 @@ pub async fn create_rule(
operator_id: Option<Uuid>,
req: CreatePointsRuleReq,
) -> HealthResult<PointsRuleResp> {
// 查重:同一租户下不允许重复的 event_type
let existing = points_rule::Entity::find()
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::EventType.eq(&req.event_type))
.filter(points_rule::Column::DeletedAt.is_null())
.one(&state.db)
.await?;
if existing.is_some() {
return Err(HealthError::Validation(format!(
"事件类型 '{}' 已存在规则,不可重复创建",
req.event_type
)));
}
let now = Utc::now();
let active = points_rule::ActiveModel {
id: Set(Uuid::now_v7()),

View File

@@ -169,6 +169,7 @@ mod m20260521_000164_reorganize_menus_scheme_b;
mod m20260522_000160_article_add_is_public;
mod m20260522_000161_patient_points_manage_perm;
mod m20260522_000162_seed_patient_miniprogram_permissions;
mod m20260526_000163_points_rule_unique_event_type;
pub struct Migrator;
@@ -345,6 +346,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260522_000160_article_add_is_public::Migration),
Box::new(m20260522_000161_patient_points_manage_perm::Migration),
Box::new(m20260522_000162_seed_patient_miniprogram_permissions::Migration),
Box::new(m20260526_000163_points_rule_unique_event_type::Migration),
]
}
}

View File

@@ -0,0 +1,41 @@
use sea_orm_migration::prelude::*;
/// 为 points_rule 添加 (tenant_id, event_type) 唯一索引(排除软删除行)
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 删除旧的非唯一索引
db.execute_unprepared("DROP INDEX IF EXISTS idx_points_rule_event_type")
.await?;
// 创建部分唯一索引(仅对未软删除的行生效)
db.execute_unprepared(
r#"
CREATE UNIQUE INDEX IF NOT EXISTS uq_points_rule_tenant_event
ON points_rule (tenant_id, event_type)
WHERE deleted_at IS NULL
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared("DROP INDEX IF EXISTS uq_points_rule_tenant_event")
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_points_rule_event_type ON points_rule (event_type)",
)
.await?;
Ok(())
}
}