Merge branch 'master' into codex/sse-stream-architecture
# Conflicts: # .hermes/shared-memory/decision-log.md # docs/【玩法创作】平台入口与玩法链路-2026-05-15.md # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
@@ -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('<section><h1>自定义横幅</h1></section>');
|
||||
expect(html).toContain(
|
||||
'<section><h1>自定义横幅</h1></section>',
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -414,6 +414,7 @@ import {
|
||||
buildPlatformTaskCompletionDialogDismissKey,
|
||||
formatPlatformDialogSource,
|
||||
isBackgroundGenerationStillRunningMessage,
|
||||
normalizePlatformDialogMessage,
|
||||
PLATFORM_TASK_COMPLETION_MESSAGE,
|
||||
type PlatformDialogCandidate,
|
||||
type PlatformErrorDialogState,
|
||||
@@ -561,6 +562,7 @@ import {
|
||||
buildPlatformPublicGalleryFeeds,
|
||||
getPlatformPublicGalleryEntryKey,
|
||||
getPlatformRecommendRuntimeKind,
|
||||
isPlatformRecommendRuntimeReadyForEntry,
|
||||
isSamePlatformPublicGalleryEntry,
|
||||
type RecommendRuntimeKind,
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision,
|
||||
@@ -744,6 +746,19 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
|
||||
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
|
||||
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
|
||||
|
||||
/** 中文注释:首页公开数据降级时,入口关闭错误不弹窗;真实创作动作仍由对应工作台提示。 */
|
||||
function isCreationEntryDisabledErrorMessage(
|
||||
message: string | null | undefined,
|
||||
) {
|
||||
const normalized = normalizePlatformDialogMessage(message);
|
||||
return Boolean(
|
||||
normalized &&
|
||||
(normalized.includes('creation_entry_disabled') ||
|
||||
normalized.includes('该玩法入口暂不可用') ||
|
||||
normalized.includes('创作入口已关闭')),
|
||||
);
|
||||
}
|
||||
|
||||
const PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY =
|
||||
'genarrative.puzzle-onboarding.first-visit.v1';
|
||||
const PUZZLE_ONBOARDING_COPY = '待定待定待定';
|
||||
@@ -4185,6 +4200,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
isMiniGameDraftGenerating(
|
||||
activePuzzleBackgroundCompileTask?.generationState ?? null,
|
||||
);
|
||||
const platformBootstrapErrorForDisplay = isCreationEntryDisabledErrorMessage(
|
||||
platformBootstrap.platformError,
|
||||
)
|
||||
? null
|
||||
: platformBootstrap.platformError;
|
||||
const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] =
|
||||
useState<string | null>(null);
|
||||
const [
|
||||
@@ -4207,7 +4227,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
{
|
||||
key: 'platform-bootstrap',
|
||||
source: '平台首页',
|
||||
message: platformBootstrap.platformError,
|
||||
message: platformBootstrapErrorForDisplay,
|
||||
},
|
||||
{
|
||||
key: 'rpg-creation-type',
|
||||
@@ -4378,7 +4398,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
match3dRun?.runId,
|
||||
match3dSession?.sessionId,
|
||||
pendingPlatformTaskFailureDialog,
|
||||
platformBootstrap.platformError,
|
||||
platformBootstrapErrorForDisplay,
|
||||
publicWorkDetailError,
|
||||
puzzleCreationError,
|
||||
puzzleError,
|
||||
@@ -12011,6 +12031,29 @@ export function PlatformEntryFlowShellImpl({
|
||||
isDesktopLayout,
|
||||
]);
|
||||
|
||||
const activeRecommendEntry =
|
||||
activeRecommendEntryKey && !isDesktopLayout
|
||||
? (recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) ===
|
||||
activeRecommendEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
activeRecommendEntry !== null &&
|
||||
isPlatformRecommendRuntimeReadyForEntry(activeRecommendEntry, {
|
||||
activeKind: activeRecommendRuntimeKind,
|
||||
hasBabyObjectMatchDraft: Boolean(babyObjectMatchDraft),
|
||||
hasBigFishRun: Boolean(bigFishRun),
|
||||
hasJumpHopRun: Boolean(jumpHopRun),
|
||||
hasMatch3DRun: Boolean(match3dRun),
|
||||
hasSquareHoleRun: Boolean(squareHoleRun),
|
||||
hasVisualNovelRun: Boolean(visualNovelRun),
|
||||
hasWoodenFishRun: Boolean(woodenFishRun),
|
||||
puzzleRunEntryProfileId: puzzleRun?.entryProfileId ?? null,
|
||||
puzzleRunCurrentLevelProfileId: puzzleRun?.currentLevel?.profileId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const decision = resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
isDesktopLayout,
|
||||
@@ -12972,7 +13015,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
(isVisualNovelCreationOpen && isVisualNovelLoadingLibrary) ||
|
||||
isBabyObjectMatchBusy
|
||||
? null
|
||||
: (platformBootstrap.platformError ??
|
||||
: (platformBootstrapErrorForDisplay ??
|
||||
sessionController.agentWorkspaceRestoreError ??
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
@@ -13093,7 +13136,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
platformError={
|
||||
platformBootstrap.isLoadingPlatform
|
||||
? null
|
||||
: (platformBootstrap.platformError ??
|
||||
: (platformBootstrapErrorForDisplay ??
|
||||
sessionController.agentWorkspaceRestoreError)
|
||||
}
|
||||
dashboardError={
|
||||
@@ -13122,6 +13165,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
|
||||
isStartingRecommendEntry={
|
||||
isStartingRecommendEntry ||
|
||||
isBigFishBusy ||
|
||||
|
||||
@@ -58,6 +58,22 @@ describe('PlatformErrorDialog', () => {
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
|
||||
});
|
||||
|
||||
test('does not render creation entry disabled errors', () => {
|
||||
render(
|
||||
<PlatformErrorDialog
|
||||
error={{
|
||||
source: '大鱼草稿',
|
||||
message:
|
||||
'creation_entry_disabled(requestId: req-big-fish-gallery)',
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
|
||||
expect(screen.queryByText(/creation_entry_disabled/u)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlatformTaskCompletionDialog', () => {
|
||||
|
||||
@@ -20,6 +20,11 @@ function buildPlatformErrorReport(error: PlatformErrorDialogPayload) {
|
||||
return [`来源:${error.source}`, `错误:${error.message}`].join('\n');
|
||||
}
|
||||
|
||||
function isBlacklistedPlatformError(error: PlatformErrorDialogPayload | null) {
|
||||
// 中文注释:入口关闭是平台开关状态,不作为全局错误弹窗打扰用户。
|
||||
return Boolean(error?.message.includes('creation_entry_disabled'));
|
||||
}
|
||||
|
||||
export function PlatformErrorDialog({
|
||||
error,
|
||||
onClose,
|
||||
@@ -30,9 +35,10 @@ export function PlatformErrorDialog({
|
||||
'idle',
|
||||
);
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const dialogError = isBlacklistedPlatformError(error) ? null : error;
|
||||
const reportText = useMemo(
|
||||
() => (error ? buildPlatformErrorReport(error) : ''),
|
||||
[error],
|
||||
() => (dialogError ? buildPlatformErrorReport(dialogError) : ''),
|
||||
[dialogError],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -46,7 +52,7 @@ export function PlatformErrorDialog({
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
}, [error?.source, error?.message]);
|
||||
}, [dialogError?.source, dialogError?.message]);
|
||||
|
||||
const copyError = () => {
|
||||
if (!reportText) {
|
||||
@@ -67,7 +73,7 @@ export function PlatformErrorDialog({
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={Boolean(error)}
|
||||
open={Boolean(dialogError)}
|
||||
title="发生错误"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
@@ -95,14 +101,14 @@ export function PlatformErrorDialog({
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
{dialogError ? (
|
||||
<>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
来源
|
||||
</div>
|
||||
<div className="mt-1 break-words text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
|
||||
{error.source}
|
||||
{dialogError.source}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
||||
@@ -110,7 +116,7 @@ export function PlatformErrorDialog({
|
||||
错误
|
||||
</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{error.message}
|
||||
{dialogError.message}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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_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 () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
|
||||
@@ -827,6 +827,7 @@ function renderLoggedOutHomeView(
|
||||
| 'recommendRuntimeContent'
|
||||
| 'activeRecommendEntryKey'
|
||||
| 'isStartingRecommendEntry'
|
||||
| 'isRecommendRuntimeReady'
|
||||
| 'recommendRuntimeError'
|
||||
| 'onSelectNextRecommendEntry'
|
||||
| 'onSelectPreviousRecommendEntry'
|
||||
@@ -887,6 +888,7 @@ function renderLoggedOutHomeView(
|
||||
}
|
||||
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
||||
isRecommendRuntimeReady={overrides.isRecommendRuntimeReady}
|
||||
recommendRuntimeError={overrides.recommendRuntimeError}
|
||||
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={
|
||||
@@ -3142,6 +3144,41 @@ test('logged in create tab shows real wallet balance beside the brand', () => {
|
||||
expect(topbar?.textContent).toContain('1,234泥点');
|
||||
});
|
||||
|
||||
test('create tab wallet chip opens reward code when recharge entry is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockNarrowMobileLayout();
|
||||
|
||||
render(
|
||||
<ProfileHomeViewHarness
|
||||
activeTab="create"
|
||||
profileDashboardOverrides={{ walletBalance: 70 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^70泥点$/u }));
|
||||
|
||||
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('create tab wallet chip opens recharge when recharge entry is enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockWechatDesktopLayout();
|
||||
|
||||
render(
|
||||
<ProfileHomeViewHarness
|
||||
activeTab="create"
|
||||
profileDashboardOverrides={{ walletBalance: 70 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^70泥点$/u }));
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull();
|
||||
});
|
||||
|
||||
test('mobile discover search submits public work code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
@@ -3673,7 +3710,10 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.platform-public-work-card__cover'),
|
||||
).toBeNull();
|
||||
@@ -3682,7 +3722,7 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile recommend loading state is themed instead of hardcoded black', () => {
|
||||
test('mobile recommend startup keeps cover visible without loading copy', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
@@ -3690,8 +3730,123 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
|
||||
recommendRuntimeContent: null,
|
||||
});
|
||||
|
||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||
expect(screen.getByText('加载中...')).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mobile recommend next level keeps runtime visual stable when active work changes', async () => {
|
||||
const animationCallbacks: FrameRequestCallback[] = [];
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn((callback: FrameRequestCallback) => {
|
||||
animationCallbacks.push(callback);
|
||||
return animationCallbacks.length;
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
const firstEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-feed-1',
|
||||
profileId: 'puzzle-profile-feed-1',
|
||||
ownerUserId: 'user-feed-1',
|
||||
publicWorkCode: 'PZ-FEED1',
|
||||
worldName: '当前拼图',
|
||||
coverImageSrc: 'current-cover.png',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
const similarEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-similar-1',
|
||||
profileId: 'puzzle-profile-similar-1',
|
||||
ownerUserId: 'user-feed-2',
|
||||
publicWorkCode: 'PZ-SIMILAR1',
|
||||
worldName: '相似拼图',
|
||||
coverImageSrc: 'similar-cover.png',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const { rerender } = renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [firstEntry, similarEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1',
|
||||
isRecommendRuntimeReady: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
animationCallbacks.splice(0).forEach((callback) => callback(16));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||
).toContain('platform-recommend-runtime-cover--hidden');
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
isDesktopLayout={false}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[firstEntry, similarEntry]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
|
||||
activeRecommendEntryKey="puzzle:user-feed-2:puzzle-profile-similar-1"
|
||||
isRecommendRuntimeReady
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const rail = document.querySelector(
|
||||
'.platform-recommend-swipe-rail',
|
||||
) as HTMLElement | null;
|
||||
expect(rail?.className).toContain('platform-recommend-swipe-rail--settled');
|
||||
expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)');
|
||||
expect(screen.getByLabelText('相似拼图 作品信息')).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||
).toContain('platform-recommend-runtime-cover--hidden');
|
||||
});
|
||||
|
||||
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -260,6 +261,7 @@ export interface RpgEntryHomeViewProps {
|
||||
recommendRuntimeContent?: ReactNode;
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
isRecommendRuntimeReady?: boolean;
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
@@ -886,6 +888,115 @@ function RecommendRuntimePreviewCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendRuntimeCover({
|
||||
entry,
|
||||
className = '',
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
className?: string;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-recommend-runtime-cover ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{coverImage || fallbackCoverImage ? (
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
fallbackSrc={fallbackCoverImage}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_22%_18%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.05),rgba(0,0,0,0.34))]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendRuntimeMountedProbe({
|
||||
onMounted,
|
||||
}: {
|
||||
onMounted: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const animationFrameId = window.requestAnimationFrame(onMounted);
|
||||
return () => window.cancelAnimationFrame(animationFrameId);
|
||||
}, [onMounted]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RecommendRuntimeVisual({
|
||||
entry,
|
||||
runtimeContent,
|
||||
isStarting,
|
||||
isRuntimeReady,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
runtimeContent?: ReactNode;
|
||||
isStarting: boolean;
|
||||
isRuntimeReady: boolean;
|
||||
}) {
|
||||
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
|
||||
const activeEntryKey = buildPublicGalleryCardKey(entry);
|
||||
const previousEntryKeyRef = useRef(activeEntryKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousEntryKeyRef.current === activeEntryKey) {
|
||||
return;
|
||||
}
|
||||
previousEntryKeyRef.current = activeEntryKey;
|
||||
setIsRuntimeMounted((currentValue) => {
|
||||
// 中文注释:拼图推荐流“下一关”会在同一个 run 内切到相似作品;
|
||||
// 此时只更新作品信息和分享基准,不应重显封面造成运行态闪跳。
|
||||
if (currentValue && !isStarting && isRuntimeReady) {
|
||||
return currentValue;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [activeEntryKey, isRuntimeReady, isStarting]);
|
||||
|
||||
const handleRuntimeMounted = useCallback(() => {
|
||||
if (!isStarting && isRuntimeReady) {
|
||||
setIsRuntimeMounted(true);
|
||||
}
|
||||
}, [isRuntimeReady, isStarting]);
|
||||
|
||||
const shouldShowCover =
|
||||
!runtimeContent || isStarting || !isRuntimeReady || !isRuntimeMounted;
|
||||
|
||||
return (
|
||||
<div className="platform-recommend-runtime-visual">
|
||||
{runtimeContent ? (
|
||||
<Suspense fallback={null}>
|
||||
<div
|
||||
className="platform-recommend-runtime-viewport"
|
||||
aria-hidden={shouldShowCover}
|
||||
>
|
||||
{runtimeContent}
|
||||
</div>
|
||||
<RecommendRuntimeMountedProbe
|
||||
key={activeEntryKey}
|
||||
onMounted={handleRuntimeMounted}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<RecommendRuntimeCover
|
||||
entry={entry}
|
||||
className={
|
||||
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendSwipeCard({
|
||||
entry,
|
||||
authorAvatarUrl,
|
||||
@@ -3404,6 +3515,7 @@ export function RpgEntryHomeView({
|
||||
recommendRuntimeContent,
|
||||
activeRecommendEntryKey = null,
|
||||
isStartingRecommendEntry = false,
|
||||
isRecommendRuntimeReady = false,
|
||||
recommendRuntimeError = null,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
@@ -5018,10 +5130,6 @@ export function RpgEntryHomeView({
|
||||
{recommendRuntimeError}
|
||||
</button>
|
||||
</section>
|
||||
) : isStartingRecommendEntry ? (
|
||||
<section className="platform-recommend-runtime-panel">
|
||||
<div className="platform-recommend-runtime-state">加载中...</div>
|
||||
</section>
|
||||
) : activeRecommendEntry ? (
|
||||
<div
|
||||
ref={recommendCardStageRef}
|
||||
@@ -5063,9 +5171,12 @@ export function RpgEntryHomeView({
|
||||
)}
|
||||
isActive
|
||||
visual={
|
||||
<div className="platform-recommend-runtime-viewport">
|
||||
{recommendRuntimeContent}
|
||||
</div>
|
||||
<RecommendRuntimeVisual
|
||||
entry={activeRecommendEntry}
|
||||
runtimeContent={recommendRuntimeContent}
|
||||
isStarting={isStartingRecommendEntry}
|
||||
isRuntimeReady={isRecommendRuntimeReady}
|
||||
/>
|
||||
}
|
||||
onDragPointerDown={beginRecommendDrag}
|
||||
onDragPointerMove={moveRecommendDrag}
|
||||
@@ -6394,7 +6505,7 @@ export function RpgEntryHomeView({
|
||||
(activeTab === 'create' || activeTab === 'saves') ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
className="platform-mobile-create-wallet-chip inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[#f0cfae] bg-[#fff5eb] px-2.5 py-1.5 text-xs font-black text-[#b65f2c] shadow-[0_10px_22px_rgba(174,111,73,0.12)]"
|
||||
aria-label={
|
||||
profileDashboardPresentation.walletBalanceWithUnitLabel
|
||||
@@ -6564,7 +6675,7 @@ export function RpgEntryHomeView({
|
||||
(activeTab === 'create' || activeTab === 'saves') ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
className="platform-desktop-create-wallet-chip platform-desktop-search inline-flex items-center gap-2 px-3 py-2.5 text-xs font-black text-[#b65f2c]"
|
||||
aria-label={
|
||||
profileDashboardPresentation.walletBalanceWithUnitLabel
|
||||
|
||||
@@ -285,7 +285,7 @@ test('resolves public work author from display name and public user code before
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('敲木鱼玩家');
|
||||
});
|
||||
|
||||
test('public work author display hides phone masks and public user codes on cards', () => {
|
||||
test('public work author display keeps phone masks and hides bare public user codes on cards', () => {
|
||||
const card = mapWoodenFishWorkToPlatformGalleryCard({
|
||||
publicWorkCode: 'WF-AUTHOR2',
|
||||
workId: 'wooden-fish-work-author-mask',
|
||||
@@ -311,8 +311,18 @@ test('public work author display hides phone masks and public user codes on card
|
||||
displayName: '158****3533',
|
||||
avatarUrl: null,
|
||||
}),
|
||||
).toBe('玩家');
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('玩家');
|
||||
).toBe('158****3533');
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe(
|
||||
'158****3533 · SY-00000003',
|
||||
);
|
||||
|
||||
const publicCodeOnlyCard = {
|
||||
...card,
|
||||
authorDisplayName: 'SY-00000003',
|
||||
};
|
||||
expect(resolvePlatformWorkAuthorDisplayName(publicCodeOnlyCard, null)).toBe(
|
||||
'玩家',
|
||||
);
|
||||
});
|
||||
|
||||
test('public work author lookup keeps public user code priority and avatar labels', () => {
|
||||
|
||||
@@ -966,9 +966,6 @@ function normalizePlatformPublicAuthorName(value: string | null | undefined) {
|
||||
}
|
||||
|
||||
const compact = normalized.replace(/\s+/gu, '');
|
||||
if (/^\d+\*+\d+(?:[·.-]?SY-\d+)?$/iu.test(compact)) {
|
||||
return '';
|
||||
}
|
||||
if (/^SY-\d+$/iu.test(compact)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -4831,6 +4831,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-visual {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--platform-recommend-runtime-fill);
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
background: var(--platform-recommend-runtime-fill);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: opacity 420ms ease;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-cover--hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.platform-recommend-swipe-stage {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
|
||||
Reference in New Issue
Block a user