feat(diary): Phase 1.3 完善修复 — 贴纸/主题 CRUD + 管理端对接 + HMS 清理
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

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 
This commit is contained in:
iven
2026-06-02 23:01:13 +08:00
parent 94bfb3297a
commit 8ea1032c9d
6 changed files with 470 additions and 8 deletions

View File

@@ -189,6 +189,51 @@ pub struct CreateCommentReq {
pub content: String,
}
/// 更新主题请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateTopicReq {
/// 主题标题
pub title: Option<String>,
/// 主题描述
pub description: Option<String>,
/// 截止日期
pub due_date: Option<chrono::NaiveDate>,
/// 乐观锁版本号
pub version: i32,
}
/// 创建贴纸包请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateStickerPackReq {
/// 贴纸包名称
pub name: String,
/// 描述
pub description: Option<String>,
/// 缩略图 URL
pub thumbnail_url: Option<String>,
/// 是否免费
#[serde(default = "default_true")]
pub is_free: bool,
/// 价格(积分)
#[serde(default)]
pub price: i32,
/// 分类
pub category: Option<String>,
}
fn default_true() -> bool { true }
/// 创建贴纸请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateStickerReq {
/// 贴纸名称
pub name: String,
/// 图片 URL
pub image_url: String,
/// 分类
pub category: Option<String>,
}
/// 评语响应
#[derive(Debug, Serialize, ToSchema)]
pub struct CommentResp {

View File

@@ -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<StickerPackResp>),
(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<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateStickerPackReq>,
) -> Result<Json<ApiResponse<StickerPackResp>>, AppError>
where
DiaryState: FromRef<S>,
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<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(pack_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
DiaryState: FromRef<S>,
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<StickerResp>),
(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<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(pack_id): Path<Uuid>,
Json(req): Json<CreateStickerReq>,
) -> Result<Json<ApiResponse<StickerResp>>, AppError>
where
DiaryState: FromRef<S>,
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<String>,

View File

@@ -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<TopicResp>),
(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<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(topic_id): Path<Uuid>,
Json(req): Json<UpdateTopicReq>,
) -> Result<Json<ApiResponse<TopicResp>>, AppError>
where
DiaryState: FromRef<S>,
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<TopicResp>),
(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<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(topic_id): Path<Uuid>,
) -> Result<Json<ApiResponse<TopicResp>>, AppError>
where
DiaryState: FromRef<S>,
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)))
}

View File

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

View File

@@ -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<StickerPackResp> {
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<StickerResp> {
// 验证贴纸包存在
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)]

View File

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