fix: 收窄创作入口关闭熔断范围
This commit is contained in:
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-03 创作入口关闭不下架已发布作品
|
||||||
|
|
||||||
|
- 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。
|
||||||
|
- 决策:入口配置的 `open=false` 只表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。后端熔断只拦新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不因创作入口关闭而失败。前端平台首页遇到旧服务端返回的 `creation_entry_disabled` 只降级,不弹平台级错误弹窗;关闭态模板卡必须明显禁用并展示 `暂未开放`,不得继续显示泥点消耗。
|
||||||
|
- 影响范围:`server-rs/crates/api-server/src/creation_entry_config.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、创作入口相关测试与玩法链路文档。
|
||||||
|
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误;进入平台首页不弹“平台首页:creation_entry_disabled”;关闭态入口卡显示锁定状态且不显示 `10-20泥点数`。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-03 最近创作只复用创作模板入口
|
## 2026-06-03 最近创作只复用创作模板入口
|
||||||
|
|
||||||
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
|
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
|
|
||||||
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
|
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
|
||||||
|
|
||||||
|
入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
|
||||||
|
|
||||||
|
创作入口页的关闭态卡片必须有明显差异:卡片禁用点击,展示后台配置的关闭态 badge 或 `暂未开放`,不再显示 `10-20泥点数` 这类可创建成本提示;开放态卡片仍不显示普通 `可创建 / 可创作` badge。
|
||||||
|
|
||||||
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
|
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
|
||||||
|
|
||||||
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recommended` / `热门推荐`,并把历史 `recent` / `最近创作` 归一到推荐分类。`最近创作` 不属于模板分类页签,只能由 7 天内的真实草稿 / 作品架后端数据决定是否展示;展示内容仍然从后端入口配置的模板卡中筛选,不读取或渲染作品标题、作品摘要、草稿阶段文案。
|
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recommended` / `热门推荐`,并把历史 `recent` / `最近创作` 归一到推荐分类。`最近创作` 不属于模板分类页签,只能由 7 天内的真实草稿 / 作品架后端数据决定是否展示;展示内容仍然从后端入口配置的模板卡中筛选,不读取或渲染作品标题、作品摘要、草稿阶段文案。
|
||||||
|
|||||||
@@ -740,7 +740,8 @@ mod tests {
|
|||||||
let response = app
|
let response = app
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.uri("/api/runtime/puzzle/works")
|
.method("POST")
|
||||||
|
.uri("/api/runtime/puzzle/agent/sessions")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.expect("request should build"),
|
.expect("request should build"),
|
||||||
)
|
)
|
||||||
@@ -756,6 +757,31 @@ mod tests {
|
|||||||
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
|
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disabled_creation_entry_does_not_block_published_runtime_routes() {
|
||||||
|
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()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/runtime/puzzle/runs")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
let body = read_json_response(response).await;
|
||||||
|
assert_ne!(
|
||||||
|
body["error"]["details"]["reason"],
|
||||||
|
"creation_entry_disabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
|
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
|
||||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||||
@@ -789,7 +815,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn disabled_rpg_route_returns_service_unavailable() {
|
async fn disabled_rpg_creation_route_returns_service_unavailable() {
|
||||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||||
state.set_test_creation_entry_route_enabled("rpg", false);
|
state.set_test_creation_entry_route_enabled("rpg", false);
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pub async fn get_creation_entry_config_handler(
|
|||||||
Ok(json_success_body(Some(&request_context), config))
|
Ok(json_success_body(Some(&request_context), config))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 中文注释:api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。
|
/// 中文注释:api-server 路由熔断只拦新建创作入口,不限制已有作品读取、发布作品游玩或公开广场浏览。
|
||||||
pub async fn require_creation_entry_route_enabled(
|
pub async fn require_creation_entry_route_enabled(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
request: Request<Body>,
|
request: Request<Body>,
|
||||||
@@ -72,54 +72,56 @@ pub async fn require_creation_entry_route_enabled(
|
|||||||
|
|
||||||
pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||||
let normalized = path.trim_end_matches('/');
|
let normalized = path.trim_end_matches('/');
|
||||||
if normalized.starts_with("/api/runtime/puzzle") {
|
if normalized == "/api/runtime/puzzle/agent/sessions"
|
||||||
|
|| normalized == "/api/runtime/puzzle/onboarding/generate"
|
||||||
|
{
|
||||||
return Some("puzzle");
|
return Some("puzzle");
|
||||||
}
|
}
|
||||||
if normalized.starts_with("/api/runtime/match3d") {
|
if normalized.starts_with("/api/runtime/puzzle/gallery/")
|
||||||
return Some("match3d");
|
&& normalized.ends_with("/remix")
|
||||||
|
{
|
||||||
|
return Some("puzzle");
|
||||||
}
|
}
|
||||||
if normalized.starts_with("/api/runtime/bark-battle") {
|
if normalized == "/api/runtime/big-fish/agent/sessions" {
|
||||||
return Some("bark-battle");
|
|
||||||
}
|
|
||||||
if normalized.starts_with("/api/creation/bark-battle") {
|
|
||||||
return Some("bark-battle");
|
|
||||||
}
|
|
||||||
if normalized.starts_with("/api/runtime/wooden-fish") {
|
|
||||||
return Some("wooden-fish");
|
|
||||||
}
|
|
||||||
if normalized.starts_with("/api/creation/wooden-fish") {
|
|
||||||
return Some("wooden-fish");
|
|
||||||
}
|
|
||||||
if normalized.starts_with("/api/runtime/square-hole") {
|
|
||||||
return Some("square-hole");
|
|
||||||
}
|
|
||||||
if normalized.starts_with("/api/runtime/jump-hop") {
|
|
||||||
return Some("jump-hop");
|
|
||||||
}
|
|
||||||
if normalized.starts_with("/api/creation/jump-hop") {
|
|
||||||
return Some("jump-hop");
|
|
||||||
}
|
|
||||||
if normalized.starts_with("/api/runtime/big-fish") {
|
|
||||||
return Some("big-fish");
|
return Some("big-fish");
|
||||||
}
|
}
|
||||||
if normalized.starts_with("/api/runtime/custom-world")
|
if normalized.starts_with("/api/runtime/big-fish/gallery/")
|
||||||
|| normalized.starts_with("/api/runtime/custom-world-library")
|
&& normalized.ends_with("/remix")
|
||||||
|| normalized.starts_with("/api/runtime/custom-world-gallery")
|
{
|
||||||
|| normalized.starts_with("/api/runtime/chat")
|
return Some("big-fish");
|
||||||
|| normalized.starts_with("/api/story")
|
}
|
||||||
|
if normalized == "/api/runtime/custom-world/agent/sessions"
|
||||||
|
|| normalized == "/api/runtime/custom-world/profile"
|
||||||
{
|
{
|
||||||
return Some("rpg");
|
return Some("rpg");
|
||||||
}
|
}
|
||||||
if normalized.starts_with("/api/runtime/visual-novel") {
|
if normalized.starts_with("/api/runtime/custom-world-gallery/")
|
||||||
|
&& normalized.ends_with("/remix")
|
||||||
|
{
|
||||||
|
return Some("rpg");
|
||||||
|
}
|
||||||
|
if normalized == "/api/creation/match3d/sessions" {
|
||||||
|
return Some("match3d");
|
||||||
|
}
|
||||||
|
if normalized == "/api/creation/square-hole/sessions" {
|
||||||
|
return Some("square-hole");
|
||||||
|
}
|
||||||
|
if normalized == "/api/creation/bark-battle/drafts" {
|
||||||
|
return Some("bark-battle");
|
||||||
|
}
|
||||||
|
if normalized == "/api/creation/wooden-fish/sessions" {
|
||||||
|
return Some("wooden-fish");
|
||||||
|
}
|
||||||
|
if normalized == "/api/creation/jump-hop/sessions" {
|
||||||
|
return Some("jump-hop");
|
||||||
|
}
|
||||||
|
if normalized == "/api/creation/visual-novel/sessions" {
|
||||||
return Some("visual-novel");
|
return Some("visual-novel");
|
||||||
}
|
}
|
||||||
if normalized.starts_with("/api/creation/visual-novel") {
|
if normalized == "/api/creation/edutainment/baby-object-match/assets" {
|
||||||
return Some("visual-novel");
|
|
||||||
}
|
|
||||||
if normalized.starts_with("/api/creation/edutainment/baby-object-match") {
|
|
||||||
return Some("baby-object-match");
|
return Some("baby-object-match");
|
||||||
}
|
}
|
||||||
if normalized.starts_with("/api/creation/edutainment/baby-love-drawing") {
|
if normalized == "/api/creation/edutainment/baby-love-drawing/magic" {
|
||||||
return Some("baby-love-drawing");
|
return Some("baby-love-drawing");
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -171,58 +173,68 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_runtime_paths_to_creation_type_ids() {
|
fn resolves_new_creation_paths_to_creation_type_ids() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/puzzle/works"),
|
resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"),
|
||||||
Some("puzzle"),
|
Some("puzzle"),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/match3d/runs/run-1"),
|
resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"),
|
||||||
|
Some("puzzle"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/creation/match3d/sessions"),
|
||||||
Some("match3d"),
|
Some("match3d"),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/square-hole/runs/run-1"),
|
resolve_creation_entry_route_id("/api/creation/square-hole/sessions"),
|
||||||
Some("square-hole"),
|
Some("square-hole"),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
resolve_creation_entry_route_id("/api/runtime/visual-novel/works"),
|
|
||||||
Some("visual-novel"),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||||
Some("visual-novel"),
|
Some("visual-novel"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/runtime/big-fish/agent/sessions"),
|
||||||
|
Some("big-fish"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
|
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
|
||||||
Some("rpg"),
|
Some("rpg"),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
|
resolve_creation_entry_route_id(
|
||||||
|
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
|
||||||
|
),
|
||||||
Some("rpg"),
|
Some("rpg"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
|
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
|
||||||
Some("rpg"),
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
|
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
|
||||||
Some("rpg"),
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
|
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
|
||||||
Some("rpg"),
|
None,
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
|
||||||
Some("bark-battle"),
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"),
|
resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"),
|
||||||
Some("bark-battle"),
|
Some("bark-battle"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
|
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
|
||||||
Some("wooden-fish"),
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),
|
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ test('creation start card renders reference-aligned banner and template metadata
|
|||||||
expect(html).toContain('拼图关卡创作');
|
expect(html).toContain('拼图关卡创作');
|
||||||
expect(html).toContain('10-20泥点数');
|
expect(html).toContain('10-20泥点数');
|
||||||
expect(html).toContain('即将开放');
|
expect(html).toContain('即将开放');
|
||||||
|
expect(html).toContain('data-locked="true"');
|
||||||
|
expect(html).toContain('暂未开放');
|
||||||
expect(html).not.toContain('可创建');
|
expect(html).not.toContain('可创建');
|
||||||
expect(html).not.toContain('可创作');
|
expect(html).not.toContain('可创作');
|
||||||
expect(html).not.toContain('creation-event-banner__counter');
|
expect(html).not.toContain('creation-event-banner__counter');
|
||||||
@@ -250,6 +252,49 @@ test('creation start card renders reference-aligned banner and template metadata
|
|||||||
expect(html).not.toContain('platform-creation-reference-card');
|
expect(html).not.toContain('platform-creation-reference-card');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('locked creation template card replaces mud point cost with unavailable state', () => {
|
||||||
|
const lockedEntryConfig = {
|
||||||
|
...testEntryConfig,
|
||||||
|
creationTypes: [
|
||||||
|
{
|
||||||
|
id: 'airp',
|
||||||
|
title: 'AI RPG',
|
||||||
|
subtitle: '原生角色扮演',
|
||||||
|
badge: '即将开放',
|
||||||
|
imageSrc: '/creation-type-references/airp.webp',
|
||||||
|
visible: true,
|
||||||
|
open: false,
|
||||||
|
sortOrder: 70,
|
||||||
|
categoryId: 'recommended',
|
||||||
|
categoryLabel: '热门推荐',
|
||||||
|
categorySortOrder: 20,
|
||||||
|
updatedAtMicros: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies CreationEntryConfig;
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CustomWorldCreationHub
|
||||||
|
items={[]}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
onRetry={() => {}}
|
||||||
|
onCreateType={noopCreateType}
|
||||||
|
onOpenDraft={() => {}}
|
||||||
|
onEnterPublished={() => {}}
|
||||||
|
entryConfig={lockedEntryConfig}
|
||||||
|
creationTypes={derivePlatformCreationTypes(
|
||||||
|
lockedEntryConfig.creationTypes,
|
||||||
|
)}
|
||||||
|
mode="start-only"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('data-locked="true"');
|
||||||
|
expect(html).toContain('即将开放');
|
||||||
|
expect(html).toContain('暂未开放');
|
||||||
|
expect(html).not.toContain('10-20泥点数');
|
||||||
|
});
|
||||||
|
|
||||||
test('creation start card falls back to legacy single banner when eventBanners is empty', () => {
|
test('creation start card falls back to legacy single banner when eventBanners is empty', () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<CustomWorldCreationHub
|
<CustomWorldCreationHub
|
||||||
@@ -301,7 +346,9 @@ test('creation start card renders html banner in an empty-permission sandbox', (
|
|||||||
|
|
||||||
expect(html).toContain('title="HTML 后台横幅"');
|
expect(html).toContain('title="HTML 后台横幅"');
|
||||||
expect(html).toContain('sandbox=""');
|
expect(html).toContain('sandbox=""');
|
||||||
expect(html).toContain('<section><h1>自定义横幅</h1></section>');
|
expect(html).toContain(
|
||||||
|
'<section><h1>自定义横幅</h1></section>',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creation start card renders recent tab with the same template cards', () => {
|
test('creation start card renders recent tab with the same template cards', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Coins, Trophy } from 'lucide-react';
|
import { Coins, LockKeyhole, Trophy } from 'lucide-react';
|
||||||
import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react';
|
import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -100,17 +100,17 @@ export function CustomWorldCreationStartCard({
|
|||||||
activeCategoryId ??
|
activeCategoryId ??
|
||||||
(hasRecentCreationTypes
|
(hasRecentCreationTypes
|
||||||
? CREATION_ENTRY_RECENT_TAB_ID
|
? CREATION_ENTRY_RECENT_TAB_ID
|
||||||
: creationTypeGroups[0]?.id ?? null);
|
: (creationTypeGroups[0]?.id ?? null));
|
||||||
const isRecentTabActive =
|
const isRecentTabActive =
|
||||||
hasRecentCreationTypes && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
|
hasRecentCreationTypes && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
|
||||||
const activeGroup = isRecentTabActive
|
const activeGroup = isRecentTabActive
|
||||||
? null
|
? null
|
||||||
: creationTypeGroups.find((group) => group.id === activeTabId) ??
|
: (creationTypeGroups.find((group) => group.id === activeTabId) ??
|
||||||
creationTypeGroups[0] ??
|
creationTypeGroups[0] ??
|
||||||
null;
|
null);
|
||||||
const visibleCreationTypes = isRecentTabActive
|
const visibleCreationTypes = isRecentTabActive
|
||||||
? recentCreationTypes
|
? recentCreationTypes
|
||||||
: activeGroup?.items ?? [];
|
: (activeGroup?.items ?? []);
|
||||||
const eventBanners = useMemo(
|
const eventBanners = useMemo(
|
||||||
() => resolveCreationEntryEventBanners(entryConfig),
|
() => resolveCreationEntryEventBanners(entryConfig),
|
||||||
[entryConfig],
|
[entryConfig],
|
||||||
@@ -318,18 +318,20 @@ export function CustomWorldCreationStartCard({
|
|||||||
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
||||||
{visibleCreationTypes.map((item) => {
|
{visibleCreationTypes.map((item) => {
|
||||||
const disabled = item.locked || busy;
|
const disabled = item.locked || busy;
|
||||||
|
const lockedBadge = item.badge.trim() || '暂未开放';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
data-locked={item.locked ? 'true' : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCreateType(item.id);
|
onCreateType(item.id);
|
||||||
}}
|
}}
|
||||||
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
|
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
|
||||||
item.locked
|
item.locked
|
||||||
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
|
? 'cursor-not-allowed border-[#d9ccc2] text-[#725b4d] shadow-[inset_0_0_0_1px_rgba(111,78,61,0.08)]'
|
||||||
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
|
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
|
||||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -337,25 +339,70 @@ export function CustomWorldCreationStartCard({
|
|||||||
<img
|
<img
|
||||||
src={item.imageSrc}
|
src={item.imageSrc}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover"
|
className={`h-full w-full object-cover ${
|
||||||
|
item.locked
|
||||||
|
? 'scale-[1.01] grayscale-[0.62] saturate-[0.55] brightness-[0.82]'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{shouldShowCreationBadge(item.badge) ? (
|
{item.locked ? (
|
||||||
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(52,36,27,0.22)_0%,rgba(52,36,27,0.52)_100%)]" />
|
||||||
{item.badge}
|
) : null}
|
||||||
|
{item.locked || shouldShowCreationBadge(item.badge) ? (
|
||||||
|
<span
|
||||||
|
className={`absolute left-2 top-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full px-2 py-0.5 text-xs font-black shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1 ${
|
||||||
|
item.locked
|
||||||
|
? 'bg-[#3f3129]/90 text-white'
|
||||||
|
: 'bg-[#b66a3e] text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.locked ? <LockKeyhole className="h-3 w-3" /> : null}
|
||||||
|
{item.locked ? lockedBadge : item.badge}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
|
{item.locked ? (
|
||||||
<Coins className="h-3 w-3 shrink-0" />
|
<span className="absolute left-1/2 top-1/2 inline-flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white/88 text-[#5d4639] shadow-[0_12px_24px_rgba(46,31,23,0.22)]">
|
||||||
<span className="truncate">10-20泥点数</span>
|
<LockKeyhole className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className={`creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full px-2 py-1 text-[11px] font-black leading-4 shadow-[0_8px_18px_rgba(119,72,44,0.16)] ${
|
||||||
|
item.locked
|
||||||
|
? 'bg-[#3f3129]/88 text-white'
|
||||||
|
: 'bg-[#fff7ec]/92 text-[#b65f2c]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.locked ? (
|
||||||
|
<LockKeyhole className="h-3 w-3 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Coins className="h-3 w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">
|
||||||
|
{item.locked ? '暂未开放' : '10-20泥点数'}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
|
<div
|
||||||
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
|
className={`creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col px-2.5 pb-2.5 pt-2.5 sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5 ${
|
||||||
|
item.locked
|
||||||
|
? 'bg-[#f3ece6] text-[#725b4d]'
|
||||||
|
: 'bg-white text-[#2f211b]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`creation-template-card__title line-clamp-1 text-sm font-black leading-5 ${
|
||||||
|
item.locked ? 'text-[#5d4639]' : 'text-[#2f211b]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
|
<div
|
||||||
|
className={`creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 sm:leading-5 ${
|
||||||
|
item.locked ? 'text-[#8a766a]' : 'text-[#6f5a4c]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{item.subtitle}
|
{item.subtitle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight, LockKeyhole } from 'lucide-react';
|
||||||
|
|
||||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||||
import { UnifiedModal } from '../common/UnifiedModal';
|
import { UnifiedModal } from '../common/UnifiedModal';
|
||||||
@@ -33,6 +33,7 @@ function CreationTypeCard(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { item, busy, onSelect } = props;
|
const { item, busy, onSelect } = props;
|
||||||
const disabled = item.locked || busy;
|
const disabled = item.locked || busy;
|
||||||
|
const lockedBadge = item.badge.trim() || '暂未开放';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -60,12 +61,15 @@ function CreationTypeCard(props: {
|
|||||||
/>
|
/>
|
||||||
<div className="relative z-10 flex min-h-6 items-start justify-end gap-3 px-4 pt-4">
|
<div className="relative z-10 flex min-h-6 items-start justify-end gap-3 px-4 pt-4">
|
||||||
{item.locked ? (
|
{item.locked ? (
|
||||||
<span className="platform-pill platform-pill--neutral px-3 text-[var(--platform-text-soft)]">
|
<span className="platform-pill platform-pill--neutral gap-1 px-3 text-[var(--platform-text-soft)]">
|
||||||
{item.badge}
|
<LockKeyhole className="h-3.5 w-3.5" />
|
||||||
|
{lockedBadge}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{item.locked ? (
|
{item.locked ? (
|
||||||
<span className="text-lg leading-none text-white/62">·</span>
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/18 text-white/72">
|
||||||
|
<LockKeyhole className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||||
)}
|
)}
|
||||||
@@ -169,7 +173,6 @@ export function PlatformEntryCreationTypeModal({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</UnifiedModal>
|
</UnifiedModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2150,6 +2150,19 @@ function normalizePlatformErrorMessage(message: string | null | undefined) {
|
|||||||
return normalized ? normalized : null;
|
return normalized ? normalized : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 首页公开数据降级时,入口关闭错误不弹窗;真实创作动作仍由对应工作台提示。 */
|
||||||
|
function isCreationEntryDisabledErrorMessage(
|
||||||
|
message: string | null | undefined,
|
||||||
|
) {
|
||||||
|
const normalized = normalizePlatformErrorMessage(message);
|
||||||
|
return Boolean(
|
||||||
|
normalized &&
|
||||||
|
(normalized.includes('creation_entry_disabled') ||
|
||||||
|
normalized.includes('该玩法入口暂不可用') ||
|
||||||
|
normalized.includes('创作入口已关闭')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatPlatformErrorSource(label: string, id?: string | null) {
|
function formatPlatformErrorSource(label: string, id?: string | null) {
|
||||||
const normalizedId = id?.trim();
|
const normalizedId = id?.trim();
|
||||||
return normalizedId ? `${label} ${normalizedId}` : label;
|
return normalizedId ? `${label} ${normalizedId}` : label;
|
||||||
@@ -6747,6 +6760,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isMiniGameDraftGenerating(
|
isMiniGameDraftGenerating(
|
||||||
activePuzzleBackgroundCompileTask?.generationState ?? null,
|
activePuzzleBackgroundCompileTask?.generationState ?? null,
|
||||||
);
|
);
|
||||||
|
const platformBootstrapErrorForDisplay = isCreationEntryDisabledErrorMessage(
|
||||||
|
platformBootstrap.platformError,
|
||||||
|
)
|
||||||
|
? null
|
||||||
|
: platformBootstrap.platformError;
|
||||||
const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] =
|
const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] =
|
||||||
useState<string | null>(null);
|
useState<string | null>(null);
|
||||||
const [
|
const [
|
||||||
@@ -6774,7 +6792,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
{
|
{
|
||||||
key: 'platform-bootstrap',
|
key: 'platform-bootstrap',
|
||||||
source: '平台首页',
|
source: '平台首页',
|
||||||
message: platformBootstrap.platformError,
|
message: platformBootstrapErrorForDisplay,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'rpg-creation-type',
|
key: 'rpg-creation-type',
|
||||||
@@ -6954,7 +6972,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
match3dRun?.runId,
|
match3dRun?.runId,
|
||||||
match3dSession?.sessionId,
|
match3dSession?.sessionId,
|
||||||
pendingPlatformTaskFailureDialog,
|
pendingPlatformTaskFailureDialog,
|
||||||
platformBootstrap.platformError,
|
platformBootstrapErrorForDisplay,
|
||||||
publicWorkDetailError,
|
publicWorkDetailError,
|
||||||
puzzleCreationError,
|
puzzleCreationError,
|
||||||
puzzleError,
|
puzzleError,
|
||||||
@@ -16352,7 +16370,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
platformError={
|
platformError={
|
||||||
platformBootstrap.isLoadingPlatform
|
platformBootstrap.isLoadingPlatform
|
||||||
? null
|
? null
|
||||||
: (platformBootstrap.platformError ??
|
: (platformBootstrapErrorForDisplay ??
|
||||||
sessionController.agentWorkspaceRestoreError)
|
sessionController.agentWorkspaceRestoreError)
|
||||||
}
|
}
|
||||||
dashboardError={
|
dashboardError={
|
||||||
|
|||||||
@@ -6782,6 +6782,27 @@ test('creation draft hub skips visual novel shelves when entry is not open', asy
|
|||||||
expect(screen.queryByText('该玩法入口暂不可用')).toBeNull();
|
expect(screen.queryByText('该玩法入口暂不可用')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('platform home suppresses creation entry disabled bootstrap errors', async () => {
|
||||||
|
vi.mocked(listRpgEntryWorldLibrary).mockRejectedValue(
|
||||||
|
new Error(
|
||||||
|
'该玩法入口暂不可用 creation_entry_disabled(requestId: req-closed)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.mocked(listRpgCreationWorks).mockRejectedValue(
|
||||||
|
new Error('creation_entry_disabled'),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(listRpgEntryWorldLibrary).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText(/平台首页/u)).toBeNull();
|
||||||
|
expect(screen.queryByText(/creation_entry_disabled/u)).toBeNull();
|
||||||
|
expect(screen.queryByText(/该玩法入口暂不可用/u)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('published puzzle works appear on home and mobile game category channel', async () => {
|
test('published puzzle works appear on home and mobile game category channel', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const publishedPuzzleWork = {
|
const publishedPuzzleWork = {
|
||||||
|
|||||||
Reference in New Issue
Block a user