收口创作流程统一总计划并修复等待页窄屏裁切
This commit is contained in:
1
server-rs/Cargo.lock
generated
1
server-rs/Cargo.lock
generated
@@ -3363,6 +3363,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shared-contracts",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
"spacetimedb-lib",
|
||||
|
||||
@@ -28,6 +28,9 @@ use shared_contracts::admin::{
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use shared_contracts::creation_entry_config::{
|
||||
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{
|
||||
@@ -291,6 +294,7 @@ fn map_admin_creation_entry_type_config(
|
||||
category_label: entry.category_label,
|
||||
category_sort_order: entry.category_sort_order,
|
||||
updated_at_micros: entry.updated_at_micros,
|
||||
unified_creation_spec: entry.unified_creation_spec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +309,23 @@ fn validate_admin_creation_entry_config(
|
||||
if title.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口标题不能为空"));
|
||||
}
|
||||
let unified_creation_spec = match payload.unified_creation_spec {
|
||||
Some(spec) => {
|
||||
validate_unified_creation_spec_for_play(&id, &spec).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
|
||||
})?;
|
||||
Some(spec)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let unified_creation_spec_json = unified_creation_spec
|
||||
.as_ref()
|
||||
.map(|spec| {
|
||||
encode_unified_creation_spec_response(spec).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(module_runtime::CreationEntryTypeAdminUpsertInput {
|
||||
id,
|
||||
title,
|
||||
@@ -317,6 +338,7 @@ fn validate_admin_creation_entry_config(
|
||||
category_id: payload.category_id.trim().to_string(),
|
||||
category_label: payload.category_label.trim().to_string(),
|
||||
category_sort_order: payload.category_sort_order,
|
||||
unified_creation_spec_json,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ use crate::errors::RuntimeProfileFieldError;
|
||||
use crate::format_utc_micros;
|
||||
use shared_contracts::creation_entry_config::{
|
||||
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse, build_phase1_unified_creation_spec,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
|
||||
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
|
||||
};
|
||||
|
||||
pub fn build_creation_entry_config_response(
|
||||
@@ -40,7 +41,10 @@ pub fn build_creation_entry_config_response(
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let unified_creation_spec = build_phase1_unified_creation_spec(item.id.as_str());
|
||||
let unified_creation_spec = resolve_unified_creation_spec_response(
|
||||
item.id.as_str(),
|
||||
item.unified_creation_spec_json.as_deref(),
|
||||
);
|
||||
CreationEntryTypeResponse {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
@@ -264,9 +268,15 @@ fn build_default_creation_entry_type_snapshot(
|
||||
category_label: category_label.to_string(),
|
||||
category_sort_order,
|
||||
updated_at_micros,
|
||||
unified_creation_spec_json: default_unified_creation_spec_json(id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_unified_creation_spec_json(play_id: &str) -> Option<String> {
|
||||
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(play_id)
|
||||
.and_then(|spec| encode_unified_creation_spec_response(&spec).ok())
|
||||
}
|
||||
|
||||
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
|
||||
RuntimeSettingsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
|
||||
@@ -102,6 +102,7 @@ pub struct CreationEntryTypeSnapshot {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -129,6 +130,7 @@ pub struct CreationEntryTypeAdminUpsertInput {
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::creation_entry_config::UnifiedCreationSpecResponse;
|
||||
|
||||
// 管理后台协议统一收口在 shared-contracts,避免页面脚本和 Rust handler 各自手拼字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -34,6 +36,8 @@ pub struct AdminCreationEntryTypeConfigPayload {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口开关配置请求。
|
||||
@@ -51,6 +55,8 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 后台作品可见性列表项。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -55,7 +56,7 @@ pub struct CreationEntryTypeResponse {
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationSpecResponse {
|
||||
pub play_id: String,
|
||||
@@ -66,7 +67,7 @@ pub struct UnifiedCreationSpecResponse {
|
||||
pub fields: Vec<UnifiedCreationFieldResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationFieldResponse {
|
||||
pub id: String,
|
||||
@@ -75,6 +76,8 @@ pub struct UnifiedCreationFieldResponse {
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
|
||||
|
||||
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
|
||||
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
|
||||
"puzzle" => (
|
||||
@@ -120,6 +123,95 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_unified_creation_spec_response(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
if spec.play_id.trim().is_empty() {
|
||||
return Err("统一创作契约 playId 不能为空".to_string());
|
||||
}
|
||||
if spec.title.trim().is_empty() {
|
||||
return Err("统一创作契约标题不能为空".to_string());
|
||||
}
|
||||
|
||||
let workspace_stage = spec.workspace_stage.trim();
|
||||
let generation_stage = spec.generation_stage.trim();
|
||||
let result_stage = spec.result_stage.trim();
|
||||
if workspace_stage.is_empty() || generation_stage.is_empty() || result_stage.is_empty() {
|
||||
return Err("统一创作契约阶段不能为空".to_string());
|
||||
}
|
||||
if workspace_stage == generation_stage
|
||||
|| workspace_stage == result_stage
|
||||
|| generation_stage == result_stage
|
||||
{
|
||||
return Err("统一创作契约阶段不能重复".to_string());
|
||||
}
|
||||
if spec.fields.is_empty() {
|
||||
return Err("统一创作契约 fields 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut field_ids = BTreeSet::new();
|
||||
for field in &spec.fields {
|
||||
let field_id = field.id.trim();
|
||||
if field_id.is_empty() {
|
||||
return Err("统一创作契约字段 id 不能为空".to_string());
|
||||
}
|
||||
if !field_ids.insert(field_id.to_string()) {
|
||||
return Err(format!("统一创作契约字段 id 重复:{field_id}"));
|
||||
}
|
||||
if field.label.trim().is_empty() {
|
||||
return Err(format!("统一创作契约字段 {field_id} 标签不能为空"));
|
||||
}
|
||||
if !UNIFIED_CREATION_FIELD_KINDS.contains(&field.kind.trim()) {
|
||||
return Err(format!(
|
||||
"统一创作契约字段 {field_id} kind 非法:{}",
|
||||
field.kind
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_unified_creation_spec_for_play(
|
||||
play_id: &str,
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
if spec.play_id.trim() != play_id.trim() {
|
||||
return Err(format!(
|
||||
"统一创作契约 playId 必须与入口 ID 一致:{}",
|
||||
play_id.trim()
|
||||
));
|
||||
}
|
||||
|
||||
validate_unified_creation_spec_response(spec)
|
||||
}
|
||||
|
||||
pub fn encode_unified_creation_spec_response(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<String, String> {
|
||||
validate_unified_creation_spec_response(spec)?;
|
||||
serde_json::to_string(spec).map_err(|error| format!("统一创作契约序列化失败:{error}"))
|
||||
}
|
||||
|
||||
pub fn decode_unified_creation_spec_response(
|
||||
value: &str,
|
||||
) -> Result<UnifiedCreationSpecResponse, String> {
|
||||
let spec = serde_json::from_str::<UnifiedCreationSpecResponse>(value)
|
||||
.map_err(|error| format!("统一创作契约 JSON 非法:{error}"))?;
|
||||
validate_unified_creation_spec_response(&spec)?;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
pub fn resolve_unified_creation_spec_response(
|
||||
play_id: &str,
|
||||
value: Option<&str>,
|
||||
) -> Option<UnifiedCreationSpecResponse> {
|
||||
match value {
|
||||
Some(raw) => decode_unified_creation_spec_response(raw).ok(),
|
||||
None => build_phase1_unified_creation_spec(play_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn unified_creation_field(
|
||||
id: &str,
|
||||
kind: &str,
|
||||
|
||||
@@ -14,6 +14,7 @@ impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTy
|
||||
category_id: input.category_id,
|
||||
category_label: input.category_label,
|
||||
category_sort_order: input.category_sort_order,
|
||||
unified_creation_spec_json: input.unified_creation_spec_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,6 +300,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
),
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
|
||||
unified_creation_spec_json: item.unified_creation_spec_json,
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||
@@ -345,6 +347,7 @@ fn map_creation_entry_config_snapshot(
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
unified_creation_spec_json: item.unified_creation_spec_json,
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct CreationEntryTypeAdminUpsertInput {
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryTypeAdminUpsertInput {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct CreationEntryTypeConfig {
|
||||
pub category_id: Option<String>,
|
||||
pub category_label: Option<String>,
|
||||
pub category_sort_order: i32,
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryTypeConfig {
|
||||
@@ -41,6 +42,8 @@ pub struct CreationEntryTypeConfigCols {
|
||||
pub category_id: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
|
||||
pub category_label: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
|
||||
pub category_sort_order: __sdk::__query_builder::Col<CreationEntryTypeConfig, i32>,
|
||||
pub unified_creation_spec_json:
|
||||
__sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
|
||||
@@ -62,6 +65,10 @@ impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
|
||||
table_name,
|
||||
"category_sort_order",
|
||||
),
|
||||
unified_creation_spec_json: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"unified_creation_spec_json",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct CreationEntryTypeSnapshot {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryTypeSnapshot {
|
||||
|
||||
@@ -11,6 +11,7 @@ crate-type = ["cdylib"]
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
shared-contracts = { workspace = true }
|
||||
module-ai = { workspace = true, features = ["spacetime-types"] }
|
||||
module-assets = { workspace = true, features = ["spacetime-types"] }
|
||||
module-bark-battle = { workspace = true }
|
||||
|
||||
@@ -1184,7 +1184,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
}
|
||||
if table_name == "creation_entry_type_config" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:入口分类字段晚于入口类型配置表加入,旧迁移包按未分类兼容。
|
||||
// 中文注释:入口分类和统一创作契约字段晚于入口类型配置表加入,旧迁移包按空配置兼容。
|
||||
object
|
||||
.entry("category_id".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
@@ -1194,6 +1194,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
object
|
||||
.entry("category_sort_order".to_string())
|
||||
.or_insert_with(|| serde_json::Value::from(0));
|
||||
object
|
||||
.entry("unified_creation_spec_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
if table_name == "user_account" {
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct CreationEntryTypeConfig {
|
||||
pub(crate) category_label: Option<String>,
|
||||
#[default(0)]
|
||||
pub(crate) category_sort_order: i32,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
@@ -96,6 +98,7 @@ 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 row = CreationEntryTypeConfig {
|
||||
id: id.clone(),
|
||||
title: input.title.trim().to_string(),
|
||||
@@ -109,6 +112,7 @@ fn upsert_creation_entry_type_config_in_tx(
|
||||
category_id: Some(normalize_category_id(&input.category_id)),
|
||||
category_label: Some(normalize_category_label(&input.category_label)),
|
||||
category_sort_order: input.category_sort_order,
|
||||
unified_creation_spec_json,
|
||||
};
|
||||
if ctx.db.creation_entry_type_config().id().find(&id).is_some() {
|
||||
ctx.db.creation_entry_type_config().id().update(row);
|
||||
@@ -145,6 +149,7 @@ fn get_or_seed_creation_entry_config_snapshot(
|
||||
category_label: normalize_optional_category_label(row.category_label.as_deref()),
|
||||
category_sort_order: row.category_sort_order,
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
unified_creation_spec_json: row.unified_creation_spec_json,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
creation_types.sort_by(|left, right| {
|
||||
@@ -404,10 +409,29 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
|
||||
category_id: Some(snapshot.category_id),
|
||||
category_label: Some(snapshot.category_label),
|
||||
category_sort_order: snapshot.category_sort_order,
|
||||
unified_creation_spec_json: snapshot.unified_creation_spec_json,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_unified_creation_spec_json(
|
||||
id: &str,
|
||||
input: &CreationEntryTypeAdminUpsertInput,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(spec_json) = input.unified_creation_spec_json.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let normalized = spec_json.trim();
|
||||
if normalized.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let spec =
|
||||
shared_contracts::creation_entry_config::decode_unified_creation_spec_response(normalized)?;
|
||||
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)
|
||||
}
|
||||
|
||||
fn normalize_category_id(value: &str) -> String {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
|
||||
@@ -95,7 +95,7 @@ pub struct VisualNovelWorkProfileRow {
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
pub(crate) published_at: Option<Timestamp>,
|
||||
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
|
||||
// 管理端可见性开关;默认显示,隐藏后不进入广场列表。
|
||||
#[default(WORK_VISIBLE_DEFAULT)]
|
||||
pub(crate) visible: bool,
|
||||
}
|
||||
@@ -171,9 +171,9 @@ pub struct VisualNovelRuntimeEvent {
|
||||
pub(crate) occurred_at: Timestamp,
|
||||
}
|
||||
|
||||
/// 视觉小说公开广场列表投影。
|
||||
/// 视觉小说广场列表投影。
|
||||
///
|
||||
/// 该 view 只暴露已发布作品卡片需要的公开字段,HTTP gallery 订阅后
|
||||
/// 该 view 只暴露已发布作品卡片需要的展示字段,HTTP gallery 缓存刷新后
|
||||
/// 从本地 cache 读取,避免每个列表请求调用 `list_visual_novel_works` procedure。
|
||||
#[spacetimedb::view(accessor = visual_novel_gallery_view, public)]
|
||||
pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec<VisualNovelGalleryViewRow> {
|
||||
|
||||
Reference in New Issue
Block a user