merge: database backed creation entry config
# Conflicts: # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
@@ -185,6 +185,7 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- 移动端优先,再兼容网页端。
|
- 移动端优先,再兼容网页端。
|
||||||
- 页面只展示后端返回的状态,不自行计算结论型业务状态。
|
- 页面只展示后端返回的状态,不自行计算结论型业务状态。
|
||||||
|
- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。
|
||||||
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。
|
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。
|
||||||
- 不在 UI 中默认写功能说明类文本。
|
- 不在 UI 中默认写功能说明类文本。
|
||||||
- 弹出独立面板的交互不要实现成在当前面板下方追加内容。
|
- 弹出独立面板的交互不要实现成在当前面板下方追加内容。
|
||||||
|
|||||||
@@ -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
1
server-rs/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -612,6 +615,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)
|
||||||
@@ -1493,6 +1500,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))
|
||||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||||
@@ -1953,6 +1965,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"));
|
||||||
|
|||||||
167
server-rs/crates/api-server/src/creation_entry_config.rs
Normal file
167
server-rs/crates/api-server/src/creation_entry_config.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -706,6 +706,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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -299,6 +304,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';
|
||||||
@@ -883,6 +889,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;
|
||||||
@@ -1699,7 +1723,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<
|
||||||
@@ -1715,6 +1754,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,
|
||||||
@@ -4274,7 +4334,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPublicWorkDetailError(null);
|
setPublicWorkDetailError(null);
|
||||||
setPlatformTab('home');
|
setPlatformTab('home');
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
pushAppHistoryPath('/');
|
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||||
|
pushAppHistoryPath('/');
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5774,7 +5836,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPublicWorkDetailError(null);
|
setPublicWorkDetailError(null);
|
||||||
setPlatformTab('home');
|
setPlatformTab('home');
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
pushAppHistoryPath('/');
|
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||||
|
pushAppHistoryPath('/');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5993,7 +6057,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPublicWorkDetailError(null);
|
setPublicWorkDetailError(null);
|
||||||
setPlatformTab('home');
|
setPlatformTab('home');
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
pushAppHistoryPath('/');
|
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||||
|
pushAppHistoryPath('/');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7567,7 +7633,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={
|
||||||
@@ -7597,6 +7664,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);
|
||||||
@@ -7618,6 +7693,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
void refreshVisualNovelShelf();
|
void refreshVisualNovelShelf();
|
||||||
}}
|
}}
|
||||||
createError={
|
createError={
|
||||||
|
creationEntryConfigError ??
|
||||||
sessionController.creationTypeError ??
|
sessionController.creationTypeError ??
|
||||||
bigFishError ??
|
bigFishError ??
|
||||||
match3dError ??
|
match3dError ??
|
||||||
@@ -7627,6 +7703,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
visualNovelError
|
visualNovelError
|
||||||
}
|
}
|
||||||
createBusy={
|
createBusy={
|
||||||
|
!creationEntryConfig ||
|
||||||
sessionController.isCreatingAgentSession ||
|
sessionController.isCreatingAgentSession ||
|
||||||
isCreativeAgentBusy ||
|
isCreativeAgentBusy ||
|
||||||
isCreativeAgentStreaming ||
|
isCreativeAgentStreaming ||
|
||||||
@@ -7637,6 +7714,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isVisualNovelBusy ||
|
isVisualNovelBusy ||
|
||||||
isVisualNovelStreamingReply
|
isVisualNovelStreamingReply
|
||||||
}
|
}
|
||||||
|
entryConfig={creationEntryConfig}
|
||||||
|
creationTypes={creationEntryTypes}
|
||||||
onCreateType={handleCreationHubCreateType}
|
onCreateType={handleCreationHubCreateType}
|
||||||
onOpenDraft={(item) => {
|
onOpenDraft={(item) => {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
@@ -7717,6 +7796,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
handleDeleteVisualNovelWork(item);
|
handleDeleteVisualNovelWork(item);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
const creationStartContent = (
|
const creationStartContent = (
|
||||||
@@ -7728,7 +7808,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="选择模板"
|
aria-label="选择模板"
|
||||||
>
|
>
|
||||||
{getVisiblePlatformCreationTypes().map((item) => {
|
{getVisiblePlatformCreationTypes(creationEntryTypes).map((item) => {
|
||||||
const selected = item.id === activeCreationFormType;
|
const selected = item.id === activeCreationFormType;
|
||||||
const disabled =
|
const disabled =
|
||||||
item.locked ||
|
item.locked ||
|
||||||
@@ -9438,7 +9518,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<PlatformEntryCreationTypeModal
|
{creationEntryConfig ? (
|
||||||
|
<PlatformEntryCreationTypeModal
|
||||||
isOpen={showCreationTypeModal}
|
isOpen={showCreationTypeModal}
|
||||||
isBusy={
|
isBusy={
|
||||||
sessionController.isCreatingAgentSession ||
|
sessionController.isCreatingAgentSession ||
|
||||||
@@ -9452,6 +9533,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isVisualNovelStreamingReply
|
isVisualNovelStreamingReply
|
||||||
}
|
}
|
||||||
error={
|
error={
|
||||||
|
creationEntryConfigError ??
|
||||||
bigFishError ??
|
bigFishError ??
|
||||||
creativeAgentError ??
|
creativeAgentError ??
|
||||||
match3dError ??
|
match3dError ??
|
||||||
@@ -9461,6 +9543,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
puzzleError ??
|
puzzleError ??
|
||||||
sessionController.creationTypeError
|
sessionController.creationTypeError
|
||||||
}
|
}
|
||||||
|
entryConfig={creationEntryConfig}
|
||||||
|
creationTypes={creationEntryTypes}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (
|
if (
|
||||||
sessionController.isCreatingAgentSession ||
|
sessionController.isCreatingAgentSession ||
|
||||||
@@ -9507,6 +9591,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
handleCreationHubCreateType('visual-novel');
|
handleCreationHubCreateType('visual-novel');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<PublishShareModal
|
<PublishShareModal
|
||||||
open={Boolean(publishSharePayload)}
|
open={Boolean(publishSharePayload)}
|
||||||
payload={publishSharePayload}
|
payload={publishSharePayload}
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'];
|
|
||||||
21
src/routing/runtimeNotFoundRecovery.test.ts
Normal file
21
src/routing/runtimeNotFoundRecovery.test.ts
Normal 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();
|
||||||
|
});
|
||||||
30
src/routing/runtimeNotFoundRecovery.ts
Normal file
30
src/routing/runtimeNotFoundRecovery.ts
Normal 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;
|
||||||
|
}
|
||||||
35
src/services/creationEntryConfigService.ts
Normal file
35
src/services/creationEntryConfigService.ts
Normal 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' },
|
||||||
|
'读取创作入口配置失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user