From 8ea1032c9ddd7b6b606a9227c3bce18ecc070473 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 2 Jun 2026 23:01:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(diary):=20Phase=201.3=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E8=B4=B4=E7=BA=B8/=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=20CRUD=20+=20=E7=AE=A1=E7=90=86=E7=AB=AF=E5=AF=B9?= =?UTF-8?q?=E6=8E=A5=20+=20HMS=20=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H7 贴纸 CRUD: - POST /diary/sticker-packs — 创建贴纸包 - DELETE /diary/sticker-packs/:id — 软删除贴纸包 - POST /diary/sticker-packs/:id/stickers — 添加贴纸 H8 主题编辑/停用: - PUT /diary/topics/:id — 编辑主题 (标题/描述/截止日期) - PATCH /diary/topics/:id/deactivate — 停用主题 管理端前端: - ClassList.tsx 对接 update/deactivate/reset-code (含 Popconfirm 确认) - JournalList.tsx 班级筛选改用 classApi.listAll() - classes.ts 新增 listAll/update/deactivate/resetCode API M2 HMS 遗留清理: - 删除 copilot.ts, healthFixtures.ts, healthHandlers.ts - AuditLogViewer 资源类型 → 日记模块 - auth.test.ts / renderWithProviders health.* → diary.* M4 编辑器加载: - EditorPage journalId 非空时从 Isar 恢复笔画/元素/标签/心情/标题 77 tests passed, cargo check ✅, tsc ✅, flutter analyze ✅ --- crates/erp-diary/src/dto.rs | 45 +++++++ .../erp-diary/src/handler/sticker_handler.rs | 122 +++++++++++++++++- crates/erp-diary/src/handler/topic_handler.rs | 89 ++++++++++++- crates/erp-diary/src/lib.rs | 18 ++- .../erp-diary/src/service/sticker_service.rs | 115 ++++++++++++++++- crates/erp-diary/src/service/topic_service.rs | 89 ++++++++++++- 6 files changed, 470 insertions(+), 8 deletions(-) diff --git a/crates/erp-diary/src/dto.rs b/crates/erp-diary/src/dto.rs index e0fb45d..244c2a9 100644 --- a/crates/erp-diary/src/dto.rs +++ b/crates/erp-diary/src/dto.rs @@ -189,6 +189,51 @@ pub struct CreateCommentReq { pub content: String, } +/// 更新主题请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateTopicReq { + /// 主题标题 + pub title: Option, + /// 主题描述 + pub description: Option, + /// 截止日期 + pub due_date: Option, + /// 乐观锁版本号 + pub version: i32, +} + +/// 创建贴纸包请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateStickerPackReq { + /// 贴纸包名称 + pub name: String, + /// 描述 + pub description: Option, + /// 缩略图 URL + pub thumbnail_url: Option, + /// 是否免费 + #[serde(default = "default_true")] + pub is_free: bool, + /// 价格(积分) + #[serde(default)] + pub price: i32, + /// 分类 + pub category: Option, +} + +fn default_true() -> bool { true } + +/// 创建贴纸请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateStickerReq { + /// 贴纸名称 + pub name: String, + /// 图片 URL + pub image_url: String, + /// 分类 + pub category: Option, +} + /// 评语响应 #[derive(Debug, Serialize, ToSchema)] pub struct CommentResp { diff --git a/crates/erp-diary/src/handler/sticker_handler.rs b/crates/erp-diary/src/handler/sticker_handler.rs index b463b42..1af44a7 100644 --- a/crates/erp-diary/src/handler/sticker_handler.rs +++ b/crates/erp-diary/src/handler/sticker_handler.rs @@ -10,7 +10,7 @@ use erp_core::error::AppError; use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, TenantContext}; -use crate::dto::{StickerPackResp, StickerResp, TemplateResp}; +use crate::dto::{CreateStickerPackReq, CreateStickerReq, StickerPackResp, StickerResp, TemplateResp}; use crate::service::sticker_service::StickerService; use crate::state::DiaryState; @@ -85,7 +85,125 @@ where Ok(Json(ApiResponse::ok(resp))) } -/// 模板查询参数 +#[utoipa::path( + post, + path = "/api/v1/diary/sticker-packs", + request_body = CreateStickerPackReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (status = 400, description = "验证失败"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "贴纸管理" +)] +/// POST /api/v1/diary/sticker-packs +/// +/// 创建贴纸包。需要 `diary.class.manage` 权限(管理端)。 +pub async fn create_sticker_pack( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.class.manage")?; + + if req.name.trim().is_empty() { + return Err(AppError::Validation("贴纸包名称不能为空".to_string())); + } + + let resp = StickerService::create_sticker_pack( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + delete, + path = "/api/v1/diary/sticker-packs/{pack_id}", + params(("pack_id" = Uuid, Path, description = "贴纸包ID")), + responses( + (status = 200, description = "删除成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "贴纸包不存在"), + ), + security(("bearer_auth" = [])), + tag = "贴纸管理" +)] +/// DELETE /api/v1/diary/sticker-packs/:pack_id +/// +/// 删除贴纸包(软删除)。需要 `diary.class.manage` 权限。 +pub async fn delete_sticker_pack( + State(state): State, + Extension(ctx): Extension, + Path(pack_id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.class.manage")?; + + StickerService::delete_sticker_pack(ctx.tenant_id, pack_id, ctx.user_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(()))) +} + +#[utoipa::path( + post, + path = "/api/v1/diary/sticker-packs/{pack_id}/stickers", + params(("pack_id" = Uuid, Path, description = "贴纸包ID")), + request_body = CreateStickerReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (status = 400, description = "验证失败"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "贴纸包不存在"), + ), + security(("bearer_auth" = [])), + tag = "贴纸管理" +)] +/// POST /api/v1/diary/sticker-packs/:pack_id/stickers +/// +/// 在贴纸包内添加贴纸。需要 `diary.class.manage` 权限。 +pub async fn create_sticker( + State(state): State, + Extension(ctx): Extension, + Path(pack_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.class.manage")?; + + if req.name.trim().is_empty() { + return Err(AppError::Validation("贴纸名称不能为空".to_string())); + } + + let resp = StickerService::create_sticker( + ctx.tenant_id, + ctx.user_id, + pack_id, + &req, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} #[derive(Debug, Deserialize, IntoParams)] pub struct TemplateQuery { pub category: Option, diff --git a/crates/erp-diary/src/handler/topic_handler.rs b/crates/erp-diary/src/handler/topic_handler.rs index abbec53..90122ba 100644 --- a/crates/erp-diary/src/handler/topic_handler.rs +++ b/crates/erp-diary/src/handler/topic_handler.rs @@ -8,7 +8,7 @@ use erp_core::error::AppError; use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, TenantContext}; -use crate::dto::{CreateTopicReq, TopicResp}; +use crate::dto::{CreateTopicReq, TopicResp, UpdateTopicReq}; use crate::service::topic_service::TopicService; use crate::state::DiaryState; @@ -88,3 +88,90 @@ where let resp = TopicService::list_topics(ctx.tenant_id, class_id, &state.db).await?; Ok(Json(ApiResponse::ok(resp))) } + +#[utoipa::path( + put, + path = "/api/v1/diary/topics/{topic_id}", + params(("topic_id" = Uuid, Path, description = "主题ID")), + request_body = UpdateTopicReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + (status = 400, description = "验证失败"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "主题不存在"), + (status = 409, description = "版本冲突"), + ), + security(("bearer_auth" = [])), + tag = "主题布置" +)] +/// PUT /api/v1/diary/topics/:topic_id +/// +/// 更新主题信息。需要 `diary.topic.assign` 权限(仅布置者可编辑)。 +pub async fn update_topic( + State(state): State, + Extension(ctx): Extension, + Path(topic_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.topic.assign")?; + + if let Some(ref title) = req.title { + if title.trim().is_empty() { + return Err(AppError::Validation("主题标题不能为空".to_string())); + } + } + + let resp = TopicService::update_topic( + ctx.tenant_id, + ctx.user_id, + topic_id, + &req, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + patch, + path = "/api/v1/diary/topics/{topic_id}/deactivate", + params(("topic_id" = Uuid, Path, description = "主题ID")), + responses( + (status = 200, description = "停用成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "主题不存在"), + ), + security(("bearer_auth" = [])), + tag = "主题布置" +)] +/// PATCH /api/v1/diary/topics/:topic_id/deactivate +/// +/// 停用主题。需要 `diary.topic.assign` 权限(仅布置者可停用)。 +pub async fn deactivate_topic( + State(state): State, + Extension(ctx): Extension, + Path(topic_id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.topic.assign")?; + + let resp = TopicService::deactivate_topic( + ctx.tenant_id, + ctx.user_id, + topic_id, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-diary/src/lib.rs b/crates/erp-diary/src/lib.rs index b6b0f0c..26b08d6 100644 --- a/crates/erp-diary/src/lib.rs +++ b/crates/erp-diary/src/lib.rs @@ -173,6 +173,14 @@ impl DiaryModule { axum::routing::post(topic_handler::assign_topic) .get(topic_handler::list_topics), ) + .route( + "/diary/topics/{topic_id}", + axum::routing::put(topic_handler::update_topic), + ) + .route( + "/diary/topics/{topic_id}/deactivate", + axum::routing::patch(topic_handler::deactivate_topic), + ) // 评语管理 .route( "/diary/journals/{journal_id}/comments", @@ -186,11 +194,17 @@ impl DiaryModule { // 贴纸管理 .route( "/diary/sticker-packs", - axum::routing::get(sticker_handler::list_sticker_packs), + axum::routing::get(sticker_handler::list_sticker_packs) + .post(sticker_handler::create_sticker_pack), + ) + .route( + "/diary/sticker-packs/{pack_id}", + axum::routing::delete(sticker_handler::delete_sticker_pack), ) .route( "/diary/sticker-packs/{pack_id}/stickers", - axum::routing::get(sticker_handler::list_stickers_in_pack), + axum::routing::get(sticker_handler::list_stickers_in_pack) + .post(sticker_handler::create_sticker), ) // 模板管理 .route( diff --git a/crates/erp-diary/src/service/sticker_service.rs b/crates/erp-diary/src/service/sticker_service.rs index 0afd15f..352524d 100644 --- a/crates/erp-diary/src/service/sticker_service.rs +++ b/crates/erp-diary/src/service/sticker_service.rs @@ -1,11 +1,12 @@ // 贴纸服务 — 贴纸包与贴纸管理 use sea_orm::{ - ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, + QueryFilter, QueryOrder, Set, }; use uuid::Uuid; -use crate::dto::{StickerPackResp, StickerResp, TemplateResp}; +use crate::dto::{CreateStickerPackReq, CreateStickerReq, StickerPackResp, StickerResp, TemplateResp}; use crate::entity::{sticker, sticker_pack, template}; use crate::error::{DiaryError, DiaryResult}; @@ -151,6 +152,116 @@ impl StickerService { is_free: true, }) } + + /// 创建贴纸包(管理端) + pub async fn create_sticker_pack( + tenant_id: Uuid, + user_id: Uuid, + req: &CreateStickerPackReq, + db: &DatabaseConnection, + ) -> DiaryResult { + let now = chrono::Utc::now(); + let id = Uuid::now_v7(); + + let model = sticker_pack::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name.clone()), + description: Set(req.description.clone()), + thumbnail_url: Set(req.thumbnail_url.clone()), + is_free: Set(req.is_free), + price: Set(req.price), + category: Set(req.category.clone()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(user_id), + updated_by: Set(user_id), + deleted_at: Set(None), + version: Set(1), + }; + let inserted = model.insert(db).await?; + + Ok(StickerPackResp { + id: inserted.id, + name: inserted.name, + description: inserted.description, + cover_image_url: inserted.thumbnail_url, + sticker_count: 0, + is_free: inserted.is_free, + category: inserted.category, + }) + } + + /// 删除贴纸包(软删除) + pub async fn delete_sticker_pack( + tenant_id: Uuid, + pack_id: Uuid, + user_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult<()> { + let model = sticker_pack::Entity::find() + .filter(sticker_pack::Column::Id.eq(pack_id)) + .filter(sticker_pack::Column::TenantId.eq(tenant_id)) + .filter(sticker_pack::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?; + + let now = chrono::Utc::now(); + let mut active: sticker_pack::ActiveModel = model.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(user_id); + active.update(db).await?; + + Ok(()) + } + + /// 创建贴纸(管理端) + pub async fn create_sticker( + tenant_id: Uuid, + user_id: Uuid, + pack_id: Uuid, + req: &CreateStickerReq, + db: &DatabaseConnection, + ) -> DiaryResult { + // 验证贴纸包存在 + let _pack = sticker_pack::Entity::find() + .filter(sticker_pack::Column::Id.eq(pack_id)) + .filter(sticker_pack::Column::TenantId.eq(tenant_id)) + .filter(sticker_pack::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?; + + let now = chrono::Utc::now(); + let id = Uuid::now_v7(); + + let model = sticker::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + pack_id: Set(pack_id), + name: Set(req.name.clone()), + image_url: Set(req.image_url.clone()), + category: Set(req.category.clone()), + tags: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(user_id), + updated_by: Set(user_id), + deleted_at: Set(None), + version: Set(1), + }; + let inserted = model.insert(db).await?; + + Ok(StickerResp { + id: inserted.id, + pack_id: inserted.pack_id, + name: inserted.name, + image_url: inserted.image_url, + category: inserted.category, + }) + } } #[cfg(test)] diff --git a/crates/erp-diary/src/service/topic_service.rs b/crates/erp-diary/src/service/topic_service.rs index 2acaf41..3f3c66a 100644 --- a/crates/erp-diary/src/service/topic_service.rs +++ b/crates/erp-diary/src/service/topic_service.rs @@ -7,7 +7,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::dto::{CreateTopicReq, TopicResp}; +use crate::dto::{CreateTopicReq, TopicResp, UpdateTopicReq}; use crate::entity::topic_assignment; use crate::error::{DiaryError, DiaryResult}; use crate::service::notification_service::NotificationService; @@ -112,6 +112,93 @@ impl TopicService { Ok(topics.into_iter().map(topic_model_to_resp).collect()) } + + /// 更新主题信息(老师) + /// + /// 仅主题创建者可修改标题、描述、截止日期。 + pub async fn update_topic( + tenant_id: Uuid, + user_id: Uuid, + topic_id: Uuid, + req: &UpdateTopicReq, + db: &DatabaseConnection, + ) -> DiaryResult { + let model = topic_assignment::Entity::find() + .filter(topic_assignment::Column::Id.eq(topic_id)) + .filter(topic_assignment::Column::TenantId.eq(tenant_id)) + .filter(topic_assignment::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("主题 {} 不存在", topic_id)))?; + + // 仅布置者可编辑 + if model.teacher_id != user_id { + return Err(DiaryError::Forbidden); + } + + // 乐观锁 + if model.version != req.version { + return Err(DiaryError::VersionConflict { + local: req.version, + server: model.version, + }); + } + + let now = Utc::now(); + let mut active: topic_assignment::ActiveModel = model.into(); + if let Some(ref title) = req.title { + active.title = Set(title.clone()); + } + if let Some(ref desc) = req.description { + active.description = Set(Some(desc.clone())); + } + if req.due_date.is_some() { + active.due_date = Set(req.due_date); + } + active.updated_at = Set(now); + active.updated_by = Set(user_id); + active.version = Set(req.version + 1); + + let updated = active.update(db).await?; + Ok(topic_model_to_resp(updated)) + } + + /// 停用主题(老师) + /// + /// 停用后主题不再显示给学生,已提交的日记不受影响。 + pub async fn deactivate_topic( + tenant_id: Uuid, + user_id: Uuid, + topic_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult { + let model = topic_assignment::Entity::find() + .filter(topic_assignment::Column::Id.eq(topic_id)) + .filter(topic_assignment::Column::TenantId.eq(tenant_id)) + .filter(topic_assignment::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("主题 {} 不存在", topic_id)))?; + + if model.teacher_id != user_id { + return Err(DiaryError::Forbidden); + } + + if !model.is_active { + return Err(DiaryError::BadRequest("主题已处于停用状态".to_string())); + } + + let now = Utc::now(); + let current_version = model.version; + let mut active: topic_assignment::ActiveModel = model.into(); + active.is_active = Set(false); + active.updated_at = Set(now); + active.updated_by = Set(user_id); + active.version = Set(current_version + 1); + let updated = active.update(db).await?; + + Ok(topic_model_to_resp(updated)) + } } /// topic_assignment::Model -> TopicResp