fix: 收窄创作入口关闭熔断范围

This commit is contained in:
2026-06-03 20:45:27 +08:00
parent 01a68bcf3d
commit deadce9cf1
9 changed files with 267 additions and 81 deletions

View File

@@ -243,6 +243,8 @@ test('creation start card renders reference-aligned banner and template metadata
expect(html).toContain('拼图关卡创作');
expect(html).toContain('10-20泥点数');
expect(html).toContain('即将开放');
expect(html).toContain('data-locked="true"');
expect(html).toContain('暂未开放');
expect(html).not.toContain('可创建');
expect(html).not.toContain('可创作');
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');
});
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', () => {
const html = renderToStaticMarkup(
<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('sandbox=""');
expect(html).toContain('&lt;section&gt;&lt;h1&gt;自定义横幅&lt;/h1&gt;&lt;/section&gt;');
expect(html).toContain(
'&lt;section&gt;&lt;h1&gt;自定义横幅&lt;/h1&gt;&lt;/section&gt;',
);
});
test('creation start card renders recent tab with the same template cards', () => {

View File

@@ -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 {
@@ -100,17 +100,17 @@ export function CustomWorldCreationStartCard({
activeCategoryId ??
(hasRecentCreationTypes
? CREATION_ENTRY_RECENT_TAB_ID
: creationTypeGroups[0]?.id ?? null);
: (creationTypeGroups[0]?.id ?? null));
const isRecentTabActive =
hasRecentCreationTypes && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
const activeGroup = isRecentTabActive
? null
: creationTypeGroups.find((group) => group.id === activeTabId) ??
: (creationTypeGroups.find((group) => group.id === activeTabId) ??
creationTypeGroups[0] ??
null;
null);
const visibleCreationTypes = isRecentTabActive
? recentCreationTypes
: activeGroup?.items ?? [];
: (activeGroup?.items ?? []);
const eventBanners = useMemo(
() => resolveCreationEntryEventBanners(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">
{visibleCreationTypes.map((item) => {
const disabled = item.locked || busy;
const lockedBadge = item.badge.trim() || '暂未开放';
return (
<button
key={item.id}
type="button"
disabled={disabled}
data-locked={item.locked ? 'true' : undefined}
onClick={() => {
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] ${
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)]'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
@@ -337,25 +339,70 @@ export function CustomWorldCreationStartCard({
<img
src={item.imageSrc}
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"
/>
{shouldShowCreationBadge(item.badge) ? (
<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">
{item.badge}
{item.locked ? (
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(52,36,27,0.22)_0%,rgba(52,36,27,0.52)_100%)]" />
) : 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>
) : 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)]">
<Coins className="h-3 w-3 shrink-0" />
<span className="truncate">10-20</span>
{item.locked ? (
<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)]">
<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>
</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 className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
<div
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}
</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}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { ArrowRight } from 'lucide-react';
import { ArrowRight, LockKeyhole } from 'lucide-react';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { UnifiedModal } from '../common/UnifiedModal';
@@ -33,6 +33,7 @@ function CreationTypeCard(props: {
}) {
const { item, busy, onSelect } = props;
const disabled = item.locked || busy;
const lockedBadge = item.badge.trim() || '暂未开放';
return (
<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">
{item.locked ? (
<span className="platform-pill platform-pill--neutral px-3 text-[var(--platform-text-soft)]">
{item.badge}
<span className="platform-pill platform-pill--neutral gap-1 px-3 text-[var(--platform-text-soft)]">
<LockKeyhole className="h-3.5 w-3.5" />
{lockedBadge}
</span>
) : null}
{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" />
)}
@@ -169,7 +173,6 @@ export function PlatformEntryCreationTypeModal({
/>
))}
</div>
</UnifiedModal>
);
}

View File

@@ -2150,6 +2150,19 @@ function normalizePlatformErrorMessage(message: string | null | undefined) {
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) {
const normalizedId = id?.trim();
return normalizedId ? `${label} ${normalizedId}` : label;
@@ -6747,6 +6760,11 @@ export function PlatformEntryFlowShellImpl({
isMiniGameDraftGenerating(
activePuzzleBackgroundCompileTask?.generationState ?? null,
);
const platformBootstrapErrorForDisplay = isCreationEntryDisabledErrorMessage(
platformBootstrap.platformError,
)
? null
: platformBootstrap.platformError;
const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] =
useState<string | null>(null);
const [
@@ -6774,7 +6792,7 @@ export function PlatformEntryFlowShellImpl({
{
key: 'platform-bootstrap',
source: '平台首页',
message: platformBootstrap.platformError,
message: platformBootstrapErrorForDisplay,
},
{
key: 'rpg-creation-type',
@@ -6954,7 +6972,7 @@ export function PlatformEntryFlowShellImpl({
match3dRun?.runId,
match3dSession?.sessionId,
pendingPlatformTaskFailureDialog,
platformBootstrap.platformError,
platformBootstrapErrorForDisplay,
publicWorkDetailError,
puzzleCreationError,
puzzleError,
@@ -16352,7 +16370,7 @@ export function PlatformEntryFlowShellImpl({
platformError={
platformBootstrap.isLoadingPlatform
? null
: (platformBootstrap.platformError ??
: (platformBootstrapErrorForDisplay ??
sessionController.agentWorkspaceRestoreError)
}
dashboardError={

View File

@@ -6782,6 +6782,27 @@ test('creation draft hub skips visual novel shelves when entry is not open', asy
expect(screen.queryByText('该玩法入口暂不可用')).toBeNull();
});
test('platform home suppresses creation entry disabled bootstrap errors', async () => {
vi.mocked(listRpgEntryWorldLibrary).mockRejectedValue(
new Error(
'该玩法入口暂不可用 creation_entry_disabledrequestId: 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 () => {
const user = userEvent.setup();
const publishedPuzzleWork = {