feat: 支持创作入口公告配置

This commit is contained in:
2026-06-03 03:31:45 +08:00
parent 1cb11bc1dd
commit 70ff18ad90
52 changed files with 3045 additions and 504 deletions

View File

@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::creation_entry_config::UnifiedCreationSpecResponse;
use crate::creation_entry_config::{CreationEntryEventBannerResponse, UnifiedCreationSpecResponse};
// 管理后台协议统一收口在 shared-contracts避免页面脚本和 Rust handler 各自手拼字段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -18,6 +18,8 @@ pub struct AdminLoginRequest {
#[serde(rename_all = "camelCase")]
pub struct AdminCreationEntryConfigResponse {
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
/// 底部加号创作入口页的后台公告列表。
pub event_banners: Vec<CreationEntryEventBannerResponse>,
}
/// 后台单个创作入口开关配置。
@@ -59,6 +61,14 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
}
/// 后台保存创作入口公告表单序列化结果请求。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertCreationEntryEventBannersRequest {
/// 传输字段沿用既有契约,内容由后台标题 / HTML 表单生成。
pub event_banners_json: String,
}
/// 后台作品可见性列表项。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]

View File

@@ -1,16 +1,22 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// 前台创作入口配置响应。
///
/// `event_banner` 保留单条旧契约兼容;新创作入口公告位应优先读取
/// `event_banners`,由后台表单配置多条公告并支持轮播。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryConfigResponse {
pub start_card: CreationEntryStartCardResponse,
pub type_modal: CreationEntryTypeModalResponse,
pub event_banner: CreationEntryEventBannerResponse,
pub event_banners: Vec<CreationEntryEventBannerResponse>,
pub creation_types: Vec<CreationEntryTypeResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryStartCardResponse {
pub title: String,
@@ -19,14 +25,19 @@ pub struct CreationEntryStartCardResponse {
pub busy_badge: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// 创作类型选择弹层的基础文案契约。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryTypeModalResponse {
pub title: String,
pub description: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// 创作入口单条公告。
///
/// `html_code` 是后台公告代码的主格式,只允许以前端沙箱 iframe 展示;
/// 结构化字段仅保留旧数据兼容,不能作为可执行 JSX 或非受控 DOM 注入。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryEventBannerResponse {
pub title: String,
@@ -35,9 +46,19 @@ pub struct CreationEntryEventBannerResponse {
pub prize_pool_mud_points: u64,
pub starts_at_text: String,
pub ends_at_text: String,
#[serde(default = "default_creation_entry_event_banner_render_mode")]
pub render_mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_code: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// 默认渲染模式使用受控结构化 UI用于旧数据兼容。
pub fn default_creation_entry_event_banner_render_mode() -> String {
"structured".to_string()
}
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryTypeResponse {
pub id: String,
@@ -56,6 +77,7 @@ pub struct CreationEntryTypeResponse {
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
}
/// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationSpecResponse {
@@ -262,8 +284,18 @@ mod tests {
);
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
assert!(jump_hop.fields.iter().any(|field| field.id == "stylePreset"));
assert!(jump_hop.fields.iter().any(|field| field.id == "endMoodPrompt"));
assert!(
jump_hop
.fields
.iter()
.any(|field| field.id == "stylePreset")
);
assert!(
jump_hop
.fields
.iter()
.any(|field| field.id == "endMoodPrompt")
);
let wooden_fish =
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
@@ -271,4 +303,68 @@ mod tests {
assert!(build_phase1_unified_creation_spec("visual-novel").is_none());
assert!(build_phase1_unified_creation_spec("bark-battle").is_none());
}
#[test]
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
r#"{
"title": "旧版横幅",
"description": "兼容旧字段",
"coverImageSrc": "/creation-type-references/puzzle.webp",
"prizePoolMudPoints": 1000,
"startsAtText": "2026-06-01",
"endsAtText": "2026-06-30"
}"#,
)
.expect("legacy banner json should decode");
assert_eq!(banner.render_mode, "structured");
assert!(banner.html_code.is_none());
}
#[test]
fn creation_entry_config_serializes_event_banners_contract() {
let response = CreationEntryConfigResponse {
start_card: CreationEntryStartCardResponse {
title: "新建作品".to_string(),
description: "选择模板".to_string(),
idle_badge: "模板".to_string(),
busy_badge: "开启中".to_string(),
},
type_modal: CreationEntryTypeModalResponse {
title: "选择创作类型".to_string(),
description: "先选玩法".to_string(),
},
event_banner: CreationEntryEventBannerResponse {
title: "第一条".to_string(),
description: "兼容单条".to_string(),
cover_image_src: "/creation-type-references/puzzle.webp".to_string(),
prize_pool_mud_points: 1000,
starts_at_text: "2026-06-01".to_string(),
ends_at_text: "2026-06-30".to_string(),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners: vec![CreationEntryEventBannerResponse {
title: "HTML 条".to_string(),
description: "沙箱".to_string(),
cover_image_src: "/creation-type-references/match3d.webp".to_string(),
prize_pool_mud_points: 800,
starts_at_text: "2026-07-01".to_string(),
ends_at_text: "2026-07-31".to_string(),
render_mode: "html".to_string(),
html_code: Some("<section>ok</section>".to_string()),
}],
creation_types: Vec::new(),
};
let value = serde_json::to_value(response).expect("response should serialize");
assert_eq!(value["eventBanners"][0]["renderMode"], "html");
assert_eq!(
value["eventBanners"][0]["htmlCode"],
"<section>ok</section>"
);
assert!(value.get("event_banner").is_none());
assert!(value.get("eventBanner").is_some());
}
}