diff --git a/apps/web/src/api/health/articles.ts b/apps/web/src/api/health/articles.ts index 1f52b69..12eaed0 100644 --- a/apps/web/src/api/health/articles.ts +++ b/apps/web/src/api/health/articles.ts @@ -252,11 +252,19 @@ export const articleTagApi = { return data.data; }, - delete: async (id: string) => { + update: async (id: string, req: { name: string; version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: ArticleTagItem; + }>(`/health/article-tags/${id}`, req); + return data.data; + }, + + delete: async (id: string, version: number) => { const { data } = await client.delete<{ success: boolean; data: null; - }>(`/health/article-tags/${id}`); + }>(`/health/article-tags/${id}`, { data: { version } }); return data.data; }, }; diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index bfb4dcd..eda1a06 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -168,6 +168,20 @@ export const pointsApi = { return data.data; }, + updateRule: async (id: string, req: Partial & { is_active?: boolean; version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: PointsRule; + }>(`/health/admin/points/rules/${id}`, { data: req, version: req.version }); + return data.data; + }, + + deleteRule: async (id: string, version: number) => { + await client.delete(`/health/admin/points/rules/${id}`, { + data: { version }, + }); + }, + // Products listProducts: async (params?: Record) => { const { data } = await client.get<{ @@ -185,6 +199,20 @@ export const pointsApi = { return data.data; }, + updateProduct: async (id: string, req: Partial & { is_active?: boolean; version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: PointsProduct; + }>(`/health/admin/points/products/${id}`, { data: req, version: req.version }); + return data.data; + }, + + deleteProduct: async (id: string, version: number) => { + await client.delete(`/health/admin/points/products/${id}`, { + data: { version }, + }); + }, + // Orders listOrders: async (params?: Record) => { const { data } = await client.get<{ diff --git a/apps/web/src/pages/health/ArticleTagManage.tsx b/apps/web/src/pages/health/ArticleTagManage.tsx index d279b17..a1dad19 100644 --- a/apps/web/src/pages/health/ArticleTagManage.tsx +++ b/apps/web/src/pages/health/ArticleTagManage.tsx @@ -9,7 +9,7 @@ import { Tag, message, } from 'antd'; -import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'; +import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; import { articleTagApi, type ArticleTagItem, @@ -22,6 +22,7 @@ export default function ArticleTagManage() { const [tags, setTags] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); const [form] = Form.useForm(); const isDark = useThemeMode(); @@ -42,37 +43,50 @@ export default function ArticleTagManage() { }, [fetchTags]); const openCreateModal = () => { + setEditing(null); form.resetFields(); setModalOpen(true); }; + const openEditModal = (record: ArticleTagItem) => { + setEditing(record); + form.setFieldsValue({ name: record.name }); + setModalOpen(true); + }; + const closeModal = () => { setModalOpen(false); + setEditing(null); form.resetFields(); }; - const handleCreate = async (values: { name: string; slug?: string; color?: string }) => { + const handleSubmit = async (values: { name: string; slug?: string; color?: string }) => { try { - const req: CreateTagReq = { - name: values.name, - slug: values.slug, - color: values.color, - }; - await articleTagApi.create(req); - message.success('标签创建成功'); + if (editing) { + await articleTagApi.update(editing.id, { name: values.name, version: editing.version }); + message.success('标签更新成功'); + } else { + const req: CreateTagReq = { + name: values.name, + slug: values.slug, + color: values.color, + }; + await articleTagApi.create(req); + message.success('标签创建成功'); + } closeModal(); fetchTags(); } catch (err: unknown) { const errorMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || - '创建失败'; + (editing ? '更新失败' : '创建失败'); message.error(errorMsg); } }; - const handleDelete = async (id: string) => { + const handleDelete = async (record: ArticleTagItem) => { try { - await articleTagApi.delete(id); + await articleTagApi.delete(record.id, record.version); message.success('标签已删除'); fetchTags(); } catch { @@ -138,13 +152,16 @@ export default function ArticleTagManage() { width: 80, render: (_: unknown, record: ArticleTagItem) => ( - handleDelete(record.id)} - > - ), diff --git a/apps/web/src/pages/health/PointsRuleList.tsx b/apps/web/src/pages/health/PointsRuleList.tsx index 7d01a5e..e2cb86a 100644 --- a/apps/web/src/pages/health/PointsRuleList.tsx +++ b/apps/web/src/pages/health/PointsRuleList.tsx @@ -19,6 +19,7 @@ import { import { PlusOutlined, EditOutlined, + DeleteOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import { @@ -101,17 +102,24 @@ export default function PointsRuleList() { streak_30d_bonus?: number; }) => { try { - const req: CreatePointsRuleReq = { - event_type: values.event_type, - name: values.name, - description: values.description, - points_value: values.points_value, - daily_cap: values.daily_cap, - streak_7d_bonus: values.streak_7d_bonus, - streak_14d_bonus: values.streak_14d_bonus, - streak_30d_bonus: values.streak_30d_bonus, - }; - await pointsApi.createRule(req); + if (editing) { + await pointsApi.updateRule(editing.id, { + ...values, + version: editing.version, + }); + } else { + const req: CreatePointsRuleReq = { + event_type: values.event_type, + name: values.name, + description: values.description, + points_value: values.points_value, + daily_cap: values.daily_cap, + streak_7d_bonus: values.streak_7d_bonus, + streak_14d_bonus: values.streak_14d_bonus, + streak_30d_bonus: values.streak_30d_bonus, + }; + await pointsApi.createRule(req); + } message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); form.resetFields(); @@ -124,19 +132,10 @@ export default function PointsRuleList() { // ---- 切换启用状态 ---- const handleToggleActive = async (record: PointsRule) => { try { - // 目前后端没有 toggle 接口,重新创建等同于更新 - // 使用 create 接口覆盖同 event_type 的规则 - const req: CreatePointsRuleReq = { - event_type: record.event_type, - name: record.name, - description: record.description ?? undefined, - points_value: record.points_value, - daily_cap: record.daily_cap, - streak_7d_bonus: record.streak_7d_bonus, - streak_14d_bonus: record.streak_14d_bonus, - streak_30d_bonus: record.streak_30d_bonus, - }; - await pointsApi.createRule(req); + await pointsApi.updateRule(record.id, { + is_active: !record.is_active, + version: record.version, + }); message.success(record.is_active ? '已停用' : '已启用'); fetchData(); } catch { @@ -144,6 +143,24 @@ export default function PointsRuleList() { } }; + // ---- 删除 ---- + const handleDelete = (record: PointsRule) => { + Modal.confirm({ + title: '确认删除', + content: `确定要删除规则「${record.name}」吗?`, + okType: 'danger', + onOk: async () => { + try { + await pointsApi.deleteRule(record.id, record.version); + message.success('删除成功'); + fetchData(); + } catch { + message.error('删除失败'); + } + }, + }); + }; + // ---- 列定义 ---- const columns = [ { @@ -234,6 +251,15 @@ export default function PointsRuleList() { unCheckedChildren="停用" onChange={() => handleToggleActive(record)} /> + ), diff --git a/crates/erp-health/src/dto/article_dto.rs b/crates/erp-health/src/dto/article_dto.rs index 57f0c59..4fa1464 100644 --- a/crates/erp-health/src/dto/article_dto.rs +++ b/crates/erp-health/src/dto/article_dto.rs @@ -218,3 +218,15 @@ impl CreateTagReq { self.name = sanitize_string(&self.name); } } + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateTagReq { + pub name: String, + pub version: i32, +} + +impl UpdateTagReq { + pub fn sanitize(&mut self) { + self.name = sanitize_string(&self.name); + } +} diff --git a/crates/erp-health/src/dto/points_dto.rs b/crates/erp-health/src/dto/points_dto.rs index 76abeb6..9e58508 100644 --- a/crates/erp-health/src/dto/points_dto.rs +++ b/crates/erp-health/src/dto/points_dto.rs @@ -264,6 +264,18 @@ pub struct OfflineEventResp { // 管理端:带版本号的更新/删除包装 // --------------------------------------------------------------------------- +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateRuleWithVersion { + pub data: UpdatePointsRuleReq, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateProductWithVersion { + pub data: UpdatePointsProductReq, + pub version: i32, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateOfflineEventWithVersion { pub data: UpdateOfflineEventReq, diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 15f8d6c..103fc2b 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -29,6 +29,9 @@ pub enum HealthError { #[error("日常监测记录不存在")] DailyMonitoringNotFound, + #[error("积分规则不存在")] + PointsRuleNotFound, + #[error("兑换商品不存在")] PointsProductNotFound, @@ -92,6 +95,7 @@ impl From for AppError { | HealthError::FollowUpTaskNotFound | HealthError::ConsultationNotFound | HealthError::ArticleNotFound + | HealthError::PointsRuleNotFound | HealthError::PointsProductNotFound | HealthError::PointsOrderNotFound | HealthError::OfflineEventNotFound diff --git a/crates/erp-health/src/handler/article_tag_handler.rs b/crates/erp-health/src/handler/article_tag_handler.rs index f7307e0..0278850 100644 --- a/crates/erp-health/src/handler/article_tag_handler.rs +++ b/crates/erp-health/src/handler/article_tag_handler.rs @@ -6,7 +6,7 @@ use erp_core::error::AppError; use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, TenantContext}; -use crate::dto::article_dto::{CreateTagReq, TagResp}; +use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq}; use crate::service::article_tag_service; use crate::state::HealthState; @@ -40,6 +40,24 @@ where Ok(Json(ApiResponse::ok(result))) } +pub async fn update_tag( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + mut req: Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.articles.manage")?; + req.sanitize(); + let result = article_tag_service::update_tag( + &state, ctx.tenant_id, id, Some(ctx.user_id), req.0, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct DeleteTagReq { pub version: i32, diff --git a/crates/erp-health/src/handler/points_handler.rs b/crates/erp-health/src/handler/points_handler.rs index 079b075..32fcb67 100644 --- a/crates/erp-health/src/handler/points_handler.rs +++ b/crates/erp-health/src/handler/points_handler.rs @@ -227,6 +227,38 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, Ok(Json(ApiResponse::ok(result))) } +pub async fn update_rule( + State(state): State, + Extension(ctx): Extension, + Path(rule_id): Path, + Json(wrapper): Json, +) -> Result>, AppError> +where HealthState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.points.manage")?; + let mut data = wrapper.data; + data.sanitize(); + let result = points_service::update_rule( + &state, ctx.tenant_id, rule_id, Some(ctx.user_id), data, wrapper.version, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_rule( + State(state): State, + Extension(ctx): Extension, + Path(rule_id): Path, + Json(wrapper): Json, +) -> Result>, AppError> +where HealthState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.points.manage")?; + points_service::delete_rule( + &state, ctx.tenant_id, rule_id, Some(ctx.user_id), wrapper.version, + ).await?; + Ok(Json(ApiResponse::ok(()))) +} + pub async fn admin_create_product( State(state): State, Extension(ctx): Extension, @@ -243,6 +275,38 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, Ok(Json(ApiResponse::ok(result))) } +pub async fn admin_update_product( + State(state): State, + Extension(ctx): Extension, + Path(product_id): Path, + Json(wrapper): Json, +) -> Result>, AppError> +where HealthState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.points.manage")?; + let mut data = wrapper.data; + data.sanitize(); + let result = points_service::update_product( + &state, ctx.tenant_id, product_id, Some(ctx.user_id), data, wrapper.version, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn admin_delete_product( + State(state): State, + Extension(ctx): Extension, + Path(product_id): Path, + Json(wrapper): Json, +) -> Result>, AppError> +where HealthState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.points.manage")?; + points_service::delete_product( + &state, ctx.tenant_id, product_id, Some(ctx.user_id), wrapper.version, + ).await?; + Ok(Json(ApiResponse::ok(()))) +} + pub async fn admin_list_orders( State(state): State, Extension(ctx): Extension, diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 1d6aa73..646d1c2 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -372,7 +372,8 @@ impl HealthModule { ) .route( "/health/article-tags/{id}", - axum::routing::delete(article_tag_handler::delete_tag), + axum::routing::put(article_tag_handler::update_tag) + .delete(article_tag_handler::delete_tag), ) // 积分商城 — 患者端 .route( @@ -426,10 +427,20 @@ impl HealthModule { axum::routing::get(points_handler::list_rules) .post(points_handler::create_rule), ) + .route( + "/health/admin/points/rules/{id}", + axum::routing::put(points_handler::update_rule) + .delete(points_handler::delete_rule), + ) .route( "/health/admin/points/products", axum::routing::post(points_handler::admin_create_product), ) + .route( + "/health/admin/points/products/{id}", + axum::routing::put(points_handler::admin_update_product) + .delete(points_handler::admin_delete_product), + ) .route( "/health/admin/points/orders", axum::routing::get(points_handler::admin_list_orders), diff --git a/crates/erp-health/src/service/article_tag_service.rs b/crates/erp-health/src/service/article_tag_service.rs index 937f8d5..fe71032 100644 --- a/crates/erp-health/src/service/article_tag_service.rs +++ b/crates/erp-health/src/service/article_tag_service.rs @@ -9,7 +9,7 @@ use erp_core::audit::AuditLog; use erp_core::audit_service; use erp_core::error::check_version; -use crate::dto::article_dto::{CreateTagReq, TagResp}; +use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq}; use crate::entity::article_tag; use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; @@ -66,6 +66,44 @@ pub async fn create_tag( }) } +pub async fn update_tag( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: UpdateTagReq, +) -> HealthResult { + let expected_version = req.version; + let model = article_tag::Entity::find() + .filter(article_tag::Column::Id.eq(id)) + .filter(article_tag::Column::TenantId.eq(tenant_id)) + .filter(article_tag::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::TagNotFound)?; + + let next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: article_tag::ActiveModel = model.into(); + active.name = Set(req.name); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "article_tag.updated", "article_tag") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(TagResp { + id: m.id, name: m.name, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + pub async fn delete_tag( state: &HealthState, tenant_id: Uuid, diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs index 31c5069..cc7d0f9 100644 --- a/crates/erp-health/src/service/points_service.rs +++ b/crates/erp-health/src/service/points_service.rs @@ -645,6 +645,89 @@ pub async fn create_product( }) } +pub async fn update_product( + state: &HealthState, + tenant_id: Uuid, + product_id: Uuid, + operator_id: Option, + req: UpdatePointsProductReq, + expected_version: i32, +) -> HealthResult { + let model = points_product::Entity::find() + .filter(points_product::Column::Id.eq(product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsProductNotFound)?; + + let next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_product::ActiveModel = model.into(); + if let Some(name) = req.name { active.name = Set(name); } + if let Some(product_type) = req.product_type { active.product_type = Set(product_type); } + if let Some(points_cost) = req.points_cost { active.points_cost = Set(points_cost); } + if let Some(stock) = req.stock { active.stock = Set(stock); } + if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); } + if let Some(description) = req.description { active.description = Set(Some(description)); } + if let Some(service_config) = req.service_config { active.service_config = Set(Some(service_config)); } + if let Some(is_active) = req.is_active { active.is_active = Set(is_active); } + if let Some(sort_order) = req.sort_order { active.sort_order = Set(sort_order); } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "points_product.updated", "points_product") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(PointsProductResp { + id: m.id, name: m.name, product_type: m.product_type, + points_cost: m.points_cost, stock: m.stock, + image_url: m.image_url, description: m.description, + is_active: m.is_active, sort_order: m.sort_order, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn delete_product( + state: &HealthState, + tenant_id: Uuid, + product_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + let model = points_product::Entity::find() + .filter(points_product::Column::Id.eq(product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsProductNotFound)?; + + let _next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_product::ActiveModel = model.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "points_product.deleted", "points_product") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(()) +} + // --------------------------------------------------------------------------- // 兑换(FIFO 消费积分) // --------------------------------------------------------------------------- @@ -1055,6 +1138,89 @@ pub async fn create_rule( }) } +pub async fn update_rule( + state: &HealthState, + tenant_id: Uuid, + rule_id: Uuid, + operator_id: Option, + req: UpdatePointsRuleReq, + expected_version: i32, +) -> HealthResult { + let model = points_rule::Entity::find() + .filter(points_rule::Column::Id.eq(rule_id)) + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsRuleNotFound)?; + + let next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_rule::ActiveModel = model.into(); + if let Some(name) = req.name { active.name = Set(name); } + if let Some(description) = req.description { active.description = Set(Some(description)); } + if let Some(points_value) = req.points_value { active.points_value = Set(points_value); } + if let Some(daily_cap) = req.daily_cap { active.daily_cap = Set(daily_cap); } + if let Some(streak_7d_bonus) = req.streak_7d_bonus { active.streak_7d_bonus = Set(streak_7d_bonus); } + if let Some(streak_14d_bonus) = req.streak_14d_bonus { active.streak_14d_bonus = Set(streak_14d_bonus); } + if let Some(streak_30d_bonus) = req.streak_30d_bonus { active.streak_30d_bonus = Set(streak_30d_bonus); } + if let Some(is_active) = req.is_active { active.is_active = Set(is_active); } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "points_rule.updated", "points_rule") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(PointsRuleResp { + id: m.id, event_type: m.event_type, name: m.name, + description: m.description, points_value: m.points_value, + daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus, + streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus, + is_active: m.is_active, created_at: m.created_at, + updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn delete_rule( + state: &HealthState, + tenant_id: Uuid, + rule_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + let model = points_rule::Entity::find() + .filter(points_rule::Column::Id.eq(rule_id)) + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsRuleNotFound)?; + + let _next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_rule::ActiveModel = model.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "points_rule.deleted", "points_rule") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(()) +} + // --------------------------------------------------------------------------- // 线下活动 // ---------------------------------------------------------------------------