feat: 统一创作页表头跟随后台入口配置
This commit is contained in:
@@ -12,7 +12,7 @@ use crate::format_utc_micros;
|
||||
use shared_contracts::creation_entry_config::{
|
||||
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
|
||||
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
|
||||
encode_unified_creation_spec_response, resolve_unified_creation_spec_response_with_entry_title,
|
||||
};
|
||||
|
||||
/// 将创作入口领域快照转换为前后台共享的 HTTP 契约响应。
|
||||
@@ -45,8 +45,9 @@ pub fn build_creation_entry_config_response(
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let unified_creation_spec = resolve_unified_creation_spec_response(
|
||||
let unified_creation_spec = resolve_unified_creation_spec_response_with_entry_title(
|
||||
item.id.as_str(),
|
||||
item.title.as_str(),
|
||||
item.unified_creation_spec_json.as_deref(),
|
||||
);
|
||||
CreationEntryTypeResponse {
|
||||
@@ -161,10 +162,9 @@ fn normalize_creation_entry_announcement_banner_value(
|
||||
);
|
||||
}
|
||||
|
||||
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
|
||||
object.clone(),
|
||||
))
|
||||
.map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?;
|
||||
let banner =
|
||||
serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(object.clone()))
|
||||
.map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?;
|
||||
normalize_creation_entry_event_banner_response(index, banner)
|
||||
}
|
||||
|
||||
@@ -243,8 +243,8 @@ pub fn resolve_creation_entry_event_banner_responses(
|
||||
banners
|
||||
}
|
||||
.into_iter()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 把领域公告快照转换为 HTTP 响应字段。
|
||||
@@ -332,10 +332,7 @@ fn normalize_banner_html_code(
|
||||
}
|
||||
let lower_html_code = html_code.to_ascii_lowercase();
|
||||
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
|
||||
return Err(format!(
|
||||
"第 {} 条 HTML 公告含有不允许的脚本代码",
|
||||
index + 1
|
||||
));
|
||||
return Err(format!("第 {} 条 HTML 公告含有不允许的脚本代码", index + 1));
|
||||
}
|
||||
|
||||
Ok(Some(html_code))
|
||||
|
||||
@@ -339,12 +339,20 @@ mod tests {
|
||||
assert_eq!(banners.len(), 1);
|
||||
assert_eq!(banners[0].render_mode, "html");
|
||||
assert_eq!(banners[0].title, "创作公告");
|
||||
assert!(banners[0].html_code.as_deref().unwrap_or("").contains("创作公告"));
|
||||
assert!(banners[0]
|
||||
.html_code
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("/creation-type-references/puzzle.webp"));
|
||||
assert!(
|
||||
banners[0]
|
||||
.html_code
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("创作公告")
|
||||
);
|
||||
assert!(
|
||||
banners[0]
|
||||
.html_code
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("/creation-type-references/puzzle.webp")
|
||||
);
|
||||
assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src);
|
||||
}
|
||||
|
||||
@@ -485,6 +493,60 @@ mod tests {
|
||||
assert_eq!(jump_hop.category_sort_order, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_response_uses_entry_title_for_legacy_unified_creation_title() {
|
||||
let response = build_creation_entry_config_response(CreationEntryConfigSnapshot {
|
||||
config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
|
||||
start_card: CreationEntryStartCardSnapshot {
|
||||
title: DEFAULT_CREATION_ENTRY_START_TITLE.to_string(),
|
||||
description: DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(),
|
||||
idle_badge: DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(),
|
||||
busy_badge: DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(),
|
||||
},
|
||||
type_modal: CreationEntryTypeModalSnapshot {
|
||||
title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
|
||||
description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
|
||||
},
|
||||
event_banner: default_creation_entry_event_banner_snapshots()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("default banner"),
|
||||
event_banners_json: Some(default_creation_entry_event_banners_json()),
|
||||
creation_types: vec![CreationEntryTypeSnapshot {
|
||||
id: "puzzle".to_string(),
|
||||
title: "定制拼图".to_string(),
|
||||
subtitle: "拼图关卡创作".to_string(),
|
||||
badge: "可创建".to_string(),
|
||||
image_src: "/creation-type-references/puzzle.webp".to_string(),
|
||||
visible: true,
|
||||
open: true,
|
||||
sort_order: 30,
|
||||
category_id: "recommended".to_string(),
|
||||
category_label: "热门推荐".to_string(),
|
||||
category_sort_order: 20,
|
||||
updated_at_micros: 1,
|
||||
unified_creation_spec_json: Some(
|
||||
r#"{"playId":"puzzle","title":"想做个什么玩法?","workspaceStage":"puzzle-agent-workspace","generationStage":"puzzle-generating","resultStage":"puzzle-result","fields":[{"id":"pictureDescription","kind":"text","label":"画面描述","required":true}]}"#
|
||||
.to_string(),
|
||||
),
|
||||
}],
|
||||
updated_at_micros: 1,
|
||||
});
|
||||
let puzzle = response
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == "puzzle")
|
||||
.expect("puzzle entry");
|
||||
|
||||
assert_eq!(
|
||||
puzzle
|
||||
.unified_creation_spec
|
||||
.as_ref()
|
||||
.map(|spec| spec.title.as_str()),
|
||||
Some("定制拼图")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_clamps_music_volume_into_valid_range() {
|
||||
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
||||
|
||||
@@ -99,6 +99,7 @@ pub struct UnifiedCreationFieldResponse {
|
||||
}
|
||||
|
||||
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
|
||||
pub const LEGACY_UNIFIED_CREATION_TITLE: &str = "想做个什么玩法?";
|
||||
|
||||
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
|
||||
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
|
||||
@@ -172,18 +173,8 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
vec![
|
||||
unified_creation_field("title", "text", "作品标题", true),
|
||||
unified_creation_field("themeDescription", "text", "主题/场景描述", true),
|
||||
unified_creation_field(
|
||||
"playerImageDescription",
|
||||
"text",
|
||||
"玩家形象描述",
|
||||
true,
|
||||
),
|
||||
unified_creation_field(
|
||||
"opponentImageDescription",
|
||||
"text",
|
||||
"对手形象描述",
|
||||
true,
|
||||
),
|
||||
unified_creation_field("playerImageDescription", "text", "玩家形象描述", true),
|
||||
unified_creation_field("opponentImageDescription", "text", "对手形象描述", true),
|
||||
unified_creation_field("onomatopoeia", "text", "拟声词", false),
|
||||
unified_creation_field("difficultyPreset", "select", "难度", true),
|
||||
],
|
||||
@@ -220,7 +211,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
|
||||
Some(UnifiedCreationSpecResponse {
|
||||
play_id: play_id.to_string(),
|
||||
title: "想做个什么玩法?".to_string(),
|
||||
title: default_unified_creation_title(play_id)?.to_string(),
|
||||
workspace_stage: workspace_stage.to_string(),
|
||||
generation_stage: generation_stage.to_string(),
|
||||
result_stage: result_stage.to_string(),
|
||||
@@ -228,6 +219,43 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_unified_creation_title(play_id: &str) -> Option<&'static str> {
|
||||
match play_id {
|
||||
"rpg" => Some("文字冒险"),
|
||||
"big-fish" => Some("摸鱼"),
|
||||
"puzzle" => Some("拼图"),
|
||||
"match3d" => Some("抓大鹅"),
|
||||
"jump-hop" => Some("跳一跳"),
|
||||
"wooden-fish" => Some("敲木鱼"),
|
||||
"square-hole" => Some("方洞"),
|
||||
"bark-battle" => Some("汪汪声浪"),
|
||||
"visual-novel" => Some("视觉小说"),
|
||||
"baby-object-match" => Some("宝贝识物"),
|
||||
"creative-agent" => Some("智能体创作"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_unified_creation_spec_title_for_entry(
|
||||
play_id: &str,
|
||||
entry_title: &str,
|
||||
mut spec: UnifiedCreationSpecResponse,
|
||||
) -> UnifiedCreationSpecResponse {
|
||||
let entry_title = entry_title.trim();
|
||||
if entry_title.is_empty() {
|
||||
return spec;
|
||||
}
|
||||
|
||||
let spec_title = spec.title.trim();
|
||||
let default_title = default_unified_creation_title(play_id).unwrap_or_default();
|
||||
if spec_title == LEGACY_UNIFIED_CREATION_TITLE
|
||||
|| (!default_title.is_empty() && spec_title == default_title)
|
||||
{
|
||||
spec.title = entry_title.to_string();
|
||||
}
|
||||
spec
|
||||
}
|
||||
|
||||
pub fn validate_unified_creation_spec_response(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
@@ -311,10 +339,27 @@ pub fn resolve_unified_creation_spec_response(
|
||||
play_id: &str,
|
||||
value: Option<&str>,
|
||||
) -> Option<UnifiedCreationSpecResponse> {
|
||||
match value {
|
||||
resolve_unified_creation_spec_response_with_entry_title(
|
||||
play_id,
|
||||
default_unified_creation_title(play_id).unwrap_or_default(),
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_unified_creation_spec_response_with_entry_title(
|
||||
play_id: &str,
|
||||
entry_title: &str,
|
||||
value: Option<&str>,
|
||||
) -> Option<UnifiedCreationSpecResponse> {
|
||||
let spec = match value {
|
||||
Some(raw) => decode_unified_creation_spec_response(raw).ok(),
|
||||
None => build_phase1_unified_creation_spec(play_id),
|
||||
}
|
||||
}?;
|
||||
Some(normalize_unified_creation_spec_title_for_entry(
|
||||
play_id,
|
||||
entry_title,
|
||||
spec,
|
||||
))
|
||||
}
|
||||
|
||||
fn unified_creation_field(
|
||||
@@ -338,10 +383,12 @@ mod tests {
|
||||
#[test]
|
||||
fn phase1_unified_creation_specs_cover_existing_templates() {
|
||||
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
|
||||
assert_eq!(puzzle.title, "拼图");
|
||||
assert_eq!(puzzle.fields[0].id, "pictureDescription");
|
||||
assert_eq!(puzzle.fields[1].kind, "image");
|
||||
|
||||
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
|
||||
assert_eq!(match3d.title, "抓大鹅");
|
||||
assert_eq!(
|
||||
match3d
|
||||
.fields
|
||||
@@ -352,6 +399,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
|
||||
assert_eq!(jump_hop.title, "跳一跳");
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
@@ -389,6 +437,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_creation_spec_title_can_fallback_to_entry_title() {
|
||||
let raw = r#"{
|
||||
"playId": "puzzle",
|
||||
"title": "想做个什么玩法?",
|
||||
"workspaceStage": "puzzle-agent-workspace",
|
||||
"generationStage": "puzzle-generating",
|
||||
"resultStage": "puzzle-result",
|
||||
"fields": [
|
||||
{
|
||||
"id": "pictureDescription",
|
||||
"kind": "text",
|
||||
"label": "画面描述",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let spec = resolve_unified_creation_spec_response_with_entry_title(
|
||||
"puzzle",
|
||||
"定制拼图",
|
||||
Some(raw),
|
||||
)
|
||||
.expect("puzzle spec");
|
||||
|
||||
assert_eq!(spec.title, "定制拼图");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_creation_spec_title_keeps_admin_custom_copy() {
|
||||
let mut spec = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
|
||||
spec.title = "你的跳一跳是...".to_string();
|
||||
|
||||
let normalized =
|
||||
normalize_unified_creation_spec_title_for_entry("jump-hop", "跳一跳", spec);
|
||||
|
||||
assert_eq!(normalized.title, "你的跳一跳是...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
|
||||
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
|
||||
|
||||
@@ -122,7 +122,8 @@ fn upsert_creation_entry_type_config_in_tx(
|
||||
if input.title.trim().is_empty() {
|
||||
return Err("入口标题不能为空".to_string());
|
||||
}
|
||||
let unified_creation_spec_json = normalize_unified_creation_spec_json(&id, &input)?;
|
||||
let unified_creation_spec_json =
|
||||
normalize_unified_creation_spec_json(&id, input.title.trim(), &input)?;
|
||||
let row = CreationEntryTypeConfig {
|
||||
id: id.clone(),
|
||||
title: input.title.trim().to_string(),
|
||||
@@ -297,6 +298,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
migrate_baby_object_match_entry_from_old_coming_soon_default(ctx, now);
|
||||
migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now);
|
||||
migrate_jump_hop_entry_from_old_puzzle_default(ctx, now);
|
||||
migrate_unified_creation_titles_to_entry_defaults(ctx, now);
|
||||
}
|
||||
|
||||
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
@@ -477,6 +479,51 @@ fn migrate_jump_hop_entry_from_old_puzzle_default(ctx: &ReducerContext, now: Tim
|
||||
});
|
||||
}
|
||||
|
||||
fn migrate_unified_creation_titles_to_entry_defaults(ctx: &ReducerContext, now: Timestamp) {
|
||||
let rows = ctx
|
||||
.db
|
||||
.creation_entry_type_config()
|
||||
.iter()
|
||||
.collect::<Vec<_>>();
|
||||
for row in rows {
|
||||
let Some(raw_spec_json) = row.unified_creation_spec_json.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(spec) =
|
||||
shared_contracts::creation_entry_config::decode_unified_creation_spec_response(
|
||||
raw_spec_json,
|
||||
)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let normalized =
|
||||
shared_contracts::creation_entry_config::normalize_unified_creation_spec_title_for_entry(
|
||||
&row.id,
|
||||
&row.title,
|
||||
spec.clone(),
|
||||
);
|
||||
if normalized.title == spec.title {
|
||||
continue;
|
||||
}
|
||||
let Ok(unified_creation_spec_json) =
|
||||
shared_contracts::creation_entry_config::encode_unified_creation_spec_response(
|
||||
&normalized,
|
||||
)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
ctx.db
|
||||
.creation_entry_type_config()
|
||||
.id()
|
||||
.update(CreationEntryTypeConfig {
|
||||
unified_creation_spec_json: Some(unified_creation_spec_json),
|
||||
updated_at: now,
|
||||
..row
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
|
||||
module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch())
|
||||
.into_iter()
|
||||
@@ -500,6 +547,7 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
|
||||
|
||||
fn normalize_unified_creation_spec_json(
|
||||
id: &str,
|
||||
entry_title: &str,
|
||||
input: &CreationEntryTypeAdminUpsertInput,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(spec_json) = input.unified_creation_spec_json.as_deref() else {
|
||||
@@ -512,6 +560,12 @@ fn normalize_unified_creation_spec_json(
|
||||
|
||||
let spec =
|
||||
shared_contracts::creation_entry_config::decode_unified_creation_spec_response(normalized)?;
|
||||
let spec =
|
||||
shared_contracts::creation_entry_config::normalize_unified_creation_spec_title_for_entry(
|
||||
id,
|
||||
entry_title,
|
||||
spec,
|
||||
);
|
||||
shared_contracts::creation_entry_config::validate_unified_creation_spec_for_play(id, &spec)?;
|
||||
shared_contracts::creation_entry_config::encode_unified_creation_spec_response(&spec).map(Some)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user