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:
2026-06-05 17:21:24 +08:00
51 changed files with 2222 additions and 755 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

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

View File

@@ -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_disabledrequestId: req-big-fish-gallery',
}}
onClose={() => {}}
/>,
);
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
expect(screen.queryByText(/creation_entry_disabled/u)).toBeNull();
});
});
describe('PlatformTaskCompletionDialog', () => {

View File

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

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 = {

View File

@@ -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', () => {

View File

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

View File

@@ -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', () => {

View File

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

View File

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