diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 3d8781ba..5f73294a 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -185,6 +185,7 @@ npm run check:server-rs-ddd - 移动端优先,再兼容网页端。 - 页面只展示后端返回的状态,不自行计算结论型业务状态。 +- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。 - 优先复用现有面板、抽屉、弹窗,不新建独立大系统。 - 不在 UI 中默认写功能说明类文本。 - 弹出独立面板的交互不要实现成在当前面板下方追加内容。 diff --git a/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md b/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md new file mode 100644 index 00000000..4568eb50 --- /dev/null +++ b/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md @@ -0,0 +1,113 @@ +# 创作入口配置数据库化与 Runtime 缺失作品返回首页 + +日期:2026-05-10 + +## 背景 + +前端创作中心原本把新建作品入口配置保存在前端代码中,导致入口是否展示、是否开放、卡片文案和 api-server 路由可用性无法使用同一份事实源控制。 + +同时,用户直接访问 `/runtime/<玩法>?work=<作品号>` 时,如果作品不存在,运行态会先弹出错误提示;但弹窗关闭后仍停留在无运行数据的页面,容易出现空白页。 + +## 设计结论 + +1. 创作入口配置事实源迁入 SpacetimeDB。 +2. 前端只通过 `GET /api/creation-entry/config` 读取配置并派生展示卡片。 +3. api-server 使用同一份配置对相关运行时路由做熔断。 +4. 直接 runtime 深链找不到作品时,弹窗确认后回到首页 `/`,避免停留在空白运行态。 + +## 数据模型 + +SpacetimeDB 新增两张表: + +- `creation_entry_config` + - 全局配置头,保存创作入口主卡片和类型选择弹窗文案。 +- `creation_entry_type_config` + - 每个玩法入口的展示与开关配置。 + - 关键字段: + - `id` + - `title` + - `subtitle` + - `badge` + - `image_src` + - `visible` + - `open` + - `sort_order` + +其中: + +- `visible=false`:前端隐藏入口。 +- `open=false`:前端展示为锁定/暂不可创建,api-server 也可据此熔断运行时入口。 +- `sort_order`:数据库排序字段,前端只做可见/锁定分组派生。 + +## API + +新增: + +```text +GET /api/creation-entry/config +``` + +返回 shared-contracts 中的 `CreationEntryConfigResponse`: + +```json +{ + "startCard": { + "title": "...", + "description": "...", + "idleBadge": "...", + "busyBadge": "..." + }, + "typeModal": { + "title": "...", + "description": "..." + }, + "creationTypes": [ + { + "id": "puzzle", + "title": "拼图", + "subtitle": "拼图关卡创作", + "badge": "可创建", + "imageSrc": "/creation-type-references/puzzle.webp", + "visible": true, + "open": true, + "sortOrder": 30, + "updatedAtMicros": 0 + } + ], + "updatedAtMicros": 0 +} +``` + +## 前端约束 + +- 禁止再新增 `src/config/newWorkEntryConfig.ts` 这类入口事实源。 +- 创作入口 UI 使用 `src/services/creationEntryConfigService.ts` 拉取后端配置。 +- `src/components/platform-entry/platformEntryCreationTypes.ts` 只保留展示派生: + - `visible -> hidden` + - `open -> locked` + - `sortOrder -> 初始顺序` +- 缺失作品的 runtime 深链恢复策略放在 `src/routing/runtimeNotFoundRecovery.ts`。 + +## Runtime 缺失作品恢复 + +当路径是以下任意 runtime 深链,并且作品读取/启动返回 404 或 NOT_FOUND: + +- `/runtime/puzzle` +- `/runtime/match3d` +- `/runtime/big-fish` +- `/runtime/square-hole` +- `/runtime/visual-novel` + +前端执行: + +1. 清理当前运行态选择和错误状态。 +2. 弹出“作品不存在或已下架,将返回首页。”。 +3. 跳转首页 `/`。 + +## 验证命令 + +```bash +npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/routing/runtimeNotFoundRecovery.test.ts +npm run typecheck +cd server-rs && cargo check -p api-server -p spacetime-module --no-default-features +``` diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 70c8739f..c8ad43b0 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -1864,6 +1864,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "shared-contracts", "shared-kernel", "spacetimedb", "time", diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 129a34ae..14c5d9b7 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -50,6 +50,9 @@ use crate::{ generate_character_visual, get_character_visual_job, publish_character_visual, }, creation_agent_document_input::parse_creation_agent_document_input, + creation_entry_config::{ + get_creation_entry_config_handler, require_creation_entry_route_enabled, + }, creative_agent::{ cancel_creative_agent_session, confirm_creative_puzzle_template, create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message, @@ -611,6 +614,10 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation-entry/config", + get(get_creation_entry_config_handler), + ) .route( "/api/runtime/settings", get(get_runtime_settings) @@ -1492,6 +1499,11 @@ pub fn build_router(state: AppState) -> Router { )), ) .route("/api/auth/password/reset", post(reset_password)) + // 后端 runtime/API 路由读取入口配置做统一熔断,避免前端隐藏后后端仍可被直接访问。 + .layer(middleware::from_fn_with_state( + state.clone(), + require_creation_entry_route_enabled, + )) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 @@ -1952,6 +1964,28 @@ mod tests { ); } + #[tokio::test] + async fn creation_entry_route_disabled_returns_service_unavailable() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state.set_test_creation_entry_route_enabled("puzzle", false); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/api/runtime/puzzle/works") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = read_json_response(response).await; + assert_eq!(body["error"]["details"]["reason"], "creation_entry_disabled"); + assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle"); + } + #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs new file mode 100644 index 00000000..11f86f81 --- /dev/null +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -0,0 +1,167 @@ +use axum::{ + Json, + body::Body, + extract::{Extension, State}, + http::{Request, StatusCode}, + middleware::Next, + response::Response, +}; +use serde_json::{Value, json}; + +use crate::{ + api_response::json_success_body, http_error::AppError, request_context::RequestContext, + state::AppState, +}; + +/// 中文注释:入口配置由 SpacetimeDB 表提供;api-server 只负责读取同一份配置并熔断运行态路由。 +pub async fn get_creation_entry_config_handler( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let config = state.get_creation_entry_config().await.map_err(|error| { + creation_entry_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })), + ) + })?; + + Ok(json_success_body(Some(&request_context), config)) +} + +/// 中文注释:api-server 路由熔断只拦运行态/API 请求,不改变前端入口展示规则。 +pub async fn require_creation_entry_route_enabled( + State(state): State, + request: Request, + next: Next, +) -> Response { + let path = request.uri().path(); + let route_id = resolve_creation_entry_route_id(path); + if route_id.is_some() { + let route_id = route_id.expect("route id should exist"); + match state.is_creation_entry_route_enabled(route_id).await { + Ok(true) => {} + Ok(false) => { + return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("该玩法入口暂不可用") + .with_details(json!({ + "reason": "creation_entry_disabled", + "creationTypeId": route_id, + })) + .into(); + } + Err(error) => { + return AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("读取玩法入口配置失败") + .with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + .into(); + } + } + } + + next.run(request).await +} + +pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { + let normalized = path.trim_end_matches('/'); + if normalized.starts_with("/api/runtime/puzzle") { + return Some("puzzle"); + } + if normalized.starts_with("/api/runtime/match3d") { + return Some("match3d"); + } + if normalized.starts_with("/api/runtime/square-hole") { + return Some("square-hole"); + } + if normalized.starts_with("/api/runtime/big-fish") { + return Some("big-fish"); + } + if normalized.starts_with("/api/runtime/visual-novel") { + return Some("visual-novel"); + } + None +} + +fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +#[cfg(test)] +pub(crate) fn test_creation_entry_config_response( +) -> shared_contracts::creation_entry_config::CreationEntryConfigResponse { + build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot { + config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(), + start_card: module_runtime::CreationEntryStartCardSnapshot { + title: module_runtime::DEFAULT_CREATION_ENTRY_START_TITLE.to_string(), + description: module_runtime::DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(), + idle_badge: module_runtime::DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(), + busy_badge: module_runtime::DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(), + }, + type_modal: module_runtime::CreationEntryTypeModalSnapshot { + title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), + description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), + }, + creation_types: vec![ + test_creation_type("rpg", false, true, 10), + test_creation_type("big-fish", false, true, 20), + test_creation_type("puzzle", true, true, 30), + test_creation_type("match3d", true, true, 40), + test_creation_type("square-hole", true, true, 50), + test_creation_type("visual-novel", true, true, 60), + test_creation_type("airp", true, false, 70), + test_creation_type("creative-agent", false, true, 80), + ], + updated_at_micros: 0, + }) +} + +#[cfg(test)] +fn test_creation_type( + id: &str, + visible: bool, + open: bool, + sort_order: i32, +) -> module_runtime::CreationEntryTypeSnapshot { + module_runtime::CreationEntryTypeSnapshot { + id: id.to_string(), + title: id.to_string(), + subtitle: "测试入口".to_string(), + badge: "测试".to_string(), + image_src: format!("/creation-type-references/{id}.webp"), + visible, + open, + sort_order, + updated_at_micros: 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_runtime_paths_to_creation_type_ids() { + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/puzzle/works"), + Some("puzzle"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/match3d/runs/run-1"), + Some("match3d"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/square-hole/runs/run-1"), + Some("square-hole"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/visual-novel/works"), + Some("visual-novel"), + ); + assert_eq!(resolve_creation_entry_route_id("/healthz"), None); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 88942aed..5f777f7b 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -23,6 +23,7 @@ mod creation_agent_anchor_templates; mod creation_agent_chat; mod creation_agent_document_input; mod creation_agent_llm_turn; +mod creation_entry_config; mod creative_agent; mod creative_agent_sse; mod custom_world; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 35b785ed..ab5366d5 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -11,6 +11,7 @@ use module_auth::{ RefreshSessionService, WechatAuthService, WechatAuthStateService, }; use module_runtime::RuntimeSnapshotRecord; +use shared_contracts::creation_entry_config::CreationEntryConfigResponse; #[cfg(test)] use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use platform_agent::MockLangChainRustAgentExecutor; @@ -41,6 +42,8 @@ pub struct AppState { auth_jwt_config: JwtConfig, admin_runtime: Option, refresh_cookie_config: RefreshCookieConfig, + #[cfg(test)] + test_creation_entry_config: Arc>>, oss_client: Option, #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, @@ -189,6 +192,10 @@ impl AppState { auth_jwt_config, admin_runtime, refresh_cookie_config, + #[cfg(test)] + test_creation_entry_config: Arc::new(Mutex::new(Some( + crate::creation_entry_config::test_creation_entry_config_response(), + ))), oss_client, auth_store, password_entry_service, @@ -221,6 +228,68 @@ impl AppState { &self.refresh_cookie_config } + pub async fn get_creation_entry_config( + &self, + ) -> Result { + match self.spacetime_client.get_creation_entry_config().await { + Ok(config) => { + #[cfg(test)] + self.cache_test_creation_entry_config(config.clone()); + Ok(config) + } + #[cfg(test)] + Err(_) => Ok(self.read_test_creation_entry_config()), + #[cfg(not(test))] + Err(error) => Err(error), + } + } + + pub async fn is_creation_entry_route_enabled( + &self, + creation_type_id: &str, + ) -> Result { + let config = self.get_creation_entry_config().await?; + Ok(config + .creation_types + .iter() + .find(|item| item.id == creation_type_id) + .map(|item| item.visible && item.open) + .unwrap_or(true)) + } + + #[cfg(test)] + pub(crate) fn set_test_creation_entry_route_enabled( + &self, + creation_type_id: impl AsRef, + enabled: bool, + ) { + let creation_type_id = creation_type_id.as_ref(); + let mut config = self.read_test_creation_entry_config(); + if let Some(item) = config + .creation_types + .iter_mut() + .find(|item| item.id == creation_type_id) + { + item.visible = enabled; + item.open = enabled; + } else { + config.creation_types.push( + shared_contracts::creation_entry_config::CreationEntryTypeResponse { + id: creation_type_id.to_string(), + title: creation_type_id.to_string(), + subtitle: String::new(), + badge: String::new(), + image_src: format!("/creation-type-references/{creation_type_id}.webp"), + visible: enabled, + open: enabled, + sort_order: i32::try_from(config.creation_types.len()).unwrap_or(i32::MAX), + updated_at_micros: 0, + }, + ); + } + self.cache_test_creation_entry_config(config); + } + pub fn oss_client(&self) -> Option<&OssClient> { self.oss_client.as_ref() } @@ -488,6 +557,21 @@ impl AppState { #[cfg(test)] impl AppState { + fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) { + *self + .test_creation_entry_config + .lock() + .expect("test creation entry config should lock") = Some(config); + } + + fn read_test_creation_entry_config(&self) -> CreationEntryConfigResponse { + self.test_creation_entry_config + .lock() + .expect("test creation entry config should lock") + .clone() + .unwrap_or_else(crate::creation_entry_config::test_creation_entry_config_response) + } + pub(crate) async fn seed_test_phone_user_with_password( &self, phone_number: &str, diff --git a/server-rs/crates/module-runtime/Cargo.toml b/server-rs/crates/module-runtime/Cargo.toml index 786ff327..fa54946a 100644 --- a/server-rs/crates/module-runtime/Cargo.toml +++ b/server-rs/crates/module-runtime/Cargo.toml @@ -11,6 +11,7 @@ spacetime-types = ["dep:spacetimedb"] [dependencies] serde = { workspace = true } serde_json = { workspace = true } +shared-contracts = { workspace = true } shared-kernel = { workspace = true } spacetimedb = { workspace = true, optional = true } time = { workspace = true, features = ["formatting", "parsing"] } diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 52e77ae8..f43e2e77 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -9,6 +9,42 @@ use std::collections::BTreeMap; use crate::domain::*; use crate::errors::RuntimeProfileFieldError; use crate::format_utc_micros; +use shared_contracts::creation_entry_config::{ + CreationEntryConfigResponse, CreationEntryStartCardResponse, CreationEntryTypeModalResponse, + CreationEntryTypeResponse, +}; + +pub fn build_creation_entry_config_response( + snapshot: CreationEntryConfigSnapshot, +) -> CreationEntryConfigResponse { + CreationEntryConfigResponse { + start_card: CreationEntryStartCardResponse { + title: snapshot.start_card.title, + description: snapshot.start_card.description, + idle_badge: snapshot.start_card.idle_badge, + busy_badge: snapshot.start_card.busy_badge, + }, + type_modal: CreationEntryTypeModalResponse { + title: snapshot.type_modal.title, + description: snapshot.type_modal.description, + }, + creation_types: snapshot + .creation_types + .into_iter() + .map(|item| CreationEntryTypeResponse { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + updated_at_micros: item.updated_at_micros, + }) + .collect(), + } +} pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord { RuntimeSettingsRecord { diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index c5eea622..29ef5c8a 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -40,6 +40,62 @@ pub const PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT: usize = 4; pub const PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES: u64 = 1_048_576; pub const PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES: u64 = 4_194_304; +pub const CREATION_ENTRY_CONFIG_GLOBAL_ID: &str = "platform_creation_center"; +pub const DEFAULT_CREATION_ENTRY_START_TITLE: &str = "新建作品"; +pub const DEFAULT_CREATION_ENTRY_START_DESCRIPTION: &str = "选择模板后进入对应的创作表单。"; +pub const DEFAULT_CREATION_ENTRY_START_IDLE_BADGE: &str = "模板 Tab"; +pub const DEFAULT_CREATION_ENTRY_START_BUSY_BADGE: &str = "正在开启"; +pub const DEFAULT_CREATION_ENTRY_MODAL_TITLE: &str = "选择创作类型"; +pub const DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION: &str = "先选玩法类型,再进入对应创作工作台。"; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreationEntryStartCardSnapshot { + pub title: String, + pub description: String, + pub idle_badge: String, + pub busy_badge: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreationEntryTypeModalSnapshot { + pub title: String, + pub description: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreationEntryTypeSnapshot { + pub id: String, + pub title: String, + pub subtitle: String, + pub badge: String, + pub image_src: String, + pub visible: bool, + pub open: bool, + pub sort_order: i32, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreationEntryConfigSnapshot { + pub config_id: String, + pub start_card: CreationEntryStartCardSnapshot, + pub type_modal: CreationEntryTypeModalSnapshot, + pub creation_types: Vec, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreationEntryConfigProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + /// 分析日期维表的纯领域快照。 /// /// date_key 沿用现有北京时间自然日桶:floor((occurred_at_micros + 8h) / 1d)。 diff --git a/server-rs/crates/shared-contracts/src/creation_entry_config.rs b/server-rs/crates/shared-contracts/src/creation_entry_config.rs new file mode 100644 index 00000000..1cebef29 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/creation_entry_config.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreationEntryConfigResponse { + pub start_card: CreationEntryStartCardResponse, + pub type_modal: CreationEntryTypeModalResponse, + pub creation_types: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreationEntryStartCardResponse { + pub title: String, + pub description: String, + pub idle_badge: String, + pub busy_badge: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreationEntryTypeModalResponse { + pub title: String, + pub description: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreationEntryTypeResponse { + pub id: String, + pub title: String, + pub subtitle: String, + pub badge: String, + pub image_src: String, + pub visible: bool, + pub open: bool, + pub sort_order: i32, + pub updated_at_micros: i64, +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 49a4da77..30613c10 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -7,6 +7,7 @@ pub mod big_fish; pub mod big_fish_works; pub mod creation_agent_document_input; pub mod creative_agent; +pub mod creation_entry_config; pub mod hyper3d; pub mod llm; pub mod match3d_agent; diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 19629621..1c092b08 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -14,7 +14,8 @@ pub use mapper::{ BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput, BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record, - BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, + BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CreationEntryConfigRecord, + CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index b2dfd9fb..b08c7531 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -705,6 +705,58 @@ pub(crate) fn map_asset_history_list_result( .collect()) } +pub type CreationEntryConfigRecord = shared_contracts::creation_entry_config::CreationEntryConfigResponse; + +pub(crate) fn map_creation_entry_config_procedure_result( + result: CreationEntryConfigProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("创作入口配置快照"))?; + + Ok(module_runtime::build_creation_entry_config_response( + map_creation_entry_config_snapshot(snapshot), + )) +} + +fn map_creation_entry_config_snapshot( + snapshot: CreationEntryConfigSnapshot, +) -> module_runtime::CreationEntryConfigSnapshot { + module_runtime::CreationEntryConfigSnapshot { + config_id: snapshot.config_id, + start_card: module_runtime::CreationEntryStartCardSnapshot { + title: snapshot.start_card.title, + description: snapshot.start_card.description, + idle_badge: snapshot.start_card.idle_badge, + busy_badge: snapshot.start_card.busy_badge, + }, + type_modal: module_runtime::CreationEntryTypeModalSnapshot { + title: snapshot.type_modal.title, + description: snapshot.type_modal.description, + }, + creation_types: snapshot + .creation_types + .into_iter() + .map(|item| module_runtime::CreationEntryTypeSnapshot { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + updated_at_micros: item.updated_at_micros, + }) + .collect(), + updated_at_micros: snapshot.updated_at_micros, + } +} + pub(crate) fn map_runtime_setting_procedure_result( result: RuntimeSettingProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_procedure_result_type.rs new file mode 100644 index 00000000..da8cf6cb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::creation_entry_config_snapshot_type::CreationEntryConfigSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryConfigProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for CreationEntryConfigProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs new file mode 100644 index 00000000..90e59621 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; +use super::creation_entry_type_config_snapshot_type::CreationEntryTypeSnapshot; +use super::creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryConfigSnapshot { + pub config_id: String, + pub start_card: CreationEntryStartCardSnapshot, + pub type_modal: CreationEntryTypeModalSnapshot, + pub creation_types: Vec, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CreationEntryConfigSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_start_card_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_start_card_snapshot_type.rs new file mode 100644 index 00000000..4ab071ad --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_start_card_snapshot_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryStartCardSnapshot { + pub title: String, + pub description: String, + pub idle_badge: String, + pub busy_badge: String, +} + +impl __sdk::InModule for CreationEntryStartCardSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_snapshot_type.rs new file mode 100644 index 00000000..814edd78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_snapshot_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryTypeSnapshot { + pub id: String, + pub title: String, + pub subtitle: String, + pub badge: String, + pub image_src: String, + pub visible: bool, + pub open: bool, + pub sort_order: i32, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CreationEntryTypeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_modal_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_modal_snapshot_type.rs new file mode 100644 index 00000000..e74cec15 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_modal_snapshot_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryTypeModalSnapshot { + pub title: String, + pub description: String, +} + +impl __sdk::InModule for CreationEntryTypeModalSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_creation_entry_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_creation_entry_config_procedure.rs new file mode 100644 index 00000000..ef41e73c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_creation_entry_config_procedure.rs @@ -0,0 +1,52 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetCreationEntryConfigArgs {} + +impl __sdk::InModule for GetCreationEntryConfigArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_creation_entry_config`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_creation_entry_config { + fn get_creation_entry_config(&self) { + self.get_creation_entry_config_then(|_, _| {}); + } + + fn get_creation_entry_config_then( + &self, + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_creation_entry_config for super::RemoteProcedures { + fn get_creation_entry_config_then( + &self, + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreationEntryConfigProcedureResult>( + "get_creation_entry_config", + GetCreationEntryConfigArgs {}, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index b290d456..70741685 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -249,6 +249,11 @@ pub mod custom_world_theme_mode_type; pub mod custom_world_work_summary_snapshot_type; pub mod custom_world_works_list_input_type; pub mod custom_world_works_list_result_type; +pub mod creation_entry_config_procedure_result_type; +pub mod creation_entry_config_snapshot_type; +pub mod creation_entry_start_card_snapshot_type; +pub mod creation_entry_type_config_snapshot_type; +pub mod creation_entry_type_modal_snapshot_type; pub mod database_migration_authorize_operator_input_type; pub mod database_migration_export_input_type; pub mod database_migration_import_chunk_input_type; @@ -290,6 +295,7 @@ pub mod finish_match_3_d_time_up_procedure; pub mod finish_square_hole_time_up_procedure; pub mod generate_big_fish_asset_procedure; pub mod get_auth_store_snapshot_procedure; +pub mod get_creation_entry_config_procedure; pub mod get_battle_state_procedure; pub mod get_big_fish_run_procedure; pub mod get_big_fish_session_procedure; @@ -1045,6 +1051,11 @@ pub use custom_world_theme_mode_type::CustomWorldThemeMode; pub use custom_world_work_summary_snapshot_type::CustomWorldWorkSummarySnapshot; pub use custom_world_works_list_input_type::CustomWorldWorksListInput; pub use custom_world_works_list_result_type::CustomWorldWorksListResult; +pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult; +pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot; +pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; +pub use creation_entry_type_config_snapshot_type::CreationEntryTypeSnapshot; +pub use creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot; pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput; pub use database_migration_export_input_type::DatabaseMigrationExportInput; pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; @@ -1086,6 +1097,7 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up; pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot; +pub use get_creation_entry_config_procedure::get_creation_entry_config; pub use get_battle_state_procedure::get_battle_state; pub use get_big_fish_run_procedure::get_big_fish_run; pub use get_big_fish_session_procedure::get_big_fish_session; diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index b21a87db..42703dfe 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -1,6 +1,22 @@ use super::*; impl SpacetimeClient { + pub async fn get_creation_entry_config( + &self, + ) -> Result { + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_creation_entry_config_then(move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_creation_entry_config_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_runtime_settings( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index f6e5d033..2d9a9abb 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1,4 +1,5 @@ use crate::runtime::analytics_date_dimension::analytics_date_dimension; +use crate::runtime::creation_entry_config::{creation_entry_config, creation_entry_type_config}; use crate::*; use serde::{Deserialize, Serialize}; use spacetimedb::sats::de::serde::DeserializeWrapper; @@ -167,6 +168,8 @@ macro_rules! migration_tables { ai_task_event, runtime_snapshot, runtime_setting, + creation_entry_config, + creation_entry_type_config, user_browse_history, profile_dashboard_state, profile_wallet_ledger, diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs new file mode 100644 index 00000000..a23ad025 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -0,0 +1,170 @@ +use crate::*; + +#[spacetimedb::table(accessor = creation_entry_config)] +pub struct CreationEntryConfig { + #[primary_key] + pub(crate) config_id: String, + pub(crate) start_title: String, + pub(crate) start_description: String, + pub(crate) start_idle_badge: String, + pub(crate) start_busy_badge: String, + pub(crate) modal_title: String, + pub(crate) modal_description: String, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = creation_entry_type_config, + index(accessor = by_creation_entry_type_sort_order, btree(columns = [sort_order])) +)] +pub struct CreationEntryTypeConfig { + #[primary_key] + pub(crate) id: String, + pub(crate) title: String, + pub(crate) subtitle: String, + pub(crate) badge: String, + pub(crate) image_src: String, + pub(crate) visible: bool, + pub(crate) open: bool, + pub(crate) sort_order: i32, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::procedure] +pub fn get_creation_entry_config( + ctx: &mut ProcedureContext, +) -> CreationEntryConfigProcedureResult { + match ctx.try_with_tx(|tx| get_or_seed_creation_entry_config_snapshot(tx)) { + Ok(record) => CreationEntryConfigProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => CreationEntryConfigProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +fn get_or_seed_creation_entry_config_snapshot( + ctx: &ReducerContext, +) -> Result { + seed_creation_entry_config_if_missing(ctx); + let header = ctx + .db + .creation_entry_config() + .config_id() + .find(CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string()) + .ok_or_else(|| "创作入口配置初始化失败".to_string())?; + let mut creation_types = ctx + .db + .creation_entry_type_config() + .iter() + .map(|row| CreationEntryTypeSnapshot { + id: row.id, + title: row.title, + subtitle: row.subtitle, + badge: row.badge, + image_src: row.image_src, + visible: row.visible, + open: row.open, + sort_order: row.sort_order, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) + .collect::>(); + creation_types.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.id.cmp(&right.id)) + }); + + Ok(CreationEntryConfigSnapshot { + config_id: header.config_id, + start_card: CreationEntryStartCardSnapshot { + title: header.start_title, + description: header.start_description, + idle_badge: header.start_idle_badge, + busy_badge: header.start_busy_badge, + }, + type_modal: CreationEntryTypeModalSnapshot { + title: header.modal_title, + description: header.modal_description, + }, + creation_types, + updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), + }) +} + +fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { + let now = ctx.timestamp; + if ctx + .db + .creation_entry_config() + .config_id() + .find(CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string()) + .is_none() + { + ctx.db.creation_entry_config().insert(CreationEntryConfig { + config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(), + start_title: DEFAULT_CREATION_ENTRY_START_TITLE.to_string(), + start_description: DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(), + start_idle_badge: DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(), + start_busy_badge: DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(), + modal_title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), + modal_description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), + updated_at: now, + }); + } + + for seed in default_creation_entry_type_configs(now) { + if ctx + .db + .creation_entry_type_config() + .id() + .find(&seed.id) + .is_none() + { + ctx.db.creation_entry_type_config().insert(seed); + } + } +} + +fn default_creation_entry_type_configs(now: Timestamp) -> Vec { + vec![ + build_creation_entry_type_seed("rpg", "文字冒险", "经典 RPG 体验", "内测", "/creation-type-references/rpg.webp", false, true, 10, now), + build_creation_entry_type_seed("big-fish", "摸鱼", "轻量闯关玩法", "可创建", "/creation-type-references/big-fish.webp", false, true, 20, now), + build_creation_entry_type_seed("puzzle", "拼图", "拼图关卡创作", "可创建", "/creation-type-references/puzzle.webp", true, true, 30, now), + build_creation_entry_type_seed("match3d", "抓大鹅", "3D 消除关卡", "可创建", "/creation-type-references/match3d.webp", true, true, 40, now), + build_creation_entry_type_seed("square-hole", "方洞", "形状投放挑战", "可创建", "/creation-type-references/square-hole.webp", true, true, 50, now), + build_creation_entry_type_seed("visual-novel", "视觉小说", "分支叙事体验", "可创建", "/creation-type-references/visual-novel.webp", true, true, 60, now), + build_creation_entry_type_seed("airp", "AI RPG", "原生角色扮演", "即将开放", "/creation-type-references/airp.webp", true, false, 70, now), + build_creation_entry_type_seed("creative-agent", "智能体创作", "对话式创作实验", "内测", "/creation-type-references/creative-agent.webp", false, true, 80, now), + ] +} + +#[allow(clippy::too_many_arguments)] +fn build_creation_entry_type_seed( + id: &str, + title: &str, + subtitle: &str, + badge: &str, + image_src: &str, + visible: bool, + open: bool, + sort_order: i32, + now: Timestamp, +) -> CreationEntryTypeConfig { + CreationEntryTypeConfig { + id: id.to_string(), + title: title.to_string(), + subtitle: subtitle.to_string(), + badge: badge.to_string(), + image_src: image_src.to_string(), + visible, + open, + sort_order, + updated_at: now, + } +} diff --git a/server-rs/crates/spacetime-module/src/runtime/mod.rs b/server-rs/crates/spacetime-module/src/runtime/mod.rs index f1bbd8e2..52386257 100644 --- a/server-rs/crates/spacetime-module/src/runtime/mod.rs +++ b/server-rs/crates/spacetime-module/src/runtime/mod.rs @@ -1,10 +1,12 @@ pub mod analytics_date_dimension; +pub mod creation_entry_config; mod browse_history; mod profile; mod settings; mod snapshots; pub use analytics_date_dimension::*; +pub use creation_entry_config::*; pub use browse_history::*; pub use profile::*; pub use settings::*; diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 214f7be2..3b159df9 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -1,13 +1,88 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes'; +import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { CustomWorldCreationHub } from './CustomWorldCreationHub'; const noopCreateType = () => {}; + +const testEntryConfig = { + startCard: { + title: '新建作品', + description: '选择模板后进入对应的创作表单。', + idleBadge: '模板 Tab', + busyBadge: '正在开启', + }, + typeModal: { + title: '选择创作类型', + description: '先选玩法类型,再进入对应创作工作台。', + }, + creationTypes: [ + { + id: 'puzzle', + title: '拼图', + subtitle: '拼图关卡创作', + badge: '可创建', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 30, + updatedAtMicros: 1, + }, + { + id: 'match3d', + title: '抓大鹅', + subtitle: '3D 消除关卡', + badge: '可创建', + imageSrc: '/creation-type-references/match3d.webp', + visible: true, + open: true, + sortOrder: 40, + updatedAtMicros: 1, + }, + { + id: 'square-hole', + title: '方洞', + subtitle: '形状投放挑战', + badge: '可创建', + imageSrc: '/creation-type-references/square-hole.webp', + visible: true, + open: true, + sortOrder: 50, + updatedAtMicros: 1, + }, + { + id: 'visual-novel', + title: '视觉小说', + subtitle: '分支叙事体验', + badge: '可创建', + imageSrc: '/creation-type-references/visual-novel.webp', + visible: true, + open: true, + sortOrder: 60, + updatedAtMicros: 1, + }, + { + id: 'airp', + title: 'AI RPG', + subtitle: '原生角色扮演', + badge: '即将开放', + imageSrc: '/creation-type-references/airp.webp', + visible: true, + open: false, + sortOrder: 70, + updatedAtMicros: 1, + }, + ], +} satisfies CreationEntryConfig; + +const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes); + const originalClipboard = navigator.clipboard; afterEach(() => { @@ -58,6 +133,8 @@ test('creation hub shows published metric growth from cached page snapshot', asy onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} onOpenPuzzleDetail={() => {}} />, ); @@ -99,6 +176,8 @@ test('creation hub reflects updated draft title summary and counts after rerende onCreateType={onCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); @@ -142,6 +221,8 @@ test('creation hub reflects updated draft title summary and counts after rerende onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); @@ -182,6 +263,8 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} onOpenPuzzleDetail={() => {}} />, ); @@ -238,6 +321,8 @@ test('creation hub shows puzzle point incentive and claims without opening card' onEnterPublished={() => {}} onOpenPuzzleDetail={onOpenPuzzleDetail} onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); @@ -296,6 +381,8 @@ test('creation hub shows RPG public work code from published library entry', () onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); @@ -318,6 +405,8 @@ test('creation hub shows delete action for persisted rpg drafts', () => { onOpenDraft={() => {}} onEnterPublished={() => {}} onDeletePublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); @@ -359,6 +448,8 @@ test('creation hub published work delete action is available beside share withou onEnterPublished={() => {}} onOpenPuzzleDetail={onOpenPuzzleDetail} onDeletePuzzle={onDeletePuzzle} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); @@ -396,6 +487,8 @@ test('creation hub opens persisted rpg drafts by card click', async () => { openedItems.push(item); }} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); @@ -444,6 +537,8 @@ test('creation hub published share button copies share text without opening the onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenPuzzleDetail={onOpenPuzzleDetail} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index af4c4eb9..278e1c14 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -1,10 +1,85 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { expect, test } from 'vitest'; +import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes'; +import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { CustomWorldCreationHub } from './CustomWorldCreationHub'; const noopCreateType = () => {}; +const testEntryConfig = { + startCard: { + title: '新建作品', + description: '选择模板后进入对应的创作表单。', + idleBadge: '模板 Tab', + busyBadge: '正在开启', + }, + typeModal: { + title: '选择创作类型', + description: '先选玩法类型,再进入对应创作工作台。', + }, + creationTypes: [ + { + id: 'puzzle', + title: '拼图', + subtitle: '拼图关卡创作', + badge: '可创建', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 30, + updatedAtMicros: 1, + }, + { + id: 'match3d', + title: '抓大鹅', + subtitle: '3D 消除关卡', + badge: '可创建', + imageSrc: '/creation-type-references/match3d.webp', + visible: true, + open: true, + sortOrder: 40, + updatedAtMicros: 1, + }, + { + id: 'square-hole', + title: '方洞', + subtitle: '形状投放挑战', + badge: '可创建', + imageSrc: '/creation-type-references/square-hole.webp', + visible: true, + open: true, + sortOrder: 50, + updatedAtMicros: 1, + }, + { + id: 'visual-novel', + title: '视觉小说', + subtitle: '分支叙事体验', + badge: '可创建', + imageSrc: '/creation-type-references/visual-novel.webp', + visible: true, + open: true, + sortOrder: 60, + updatedAtMicros: 1, + }, + { + id: 'airp', + title: 'AI RPG', + subtitle: '原生角色扮演', + badge: '即将开放', + imageSrc: '/creation-type-references/airp.webp', + visible: true, + open: false, + sortOrder: 70, + updatedAtMicros: 1, + }, + ], +} satisfies CreationEntryConfig; + +const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes); + + test('creation hub draft card renders compiled work summary fields', () => { const html = renderToStaticMarkup( { onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} />, ); @@ -79,6 +156,8 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} onOpenPuzzleDetail={() => {}} />, ); @@ -123,6 +202,8 @@ test('creation hub published work spans full mobile row', () => { onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} onOpenPuzzleDetail={() => {}} />, ); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 08da1b50..87a338a4 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -8,7 +8,11 @@ import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contr import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { CustomWorldProfile } from '../../types'; -import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes'; +import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; +import type { + PlatformCreationTypeCard, + PlatformCreationTypeId, +} from '../platform-entry/platformEntryCreationTypes'; import { buildCreationWorkShelfItems, type CreationWorkShelfItem, @@ -38,6 +42,8 @@ type CustomWorldCreationHubProps = { onRetry: () => void; createError?: string | null; createBusy?: boolean; + entryConfig: CreationEntryConfig; + creationTypes: readonly PlatformCreationTypeCard[]; onCreateType: (type: PlatformCreationTypeId) => void; onOpenDraft: (item: CustomWorldWorkSummary) => void; onEnterPublished: (profileId: string) => void; @@ -134,6 +140,8 @@ export function CustomWorldCreationHub({ onRetry, createError = null, createBusy = false, + entryConfig, + creationTypes, onCreateType, onOpenDraft, onEnterPublished, @@ -309,6 +317,8 @@ export function CustomWorldCreationHub({ ) : null} diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index 8bcd3c37..5c6aaedc 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -1,25 +1,30 @@ import { ArrowRight } from 'lucide-react'; -import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig'; +import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { getVisiblePlatformCreationTypes, + type PlatformCreationTypeCard, type PlatformCreationTypeId, } from '../platform-entry/platformEntryCreationTypes'; type CustomWorldCreationStartCardProps = { busy?: boolean; error?: string | null; + entryConfig: CreationEntryConfig; + creationTypes: readonly PlatformCreationTypeCard[]; onCreateType: (type: PlatformCreationTypeId) => void; }; export function CustomWorldCreationStartCard({ busy = false, error = null, + entryConfig, + creationTypes, onCreateType, }: CustomWorldCreationStartCardProps) { // 创作首页首屏卡带与创作类型弹层保持同一份展示口径, // 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。 - const visibleCreationTypes = getVisiblePlatformCreationTypes(); + const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes); return ( // 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。 @@ -28,15 +33,15 @@ export function CustomWorldCreationStartCard({
- {NEW_WORK_ENTRY_CONFIG.startCard.title} + {entryConfig.startCard.title}
- {NEW_WORK_ENTRY_CONFIG.startCard.description} + {entryConfig.startCard.description}
{busy - ? NEW_WORK_ENTRY_CONFIG.startCard.busyBadge - : NEW_WORK_ENTRY_CONFIG.startCard.idleBadge} + ? entryConfig.startCard.busyBadge + : entryConfig.startCard.idleBadge}
diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 8329d344..56312543 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -1,13 +1,18 @@ import { ArrowRight } from 'lucide-react'; -import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig'; +import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { UnifiedModal } from '../common/UnifiedModal'; -import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes'; +import { + getVisiblePlatformCreationTypes, + type PlatformCreationTypeCard, +} from './platformEntryCreationTypes'; export interface PlatformEntryCreationTypeModalProps { isOpen: boolean; isBusy: boolean; error: string | null; + entryConfig: CreationEntryConfig; + creationTypes: readonly PlatformCreationTypeCard[]; onClose: () => void; onSelectRpg: () => void; onSelectBigFish: () => void; @@ -86,6 +91,8 @@ export function PlatformEntryCreationTypeModal({ isOpen, isBusy, error, + entryConfig, + creationTypes, onClose, onSelectRpg, onSelectBigFish, @@ -101,13 +108,13 @@ export function PlatformEntryCreationTypeModal({ // 平台入口只渲染当前允许展示的创作类型; // 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。 - const visibleCreationTypes = getVisiblePlatformCreationTypes(); + const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes); return ( (null); const [publishSharePayload, setPublishSharePayload] = useState(null); - const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish'); + const [creationEntryConfig, setCreationEntryConfig] = + useState(null); + const [creationEntryConfigError, setCreationEntryConfigError] = useState< + string | null + >(null); + const creationEntryTypes = useMemo( + () => + creationEntryConfig + ? derivePlatformCreationTypes(creationEntryConfig.creationTypes) + : [], + [creationEntryConfig], + ); + const isBigFishCreationVisible = isPlatformCreationTypeVisible( + creationEntryTypes, + 'big-fish', + ); const [profilePlayStats, setProfilePlayStats] = useState(null); const [profilePlayStatsError, setProfilePlayStatsError] = useState< @@ -1695,6 +1734,27 @@ export function PlatformEntryFlowShellImpl({ ); const handledInitialPublicWorkCodeRef = useRef(null); + useEffect(() => { + let cancelled = false; + setCreationEntryConfigError(null); + void fetchCreationEntryConfig() + .then((config) => { + if (!cancelled) { + setCreationEntryConfig(config); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setCreationEntryConfigError( + error instanceof Error ? error.message : '读取创作入口配置失败。', + ); + } + }); + return () => { + cancelled = true; + }; + }, []); + const platformBootstrap = usePlatformEntryBootstrap({ user: authUi?.user, canAccessProtectedData: authUi?.canAccessProtectedData, @@ -4092,7 +4152,9 @@ export function PlatformEntryFlowShellImpl({ setPublicWorkDetailError(null); setPlatformTab('home'); setSelectionStage('platform'); - pushAppHistoryPath('/'); + if (!maybeAlertRuntimeNotFoundAndReturnHome()) { + pushAppHistoryPath('/'); + } return false; } @@ -5590,7 +5652,9 @@ export function PlatformEntryFlowShellImpl({ setPublicWorkDetailError(null); setPlatformTab('home'); setSelectionStage('platform'); - pushAppHistoryPath('/'); + if (!maybeAlertRuntimeNotFoundAndReturnHome()) { + pushAppHistoryPath('/'); + } return; } @@ -5809,7 +5873,9 @@ export function PlatformEntryFlowShellImpl({ setPublicWorkDetailError(null); setPlatformTab('home'); setSelectionStage('platform'); - pushAppHistoryPath('/'); + if (!maybeAlertRuntimeNotFoundAndReturnHome()) { + pushAppHistoryPath('/'); + } return; } @@ -7380,7 +7446,8 @@ export function PlatformEntryFlowShellImpl({ fallbackLabel: string, ) => ( }> - { platformBootstrap.setPlatformError(null); + setCreationEntryConfigError(null); + void fetchCreationEntryConfig() + .then(setCreationEntryConfig) + .catch((error: unknown) => { + setCreationEntryConfigError( + error instanceof Error ? error.message : '读取创作入口配置失败。', + ); + }); setBigFishError(null); setMatch3DError(null); setSquareHoleError(null); @@ -7431,6 +7506,7 @@ export function PlatformEntryFlowShellImpl({ void refreshVisualNovelShelf(); }} createError={ + creationEntryConfigError ?? sessionController.creationTypeError ?? bigFishError ?? match3dError ?? @@ -7440,6 +7516,7 @@ export function PlatformEntryFlowShellImpl({ visualNovelError } createBusy={ + !creationEntryConfig || sessionController.isCreatingAgentSession || isCreativeAgentBusy || isCreativeAgentStreaming || @@ -7450,6 +7527,8 @@ export function PlatformEntryFlowShellImpl({ isVisualNovelBusy || isVisualNovelStreamingReply } + entryConfig={creationEntryConfig} + creationTypes={creationEntryTypes} onCreateType={handleCreationHubCreateType} onOpenDraft={(item) => { runProtectedAction(() => { @@ -7530,6 +7609,7 @@ export function PlatformEntryFlowShellImpl({ handleDeleteVisualNovelWork(item); }} /> + ) : null} ); const creationStartContent = ( @@ -7541,7 +7621,7 @@ export function PlatformEntryFlowShellImpl({ role="tablist" aria-label="选择模板" > - {getVisiblePlatformCreationTypes().map((item) => { + {getVisiblePlatformCreationTypes(creationEntryTypes).map((item) => { const selected = item.id === 'puzzle'; const disabled = item.locked || @@ -9117,7 +9197,8 @@ export function PlatformEntryFlowShellImpl({ )} - { if ( sessionController.isCreatingAgentSession || @@ -9192,6 +9276,7 @@ export function PlatformEntryFlowShellImpl({ }); }} /> + ) : null} { - const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find( - (item) => item.id === 'puzzle', - ); - const match3dConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find( - (item) => item.id === 'match3d', - ); - - expect(puzzleConfig).toBeTruthy(); - expect(match3dConfig).toBeTruthy(); - expect(PLATFORM_CREATION_TYPES).toContainEqual( - expect.objectContaining({ +test('database entry config controls visibility open state and display order', () => { + const cards = derivePlatformCreationTypes([ + { id: 'puzzle', - title: puzzleConfig?.title, - subtitle: puzzleConfig?.subtitle, - badge: puzzleConfig?.badge, - imageSrc: puzzleConfig?.imageSrc, - locked: false, - hidden: false, - }), - ); - expect(PLATFORM_CREATION_TYPES).toContainEqual( + title: '数据库拼图', + subtitle: '由服务端配置覆盖', + badge: '维护中', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: false, + sortOrder: 30, + updatedAtMicros: 1, + }, + { + id: 'match3d', + title: '抓大鹅', + subtitle: '数据库开放', + badge: '可创建', + imageSrc: '/creation-type-references/match3d.webp', + visible: true, + open: true, + sortOrder: 20, + updatedAtMicros: 1, + }, + { + id: 'square-hole', + title: '方洞挑战', + subtitle: '临时隐藏', + badge: '可创建', + imageSrc: '/creation-type-references/square-hole.webp', + visible: false, + open: true, + sortOrder: 10, + updatedAtMicros: 1, + }, + ]); + + expect(cards).toEqual([ expect.objectContaining({ id: 'match3d', - title: match3dConfig?.title, - subtitle: match3dConfig?.subtitle, - badge: match3dConfig?.badge, - imageSrc: match3dConfig?.imageSrc, locked: false, hidden: false, }), - ); -}); - -test('every platform creation type has a generated reference image', () => { - expect( - NEW_WORK_ENTRY_CONFIG.creationTypes.every((item) => - item.imageSrc.startsWith('/creation-type-references/'), - ), - ).toBe(true); -}); - -test('new work entry config controls visibility and open order', () => { - const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id); - - expect(isPlatformCreationTypeVisible('rpg')).toBe(false); - expect(isPlatformCreationTypeVisible('big-fish')).toBe(false); - expect(isPlatformCreationTypeVisible('match3d')).toBe(true); - expect(isPlatformCreationTypeVisible('creative-agent')).toBe(false); - expect(visibleIds).not.toContain('rpg'); - expect(visibleIds).not.toContain('big-fish'); - expect(visibleIds).not.toContain('creative-agent'); - expect(visibleIds).toEqual([ - 'puzzle', - 'match3d', - 'square-hole', - 'visual-novel', - 'airp', + expect.objectContaining({ + id: 'square-hole', + locked: false, + hidden: true, + }), + expect.objectContaining({ + id: 'puzzle', + title: '数据库拼图', + locked: true, + hidden: false, + }), + ]); +}); + +test('visible platform creation types hide invisible cards and put locked cards last', () => { + const cards = derivePlatformCreationTypes([ + { + id: 'hidden', + title: '隐藏', + subtitle: '隐藏', + badge: '隐藏', + imageSrc: '/creation-type-references/hidden.webp', + visible: false, + open: true, + sortOrder: 1, + updatedAtMicros: 1, + }, + { + id: 'locked', + title: '锁定', + subtitle: '锁定', + badge: '即将开放', + imageSrc: '/creation-type-references/locked.webp', + visible: true, + open: false, + sortOrder: 2, + updatedAtMicros: 1, + }, + { + id: 'open', + title: '开放', + subtitle: '开放', + badge: '可创建', + imageSrc: '/creation-type-references/open.webp', + visible: true, + open: true, + sortOrder: 3, + updatedAtMicros: 1, + }, + ]); + + expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual([ + 'open', + 'locked', ]); }); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index 067988b8..0bad838b 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -1,9 +1,6 @@ -import { - NEW_WORK_ENTRY_CONFIG, - type NewWorkEntryCreationTypeId, -} from '../../config/newWorkEntryConfig'; +import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService'; -export type PlatformCreationTypeId = NewWorkEntryCreationTypeId; +export type PlatformCreationTypeId = string; export type PlatformCreationTypeCard = { id: PlatformCreationTypeId; @@ -15,40 +12,46 @@ export type PlatformCreationTypeCard = { hidden?: boolean; }; -/** - * 返回当前平台入口允许展示的创作类型。 - * 平台层的入口、首屏卡带与初始化请求都应基于这份结果统一判断。 - */ -export function getVisiblePlatformCreationTypes() { - const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter( - (item) => !item.hidden, - ); +export function getVisiblePlatformCreationTypes( + creationTypes: readonly PlatformCreationTypeCard[], +) { + const visibleCreationTypes = creationTypes.filter((item) => !item.hidden); - // 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用配置顺序。 + // 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用数据库排序。 return [ ...visibleCreationTypes.filter((item) => !item.locked), ...visibleCreationTypes.filter((item) => item.locked), ]; } -/** - * 判断某个创作类型当前是否仍暴露在平台入口中。 - */ -export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) { - return PLATFORM_CREATION_TYPES.some((item) => item.id === id && !item.hidden); +export function isPlatformCreationTypeVisible( + creationTypes: readonly PlatformCreationTypeCard[], + id: PlatformCreationTypeId, +) { + return creationTypes.some((item) => item.id === id && !item.hidden); } /** - * 创作页与类型弹层共用同一份新建作品入口配置,避免多入口文案和开放状态漂移。 - * `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。 + * 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB,前端不再保留入口默认配置。 */ -export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = - NEW_WORK_ENTRY_CONFIG.creationTypes.map((item) => ({ - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - imageSrc: item.imageSrc, - locked: !item.open, - hidden: !item.visible, - })); +export function derivePlatformCreationTypes( + creationTypes: readonly CreationEntryTypeConfig[], +): PlatformCreationTypeCard[] { + const orderedCards = [...creationTypes] + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((item) => ({ + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + imageSrc: item.imageSrc, + locked: !item.open, + hidden: !item.visible, + })); + + return [ + ...orderedCards.filter((item) => !item.hidden && !item.locked), + ...orderedCards.filter((item) => item.hidden), + ...orderedCards.filter((item) => !item.hidden && item.locked), + ]; +} diff --git a/src/config/newWorkEntryConfig.ts b/src/config/newWorkEntryConfig.ts deleted file mode 100644 index 95c1a337..00000000 --- a/src/config/newWorkEntryConfig.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * 新建作品入口配置。 - * 修改入口开放状态、隐藏状态和展示文案时,优先调整本文件,避免多入口文案漂移。 - */ -export const NEW_WORK_ENTRY_CONFIG = { - startCard: { - title: '新建作品', - description: '选择模板后进入对应的创作表单。', - idleBadge: '模板 Tab', - busyBadge: '正在开启', - }, - typeModal: { - title: '选择创作类型', - description: '先选玩法类型,再进入对应创作工作台。', - }, - creationTypes: [ - { - id: 'rpg', - title: '角色扮演', - subtitle: '敬请期待', - badge: '敬请期待', - imageSrc: '/creation-type-references/rpg.webp', - visible: false, - open: true, - }, - { - id: 'big-fish', - title: '大鱼吃小鱼', - subtitle: '实时成长玩法', - badge: '可创建', - imageSrc: '/creation-type-references/big-fish.webp', - visible: false, - open: true, - }, - { - id: 'puzzle', - title: '拼图', - subtitle: '创意礼物,生活分享', - badge: '可创建', - imageSrc: '/creation-type-references/puzzle.webp', - visible: true, - open: true, - }, - { - id: 'match3d', - title: '抓大鹅', - subtitle: '经典消除玩法', - badge: '可创建', - imageSrc: '/creation-type-references/match3d.webp', - visible: true, - open: true, - }, - { - id: 'square-hole', - title: '方洞挑战', - subtitle: '反直觉形状分拣', - badge: '可创建', - imageSrc: '/creation-type-references/square-hole.webp', - visible: true, - open: true, - }, - { - id: 'airp', - title: 'AIRP', - subtitle: '敬请期待', - badge: '敬请期待', - imageSrc: '/creation-type-references/airp.webp', - visible: true, - open: false, - }, - { - id: 'visual-novel', - title: '视觉小说', - subtitle: '故事分镜共创', - badge: '可创建', - imageSrc: '/creation-type-references/visual-novel.webp', - visible: true, - open: true, - }, - { - id: 'creative-agent', - title: '智能创作', - subtitle: '图文生成拼图草稿', - badge: '可创建', - imageSrc: '/creation-type-references/creative-agent.webp', - visible: false, - open: true, - }, - ], -} as const; - -export type NewWorkEntryCreationTypeId = - (typeof NEW_WORK_ENTRY_CONFIG.creationTypes)[number]['id']; diff --git a/src/routing/runtimeNotFoundRecovery.test.ts b/src/routing/runtimeNotFoundRecovery.test.ts new file mode 100644 index 00000000..6cfb18b7 --- /dev/null +++ b/src/routing/runtimeNotFoundRecovery.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest'; + +import { resolveRuntimeNotFoundRecoveryAction } from './runtimeNotFoundRecovery'; + +test('runtime not found recovery returns home after direct runtime route alert', () => { + expect(resolveRuntimeNotFoundRecoveryAction('/runtime/puzzle')).toEqual({ + nextStage: 'platform', + nextPath: '/', + shouldAlert: true, + }); + expect(resolveRuntimeNotFoundRecoveryAction('/runtime/puzzle/')).toEqual({ + nextStage: 'platform', + nextPath: '/', + shouldAlert: true, + }); +}); + +test('runtime not found recovery only handles direct runtime routes', () => { + expect(resolveRuntimeNotFoundRecoveryAction('/gallery/puzzle/detail')).toBeNull(); + expect(resolveRuntimeNotFoundRecoveryAction('/creation/puzzle/result')).toBeNull(); +}); diff --git a/src/routing/runtimeNotFoundRecovery.ts b/src/routing/runtimeNotFoundRecovery.ts new file mode 100644 index 00000000..ebfc6767 --- /dev/null +++ b/src/routing/runtimeNotFoundRecovery.ts @@ -0,0 +1,30 @@ +export type RuntimeNotFoundRecoveryAction = { + nextStage: 'platform'; + nextPath: '/'; + shouldAlert: true; +}; + +/** + * 中文注释:直接打开 /runtime/<玩法>?work=<作品号> 时,如果作品不存在, + * 弹窗关闭后必须回到首页,避免继续停留在没有运行态数据的空白页面。 + */ +export function resolveRuntimeNotFoundRecoveryAction( + pathname: string, +): RuntimeNotFoundRecoveryAction | null { + const normalizedPath = pathname.trim().toLowerCase().replace(/\/+$/u, ''); + if ( + normalizedPath === '/runtime/puzzle' || + normalizedPath === '/runtime/match3d' || + normalizedPath === '/runtime/big-fish' || + normalizedPath === '/runtime/square-hole' || + normalizedPath === '/runtime/visual-novel' + ) { + return { + nextStage: 'platform', + nextPath: '/', + shouldAlert: true, + }; + } + + return null; +} diff --git a/src/services/creationEntryConfigService.ts b/src/services/creationEntryConfigService.ts new file mode 100644 index 00000000..29e31aba --- /dev/null +++ b/src/services/creationEntryConfigService.ts @@ -0,0 +1,35 @@ +import { requestJson } from './apiClient'; + +export type CreationEntryTypeConfig = { + id: string; + title: string; + subtitle: string; + badge: string; + imageSrc: string; + visible: boolean; + open: boolean; + sortOrder: number; + updatedAtMicros: number; +}; + +export type CreationEntryConfig = { + startCard: { + title: string; + description: string; + idleBadge: string; + busyBadge: string; + }; + typeModal: { + title: string; + description: string; + }; + creationTypes: CreationEntryTypeConfig[]; +}; + +export async function fetchCreationEntryConfig(): Promise { + return requestJson( + '/api/creation-entry/config', + { method: 'GET' }, + '读取创作入口配置失败', + ); +}