feat: move creation entry config to database

This commit is contained in:
2026-05-11 11:23:24 +08:00
parent 7f2461313e
commit 793d82cccd
37 changed files with 1458 additions and 204 deletions

View File

@@ -185,6 +185,7 @@ npm run check:server-rs-ddd
- 移动端优先,再兼容网页端。 - 移动端优先,再兼容网页端。
- 页面只展示后端返回的状态,不自行计算结论型业务状态。 - 页面只展示后端返回的状态,不自行计算结论型业务状态。
- 创作中心入口配置事实源在 SpacetimeDB通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。 - 优先复用现有面板、抽屉、弹窗,不新建独立大系统。
- 不在 UI 中默认写功能说明类文本。 - 不在 UI 中默认写功能说明类文本。
- 弹出独立面板的交互不要实现成在当前面板下方追加内容。 - 弹出独立面板的交互不要实现成在当前面板下方追加内容。

View File

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

1
server-rs/Cargo.lock generated
View File

@@ -1864,6 +1864,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"shared-contracts",
"shared-kernel", "shared-kernel",
"spacetimedb", "spacetimedb",
"time", "time",

View File

@@ -50,6 +50,9 @@ use crate::{
generate_character_visual, get_character_visual_job, publish_character_visual, generate_character_visual, get_character_visual_job, publish_character_visual,
}, },
creation_agent_document_input::parse_creation_agent_document_input, creation_agent_document_input::parse_creation_agent_document_input,
creation_entry_config::{
get_creation_entry_config_handler, require_creation_entry_route_enabled,
},
creative_agent::{ creative_agent::{
cancel_creative_agent_session, confirm_creative_puzzle_template, cancel_creative_agent_session, confirm_creative_puzzle_template,
create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message, 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, require_bearer_auth,
)), )),
) )
.route(
"/api/creation-entry/config",
get(get_creation_entry_config_handler),
)
.route( .route(
"/api/runtime/settings", "/api/runtime/settings",
get(get_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)) .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 记录到最终对外返回的状态与错误体形态。 // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response)) .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] #[tokio::test]
async fn healthz_returns_standard_envelope_when_requested() { async fn healthz_returns_standard_envelope_when_requested() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, 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<AppState>,
request: Request<Body>,
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);
}
}

View File

@@ -23,6 +23,7 @@ mod creation_agent_anchor_templates;
mod creation_agent_chat; mod creation_agent_chat;
mod creation_agent_document_input; mod creation_agent_document_input;
mod creation_agent_llm_turn; mod creation_agent_llm_turn;
mod creation_entry_config;
mod creative_agent; mod creative_agent;
mod creative_agent_sse; mod creative_agent_sse;
mod custom_world; mod custom_world;

View File

@@ -11,6 +11,7 @@ use module_auth::{
RefreshSessionService, WechatAuthService, WechatAuthStateService, RefreshSessionService, WechatAuthService, WechatAuthStateService,
}; };
use module_runtime::RuntimeSnapshotRecord; use module_runtime::RuntimeSnapshotRecord;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
#[cfg(test)] #[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_agent::MockLangChainRustAgentExecutor; use platform_agent::MockLangChainRustAgentExecutor;
@@ -41,6 +42,8 @@ pub struct AppState {
auth_jwt_config: JwtConfig, auth_jwt_config: JwtConfig,
admin_runtime: Option<AdminRuntime>, admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig, refresh_cookie_config: RefreshCookieConfig,
#[cfg(test)]
test_creation_entry_config: Arc<Mutex<Option<CreationEntryConfigResponse>>>,
oss_client: Option<OssClient>, oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))] #[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore, auth_store: InMemoryAuthStore,
@@ -189,6 +192,10 @@ impl AppState {
auth_jwt_config, auth_jwt_config,
admin_runtime, admin_runtime,
refresh_cookie_config, 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, oss_client,
auth_store, auth_store,
password_entry_service, password_entry_service,
@@ -221,6 +228,68 @@ impl AppState {
&self.refresh_cookie_config &self.refresh_cookie_config
} }
pub async fn get_creation_entry_config(
&self,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
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<bool, SpacetimeClientError> {
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<str>,
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> { pub fn oss_client(&self) -> Option<&OssClient> {
self.oss_client.as_ref() self.oss_client.as_ref()
} }
@@ -488,6 +557,21 @@ impl AppState {
#[cfg(test)] #[cfg(test)]
impl AppState { 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( pub(crate) async fn seed_test_phone_user_with_password(
&self, &self,
phone_number: &str, phone_number: &str,

View File

@@ -11,6 +11,7 @@ spacetime-types = ["dep:spacetimedb"]
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
shared-contracts = { workspace = true }
shared-kernel = { workspace = true } shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true } spacetimedb = { workspace = true, optional = true }
time = { workspace = true, features = ["formatting", "parsing"] } time = { workspace = true, features = ["formatting", "parsing"] }

View File

@@ -9,6 +9,42 @@ use std::collections::BTreeMap;
use crate::domain::*; use crate::domain::*;
use crate::errors::RuntimeProfileFieldError; use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros; 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 { pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
RuntimeSettingsRecord { RuntimeSettingsRecord {

View File

@@ -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_MAX_BYTES: u64 = 1_048_576;
pub const PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES: u64 = 4_194_304; 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<CreationEntryTypeSnapshot>,
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<CreationEntryConfigSnapshot>,
pub error_message: Option<String>,
}
/// 分析日期维表的纯领域快照。 /// 分析日期维表的纯领域快照。
/// ///
/// date_key 沿用现有北京时间自然日桶floor((occurred_at_micros + 8h) / 1d)。 /// date_key 沿用现有北京时间自然日桶floor((occurred_at_micros + 8h) / 1d)。

View File

@@ -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<CreationEntryTypeResponse>,
}
#[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,
}

View File

@@ -7,6 +7,7 @@ pub mod big_fish;
pub mod big_fish_works; pub mod big_fish_works;
pub mod creation_agent_document_input; pub mod creation_agent_document_input;
pub mod creative_agent; pub mod creative_agent;
pub mod creation_entry_config;
pub mod hyper3d; pub mod hyper3d;
pub mod llm; pub mod llm;
pub mod match3d_agent; pub mod match3d_agent;

View File

@@ -14,7 +14,8 @@ pub use mapper::{
BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput,
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record, BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CreationEntryConfigRecord,
CustomWorldAgentActionExecuteRecord,
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,

View File

@@ -705,6 +705,58 @@ pub(crate) fn map_asset_history_list_result(
.collect()) .collect())
} }
pub type CreationEntryConfigRecord = shared_contracts::creation_entry_config::CreationEntryConfigResponse;
pub(crate) fn map_creation_entry_config_procedure_result(
result: CreationEntryConfigProcedureResult,
) -> Result<CreationEntryConfigRecord, SpacetimeClientError> {
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( pub(crate) fn map_runtime_setting_procedure_result(
result: RuntimeSettingProcedureResult, result: RuntimeSettingProcedureResult,
) -> Result<RuntimeSettingsRecord, SpacetimeClientError> { ) -> Result<RuntimeSettingsRecord, SpacetimeClientError> {

View File

@@ -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<CreationEntryConfigSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for CreationEntryConfigProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -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<CreationEntryTypeSnapshot>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for CreationEntryConfigSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<CreationEntryConfigProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_creation_entry_config for super::RemoteProcedures {
fn get_creation_entry_config_then(
&self,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, CreationEntryConfigProcedureResult>(
"get_creation_entry_config",
GetCreationEntryConfigArgs {},
__callback,
);
}
}

View File

@@ -249,6 +249,11 @@ pub mod custom_world_theme_mode_type;
pub mod custom_world_work_summary_snapshot_type; pub mod custom_world_work_summary_snapshot_type;
pub mod custom_world_works_list_input_type; pub mod custom_world_works_list_input_type;
pub mod custom_world_works_list_result_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_authorize_operator_input_type;
pub mod database_migration_export_input_type; pub mod database_migration_export_input_type;
pub mod database_migration_import_chunk_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 finish_square_hole_time_up_procedure;
pub mod generate_big_fish_asset_procedure; pub mod generate_big_fish_asset_procedure;
pub mod get_auth_store_snapshot_procedure; pub mod get_auth_store_snapshot_procedure;
pub mod get_creation_entry_config_procedure;
pub mod get_battle_state_procedure; pub mod get_battle_state_procedure;
pub mod get_big_fish_run_procedure; pub mod get_big_fish_run_procedure;
pub mod get_big_fish_session_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_work_summary_snapshot_type::CustomWorldWorkSummarySnapshot;
pub use custom_world_works_list_input_type::CustomWorldWorksListInput; pub use custom_world_works_list_input_type::CustomWorldWorksListInput;
pub use custom_world_works_list_result_type::CustomWorldWorksListResult; 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_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput;
pub use database_migration_export_input_type::DatabaseMigrationExportInput; pub use database_migration_export_input_type::DatabaseMigrationExportInput;
pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; 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 finish_square_hole_time_up_procedure::finish_square_hole_time_up;
pub use generate_big_fish_asset_procedure::generate_big_fish_asset; 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_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_battle_state_procedure::get_battle_state;
pub use get_big_fish_run_procedure::get_big_fish_run; pub use get_big_fish_run_procedure::get_big_fish_run;
pub use get_big_fish_session_procedure::get_big_fish_session; pub use get_big_fish_session_procedure::get_big_fish_session;

View File

@@ -1,6 +1,22 @@
use super::*; use super::*;
impl SpacetimeClient { impl SpacetimeClient {
pub async fn get_creation_entry_config(
&self,
) -> Result<CreationEntryConfigRecord, SpacetimeClientError> {
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( pub async fn get_runtime_settings(
&self, &self,
user_id: String, user_id: String,

View File

@@ -1,4 +1,5 @@
use crate::runtime::analytics_date_dimension::analytics_date_dimension; 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 crate::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use spacetimedb::sats::de::serde::DeserializeWrapper; use spacetimedb::sats::de::serde::DeserializeWrapper;
@@ -167,6 +168,8 @@ macro_rules! migration_tables {
ai_task_event, ai_task_event,
runtime_snapshot, runtime_snapshot,
runtime_setting, runtime_setting,
creation_entry_config,
creation_entry_type_config,
user_browse_history, user_browse_history,
profile_dashboard_state, profile_dashboard_state,
profile_wallet_ledger, profile_wallet_ledger,

View File

@@ -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<CreationEntryConfigSnapshot, String> {
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::<Vec<_>>();
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<CreationEntryTypeConfig> {
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,
}
}

View File

@@ -1,10 +1,12 @@
pub mod analytics_date_dimension; pub mod analytics_date_dimension;
pub mod creation_entry_config;
mod browse_history; mod browse_history;
mod profile; mod profile;
mod settings; mod settings;
mod snapshots; mod snapshots;
pub use analytics_date_dimension::*; pub use analytics_date_dimension::*;
pub use creation_entry_config::*;
pub use browse_history::*; pub use browse_history::*;
pub use profile::*; pub use profile::*;
pub use settings::*; pub use settings::*;

View File

@@ -1,13 +1,88 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; 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'; import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {}; 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; const originalClipboard = navigator.clipboard;
afterEach(() => { afterEach(() => {
@@ -58,6 +133,8 @@ test('creation hub shows published metric growth from cached page snapshot', asy
onCreateType={noopCreateType} onCreateType={noopCreateType}
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}} onOpenPuzzleDetail={() => {}}
/>, />,
); );
@@ -99,6 +176,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
onCreateType={onCreateType} onCreateType={onCreateType}
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>, />,
); );
@@ -142,6 +221,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
onCreateType={noopCreateType} onCreateType={noopCreateType}
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} 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} onCreateType={noopCreateType}
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}} onOpenPuzzleDetail={() => {}}
/>, />,
); );
@@ -238,6 +321,8 @@ test('creation hub shows puzzle point incentive and claims without opening card'
onEnterPublished={() => {}} onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail} onOpenPuzzleDetail={onOpenPuzzleDetail}
onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive} onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>, />,
); );
@@ -296,6 +381,8 @@ test('creation hub shows RPG public work code from published library entry', ()
onCreateType={noopCreateType} onCreateType={noopCreateType}
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>, />,
); );
@@ -318,6 +405,8 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} onEnterPublished={() => {}}
onDeletePublished={() => {}} onDeletePublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>, />,
); );
@@ -359,6 +448,8 @@ test('creation hub published work delete action is available beside share withou
onEnterPublished={() => {}} onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail} onOpenPuzzleDetail={onOpenPuzzleDetail}
onDeletePuzzle={onDeletePuzzle} onDeletePuzzle={onDeletePuzzle}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>, />,
); );
@@ -396,6 +487,8 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
openedItems.push(item); openedItems.push(item);
}} }}
onEnterPublished={() => {}} onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>, />,
); );
@@ -444,6 +537,8 @@ test('creation hub published share button copies share text without opening the
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail} onOpenPuzzleDetail={onOpenPuzzleDetail}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>, />,
); );

View File

@@ -1,10 +1,85 @@
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { CustomWorldCreationHub } from './CustomWorldCreationHub'; import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {}; 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', () => { test('creation hub draft card renders compiled work summary fields', () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<CustomWorldCreationHub <CustomWorldCreationHub
@@ -36,6 +111,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
onCreateType={noopCreateType} onCreateType={noopCreateType}
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} 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} onCreateType={noopCreateType}
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}} onOpenPuzzleDetail={() => {}}
/>, />,
); );
@@ -123,6 +202,8 @@ test('creation hub published work spans full mobile row', () => {
onCreateType={noopCreateType} onCreateType={noopCreateType}
onOpenDraft={() => {}} onOpenDraft={() => {}}
onEnterPublished={() => {}} onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}} onOpenPuzzleDetail={() => {}}
/>, />,
); );

View File

@@ -8,7 +8,11 @@ import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contr
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldProfile } from '../../types'; 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 { import {
buildCreationWorkShelfItems, buildCreationWorkShelfItems,
type CreationWorkShelfItem, type CreationWorkShelfItem,
@@ -38,6 +42,8 @@ type CustomWorldCreationHubProps = {
onRetry: () => void; onRetry: () => void;
createError?: string | null; createError?: string | null;
createBusy?: boolean; createBusy?: boolean;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
onCreateType: (type: PlatformCreationTypeId) => void; onCreateType: (type: PlatformCreationTypeId) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void; onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void; onEnterPublished: (profileId: string) => void;
@@ -134,6 +140,8 @@ export function CustomWorldCreationHub({
onRetry, onRetry,
createError = null, createError = null,
createBusy = false, createBusy = false,
entryConfig,
creationTypes,
onCreateType, onCreateType,
onOpenDraft, onOpenDraft,
onEnterPublished, onEnterPublished,
@@ -309,6 +317,8 @@ export function CustomWorldCreationHub({
<CustomWorldCreationStartCard <CustomWorldCreationStartCard
busy={createBusy} busy={createBusy}
error={createError} error={createError}
entryConfig={entryConfig}
creationTypes={creationTypes}
onCreateType={onCreateType} onCreateType={onCreateType}
/> />
) : null} ) : null}

View File

@@ -1,25 +1,30 @@
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { import {
getVisiblePlatformCreationTypes, getVisiblePlatformCreationTypes,
type PlatformCreationTypeCard,
type PlatformCreationTypeId, type PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes'; } from '../platform-entry/platformEntryCreationTypes';
type CustomWorldCreationStartCardProps = { type CustomWorldCreationStartCardProps = {
busy?: boolean; busy?: boolean;
error?: string | null; error?: string | null;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
onCreateType: (type: PlatformCreationTypeId) => void; onCreateType: (type: PlatformCreationTypeId) => void;
}; };
export function CustomWorldCreationStartCard({ export function CustomWorldCreationStartCard({
busy = false, busy = false,
error = null, error = null,
entryConfig,
creationTypes,
onCreateType, onCreateType,
}: CustomWorldCreationStartCardProps) { }: CustomWorldCreationStartCardProps) {
// 创作首页首屏卡带与创作类型弹层保持同一份展示口径, // 创作首页首屏卡带与创作类型弹层保持同一份展示口径,
// 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。 // 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。
const visibleCreationTypes = getVisiblePlatformCreationTypes(); const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes);
return ( return (
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。 // 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
@@ -28,15 +33,15 @@ export function CustomWorldCreationStartCard({
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3"> <div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
<div className="flex items-center justify-between gap-3 xl:items-end"> <div className="flex items-center justify-between gap-3 xl:items-end">
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl"> <div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
{NEW_WORK_ENTRY_CONFIG.startCard.title} {entryConfig.startCard.title}
</div> </div>
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5"> <div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
{NEW_WORK_ENTRY_CONFIG.startCard.description} {entryConfig.startCard.description}
</div> </div>
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden"> <span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
{busy {busy
? NEW_WORK_ENTRY_CONFIG.startCard.busyBadge ? entryConfig.startCard.busyBadge
: NEW_WORK_ENTRY_CONFIG.startCard.idleBadge} : entryConfig.startCard.idleBadge}
</span> </span>
</div> </div>

View File

@@ -1,13 +1,18 @@
import { ArrowRight } from 'lucide-react'; 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 { UnifiedModal } from '../common/UnifiedModal';
import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes'; import {
getVisiblePlatformCreationTypes,
type PlatformCreationTypeCard,
} from './platformEntryCreationTypes';
export interface PlatformEntryCreationTypeModalProps { export interface PlatformEntryCreationTypeModalProps {
isOpen: boolean; isOpen: boolean;
isBusy: boolean; isBusy: boolean;
error: string | null; error: string | null;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
onClose: () => void; onClose: () => void;
onSelectRpg: () => void; onSelectRpg: () => void;
onSelectBigFish: () => void; onSelectBigFish: () => void;
@@ -86,6 +91,8 @@ export function PlatformEntryCreationTypeModal({
isOpen, isOpen,
isBusy, isBusy,
error, error,
entryConfig,
creationTypes,
onClose, onClose,
onSelectRpg, onSelectRpg,
onSelectBigFish, onSelectBigFish,
@@ -101,13 +108,13 @@ export function PlatformEntryCreationTypeModal({
// 平台入口只渲染当前允许展示的创作类型; // 平台入口只渲染当前允许展示的创作类型;
// 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。 // 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。
const visibleCreationTypes = getVisiblePlatformCreationTypes(); const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes);
return ( return (
<UnifiedModal <UnifiedModal
open={isOpen} open={isOpen}
title={NEW_WORK_ENTRY_CONFIG.typeModal.title} title={entryConfig.typeModal.title}
description={NEW_WORK_ENTRY_CONFIG.typeModal.description} description={entryConfig.typeModal.description}
onClose={onClose} onClose={onClose}
closeDisabled={isBusy} closeDisabled={isBusy}
size="lg" size="lg"

View File

@@ -1,4 +1,4 @@
import { ArrowRight, Loader2, Sparkles } from 'lucide-react'; import { ArrowRight, Loader2, Sparkles } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { import {
type Dispatch, type Dispatch,
@@ -99,10 +99,15 @@ import {
buildPublicWorkStagePath, buildPublicWorkStagePath,
pushAppHistoryPath, pushAppHistoryPath,
} from '../../routing/appPageRoutes'; } from '../../routing/appPageRoutes';
import { resolveRuntimeNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
import { import {
ApiClientError, ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS, BACKGROUND_AUTH_REQUEST_OPTIONS,
} from '../../services/apiClient'; } from '../../services/apiClient';
import {
fetchCreationEntryConfig,
type CreationEntryConfig,
} from '../../services/creationEntryConfigService';
import { import {
getPublicAuthUserByCode, getPublicAuthUserByCode,
getPublicAuthUserById, getPublicAuthUserById,
@@ -293,6 +298,7 @@ import {
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import { import {
derivePlatformCreationTypes,
getVisiblePlatformCreationTypes, getVisiblePlatformCreationTypes,
isPlatformCreationTypeVisible, isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes'; } from './platformEntryCreationTypes';
@@ -875,6 +881,24 @@ function isMissingPuzzleWorkError(error: unknown) {
); );
} }
function maybeAlertRuntimeNotFoundAndReturnHome() {
if (typeof window === 'undefined') {
return false;
}
const recoveryAction = resolveRuntimeNotFoundRecoveryAction(
window.location.pathname,
);
if (!recoveryAction) {
return false;
}
// 中文注释:直接 runtime 深链找不到作品时,弹窗确认后立刻回首页,避免保留空白运行态。
window.alert('作品不存在或已下架,将返回首页。');
pushAppHistoryPath(recoveryAction.nextPath);
return true;
}
function hasSeenPuzzleOnboarding() { function hasSeenPuzzleOnboarding() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return true; return true;
@@ -1679,7 +1703,22 @@ export function PlatformEntryFlowShellImpl({
] = useState<string | null>(null); ] = useState<string | null>(null);
const [publishSharePayload, setPublishSharePayload] = const [publishSharePayload, setPublishSharePayload] =
useState<PublishShareModalPayload | null>(null); useState<PublishShareModalPayload | null>(null);
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish'); const [creationEntryConfig, setCreationEntryConfig] =
useState<CreationEntryConfig | null>(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] = const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null); useState<ProfilePlayStatsResponse | null>(null);
const [profilePlayStatsError, setProfilePlayStatsError] = useState< const [profilePlayStatsError, setProfilePlayStatsError] = useState<
@@ -1695,6 +1734,27 @@ export function PlatformEntryFlowShellImpl({
); );
const handledInitialPublicWorkCodeRef = useRef<string | null>(null); const handledInitialPublicWorkCodeRef = useRef<string | null>(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({ const platformBootstrap = usePlatformEntryBootstrap({
user: authUi?.user, user: authUi?.user,
canAccessProtectedData: authUi?.canAccessProtectedData, canAccessProtectedData: authUi?.canAccessProtectedData,
@@ -4092,7 +4152,9 @@ export function PlatformEntryFlowShellImpl({
setPublicWorkDetailError(null); setPublicWorkDetailError(null);
setPlatformTab('home'); setPlatformTab('home');
setSelectionStage('platform'); setSelectionStage('platform');
pushAppHistoryPath('/'); if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
pushAppHistoryPath('/');
}
return false; return false;
} }
@@ -5590,7 +5652,9 @@ export function PlatformEntryFlowShellImpl({
setPublicWorkDetailError(null); setPublicWorkDetailError(null);
setPlatformTab('home'); setPlatformTab('home');
setSelectionStage('platform'); setSelectionStage('platform');
pushAppHistoryPath('/'); if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
pushAppHistoryPath('/');
}
return; return;
} }
@@ -5809,7 +5873,9 @@ export function PlatformEntryFlowShellImpl({
setPublicWorkDetailError(null); setPublicWorkDetailError(null);
setPlatformTab('home'); setPlatformTab('home');
setSelectionStage('platform'); setSelectionStage('platform');
pushAppHistoryPath('/'); if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
pushAppHistoryPath('/');
}
return; return;
} }
@@ -7380,7 +7446,8 @@ export function PlatformEntryFlowShellImpl({
fallbackLabel: string, fallbackLabel: string,
) => ( ) => (
<Suspense fallback={<LazyPanelFallback label={fallbackLabel} />}> <Suspense fallback={<LazyPanelFallback label={fallbackLabel} />}>
<CustomWorldCreationHub {creationEntryConfig ? (
<CustomWorldCreationHub
mode={mode} mode={mode}
items={creationHubItems} items={creationHubItems}
loading={ loading={
@@ -7410,6 +7477,14 @@ export function PlatformEntryFlowShellImpl({
} }
onRetry={() => { onRetry={() => {
platformBootstrap.setPlatformError(null); platformBootstrap.setPlatformError(null);
setCreationEntryConfigError(null);
void fetchCreationEntryConfig()
.then(setCreationEntryConfig)
.catch((error: unknown) => {
setCreationEntryConfigError(
error instanceof Error ? error.message : '读取创作入口配置失败。',
);
});
setBigFishError(null); setBigFishError(null);
setMatch3DError(null); setMatch3DError(null);
setSquareHoleError(null); setSquareHoleError(null);
@@ -7431,6 +7506,7 @@ export function PlatformEntryFlowShellImpl({
void refreshVisualNovelShelf(); void refreshVisualNovelShelf();
}} }}
createError={ createError={
creationEntryConfigError ??
sessionController.creationTypeError ?? sessionController.creationTypeError ??
bigFishError ?? bigFishError ??
match3dError ?? match3dError ??
@@ -7440,6 +7516,7 @@ export function PlatformEntryFlowShellImpl({
visualNovelError visualNovelError
} }
createBusy={ createBusy={
!creationEntryConfig ||
sessionController.isCreatingAgentSession || sessionController.isCreatingAgentSession ||
isCreativeAgentBusy || isCreativeAgentBusy ||
isCreativeAgentStreaming || isCreativeAgentStreaming ||
@@ -7450,6 +7527,8 @@ export function PlatformEntryFlowShellImpl({
isVisualNovelBusy || isVisualNovelBusy ||
isVisualNovelStreamingReply isVisualNovelStreamingReply
} }
entryConfig={creationEntryConfig}
creationTypes={creationEntryTypes}
onCreateType={handleCreationHubCreateType} onCreateType={handleCreationHubCreateType}
onOpenDraft={(item) => { onOpenDraft={(item) => {
runProtectedAction(() => { runProtectedAction(() => {
@@ -7530,6 +7609,7 @@ export function PlatformEntryFlowShellImpl({
handleDeleteVisualNovelWork(item); handleDeleteVisualNovelWork(item);
}} }}
/> />
) : null}
</Suspense> </Suspense>
); );
const creationStartContent = ( const creationStartContent = (
@@ -7541,7 +7621,7 @@ export function PlatformEntryFlowShellImpl({
role="tablist" role="tablist"
aria-label="选择模板" aria-label="选择模板"
> >
{getVisiblePlatformCreationTypes().map((item) => { {getVisiblePlatformCreationTypes(creationEntryTypes).map((item) => {
const selected = item.id === 'puzzle'; const selected = item.id === 'puzzle';
const disabled = const disabled =
item.locked || item.locked ||
@@ -9117,7 +9197,8 @@ export function PlatformEntryFlowShellImpl({
)} )}
</AnimatePresence> </AnimatePresence>
<PlatformEntryCreationTypeModal {creationEntryConfig ? (
<PlatformEntryCreationTypeModal
isOpen={showCreationTypeModal} isOpen={showCreationTypeModal}
isBusy={ isBusy={
sessionController.isCreatingAgentSession || sessionController.isCreatingAgentSession ||
@@ -9131,6 +9212,7 @@ export function PlatformEntryFlowShellImpl({
isVisualNovelStreamingReply isVisualNovelStreamingReply
} }
error={ error={
creationEntryConfigError ??
bigFishError ?? bigFishError ??
creativeAgentError ?? creativeAgentError ??
match3dError ?? match3dError ??
@@ -9140,6 +9222,8 @@ export function PlatformEntryFlowShellImpl({
puzzleError ?? puzzleError ??
sessionController.creationTypeError sessionController.creationTypeError
} }
entryConfig={creationEntryConfig}
creationTypes={creationEntryTypes}
onClose={() => { onClose={() => {
if ( if (
sessionController.isCreatingAgentSession || sessionController.isCreatingAgentSession ||
@@ -9192,6 +9276,7 @@ export function PlatformEntryFlowShellImpl({
}); });
}} }}
/> />
) : null}
<PublishShareModal <PublishShareModal
open={Boolean(publishSharePayload)} open={Boolean(publishSharePayload)}
payload={publishSharePayload} payload={publishSharePayload}

View File

@@ -1,69 +1,103 @@
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig'; import { derivePlatformCreationTypes, getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
import {
getVisiblePlatformCreationTypes,
isPlatformCreationTypeVisible,
PLATFORM_CREATION_TYPES,
} from './platformEntryCreationTypes';
test('platform creation types are derived from new work entry config', () => { test('database entry config controls visibility open state and display order', () => {
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find( const cards = derivePlatformCreationTypes([
(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({
id: 'puzzle', id: 'puzzle',
title: puzzleConfig?.title, title: '数据库拼图',
subtitle: puzzleConfig?.subtitle, subtitle: '由服务端配置覆盖',
badge: puzzleConfig?.badge, badge: '维护中',
imageSrc: puzzleConfig?.imageSrc, imageSrc: '/creation-type-references/puzzle.webp',
locked: false, visible: true,
hidden: false, open: false,
}), sortOrder: 30,
); updatedAtMicros: 1,
expect(PLATFORM_CREATION_TYPES).toContainEqual( },
{
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({ expect.objectContaining({
id: 'match3d', id: 'match3d',
title: match3dConfig?.title,
subtitle: match3dConfig?.subtitle,
badge: match3dConfig?.badge,
imageSrc: match3dConfig?.imageSrc,
locked: false, locked: false,
hidden: false, hidden: false,
}), }),
); expect.objectContaining({
}); id: 'square-hole',
locked: false,
test('every platform creation type has a generated reference image', () => { hidden: true,
expect( }),
NEW_WORK_ENTRY_CONFIG.creationTypes.every((item) => expect.objectContaining({
item.imageSrc.startsWith('/creation-type-references/'), id: 'puzzle',
), title: '数据库拼图',
).toBe(true); locked: true,
}); hidden: false,
}),
test('new work entry config controls visibility and open order', () => { ]);
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id); });
expect(isPlatformCreationTypeVisible('rpg')).toBe(false); test('visible platform creation types hide invisible cards and put locked cards last', () => {
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false); const cards = derivePlatformCreationTypes([
expect(isPlatformCreationTypeVisible('match3d')).toBe(true); {
expect(isPlatformCreationTypeVisible('creative-agent')).toBe(false); id: 'hidden',
expect(visibleIds).not.toContain('rpg'); title: '隐藏',
expect(visibleIds).not.toContain('big-fish'); subtitle: '隐藏',
expect(visibleIds).not.toContain('creative-agent'); badge: '隐藏',
expect(visibleIds).toEqual([ imageSrc: '/creation-type-references/hidden.webp',
'puzzle', visible: false,
'match3d', open: true,
'square-hole', sortOrder: 1,
'visual-novel', updatedAtMicros: 1,
'airp', },
{
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',
]); ]);
}); });

View File

@@ -1,9 +1,6 @@
import { import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
NEW_WORK_ENTRY_CONFIG,
type NewWorkEntryCreationTypeId,
} from '../../config/newWorkEntryConfig';
export type PlatformCreationTypeId = NewWorkEntryCreationTypeId; export type PlatformCreationTypeId = string;
export type PlatformCreationTypeCard = { export type PlatformCreationTypeCard = {
id: PlatformCreationTypeId; id: PlatformCreationTypeId;
@@ -15,40 +12,46 @@ export type PlatformCreationTypeCard = {
hidden?: boolean; hidden?: boolean;
}; };
/** export function getVisiblePlatformCreationTypes(
* 返回当前平台入口允许展示的创作类型。 creationTypes: readonly PlatformCreationTypeCard[],
* 平台层的入口、首屏卡带与初始化请求都应基于这份结果统一判断。 ) {
*/ const visibleCreationTypes = creationTypes.filter((item) => !item.hidden);
export function getVisiblePlatformCreationTypes() {
const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter(
(item) => !item.hidden,
);
// 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用配置顺序。 // 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用数据库排序。
return [ return [
...visibleCreationTypes.filter((item) => !item.locked), ...visibleCreationTypes.filter((item) => !item.locked),
...visibleCreationTypes.filter((item) => item.locked), ...visibleCreationTypes.filter((item) => item.locked),
]; ];
} }
/** export function isPlatformCreationTypeVisible(
* 判断某个创作类型当前是否仍暴露在平台入口中。 creationTypes: readonly PlatformCreationTypeCard[],
*/ id: PlatformCreationTypeId,
export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) { ) {
return PLATFORM_CREATION_TYPES.some((item) => item.id === id && !item.hidden); return creationTypes.some((item) => item.id === id && !item.hidden);
} }
/** /**
* 创作页与类型弹层共用同一份新建作品入口配置,避免多入口文案和开放状态漂移 * 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB前端不再保留入口默认配置
* `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。
*/ */
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = export function derivePlatformCreationTypes(
NEW_WORK_ENTRY_CONFIG.creationTypes.map((item) => ({ creationTypes: readonly CreationEntryTypeConfig[],
id: item.id, ): PlatformCreationTypeCard[] {
title: item.title, const orderedCards = [...creationTypes]
subtitle: item.subtitle, .sort((left, right) => left.sortOrder - right.sortOrder)
badge: item.badge, .map((item) => ({
imageSrc: item.imageSrc, id: item.id,
locked: !item.open, title: item.title,
hidden: !item.visible, 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),
];
}

View File

@@ -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'];

View File

@@ -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();
});

View File

@@ -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;
}

View File

@@ -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<CreationEntryConfig> {
return requestJson<CreationEntryConfig>(
'/api/creation-entry/config',
{ method: 'GET' },
'读取创作入口配置失败',
);
}