This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -1,4 +1,3 @@
import { X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
@@ -88,6 +87,7 @@ import {
InventoryItemGrid,
} from './InventoryItemViews';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelCloseButton } from './PixelCloseButton';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import { SkillEffectPreview } from './SkillEffectPreview';
@@ -957,8 +957,8 @@ export function AdventureEntityModal({
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="relative flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="min-w-0 pr-10">
<div className="text-[10px] uppercase tracking-[0.24em] text-zinc-500">
</div>
@@ -975,13 +975,7 @@ export function AdventureEntityModal({
/>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<X className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭冒险详情" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
@@ -1319,13 +1313,10 @@ export function AdventureEntityModal({
{detailCharacter.name}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedContributionLabel(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<X className="h-4 w-4" />
</button>
label="关闭标签效果"
/>
</div>
<div className="overflow-y-auto p-4 sm:p-5">
@@ -1431,13 +1422,10 @@ export function AdventureEntityModal({
{selectedSkillOwnerName}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedSkillId(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<X className="h-4 w-4" />
</button>
label="关闭技能详情"
/>
</div>
<div className="space-y-4 overflow-y-auto p-4 sm:p-5">

View File

@@ -2,8 +2,8 @@ import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useRef } from 'react';
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
interface CharacterChatModalProps {
modal: CharacterChatModalState | null;
@@ -56,13 +56,11 @@ export function CharacterChatModal({
{modal.target.character.title} / {modal.target.roleLabel}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭角色聊天"
placement="inline"
/>
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] sm:overflow-hidden sm:p-5">

View File

@@ -23,7 +23,6 @@ import {
type WorldType,
} from '../types';
import {
CHROME_ICONS,
getNineSliceStyle,
type NineSliceTexture,
UI_CHROME,
@@ -38,7 +37,7 @@ import {
CharacterSkillsList,
} from './CharacterInfoShared';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelIcon } from './PixelIcon';
import { PixelCloseButton } from './PixelCloseButton';
interface CharacterDetailModalProps {
character: Character | null;
@@ -194,14 +193,7 @@ export function CharacterDetailModal({
{subtitle}
</div>
</div>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
aria-label="关闭角色详情"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭角色详情" />
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">

View File

@@ -1,7 +1,6 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
@@ -27,6 +26,7 @@ import {
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
@@ -38,12 +38,10 @@ import {
CustomWorldProfile,
EquipmentLoadout,
GameState,
QuestLogEntry,
TimedBuildBuff,
WorldType,
} from '../types';
import {
CHROME_ICONS,
getEquipmentSlotIcon,
getNineSliceStyle,
UI_CHROME,
@@ -66,6 +64,7 @@ import {
} from './CharacterInfoShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
import { ResolvedAssetImage } from './ResolvedAssetImage';
@@ -82,7 +81,6 @@ interface CharacterPanelProps {
activeBuildBuffs?: TimedBuildBuff[];
companionRenderStates: CompanionRenderState[];
npcStates?: GameState['npcStates'];
quests: QuestLogEntry[];
onOpenCamp?: () => void;
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
chatSummaries?: Record<string, string>;
@@ -155,7 +153,6 @@ export function CharacterPanel({
activeBuildBuffs = [],
companionRenderStates,
npcStates = {},
quests,
onInspectMember,
companionArcStates = [],
companionResolutions = [],
@@ -215,11 +212,6 @@ export function CharacterPanel({
[partyMembers, selectedMemberId],
);
const activeQuests = useMemo(
() => quests.filter((quest) => quest.status !== 'turned_in'),
[quests],
);
const buildBreakdownByMemberId = useMemo(
() =>
Object.fromEntries(
@@ -374,29 +366,6 @@ export function CharacterPanel({
paddingY: 12,
})}
>
{activeQuests.length > 0 && (
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
<div className="mb-2 text-xs font-bold text-sky-100">
</div>
<div className="space-y-2">
{activeQuests.map((quest) => (
<div
key={quest.id}
className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200"
>
<div className="font-semibold text-white">
{quest.title}
</div>
<div className="mt-1 text-xs text-zinc-400">
{quest.summary}
</div>
</div>
))}
</div>
</div>
)}
<div className="mb-3 text-xs font-bold text-white"></div>
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
{partyMembers.map((member) => (
@@ -497,13 +466,10 @@ export function CharacterPanel({
{selectedMember.character.name}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedContributionLabel(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭标签效果"
/>
</div>
<div className="overflow-y-auto p-4 sm:p-5">
@@ -619,13 +585,10 @@ export function CharacterPanel({
</span>
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedMemberId(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭角色详情"
/>
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">

View File

@@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { getCharacterById } from '../data/characterPresets';
import { MAX_COMPANIONS } from '../data/npcInteractions';
import { Character, CompanionState } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
import { ResolvedAssetImage } from './ResolvedAssetImage';
interface CompanionCampModalProps {
@@ -145,13 +145,7 @@ export function CompanionCampModal({
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
</div>
</div>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭营地编组" />
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">

View File

@@ -1099,7 +1099,7 @@ export function CustomWorldEntityCatalog({
<div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
</span>
) : null}
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">

View File

@@ -5,11 +5,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { buildInventoryItemDescription } from '../data/itemPresentation';
import type { Character, InventoryItem, WorldType } from '../types';
import {
CHROME_ICONS,
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
@@ -185,13 +185,7 @@ export function InventoryItemDetailModal({
onClick={(event) => event.stopPropagation()}
>
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭物品详情" className="top-4 sm:top-5" />
<div
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}

View File

@@ -38,7 +38,6 @@ interface InventoryPanelProps {
onCraftRecipe: (recipeId: string) => Promise<boolean>;
onDismantleItem: (itemId: string) => Promise<boolean>;
onReforgeItem: (itemId: string) => Promise<boolean>;
continueGameDigest?: string | null;
narrativeCodex?: NarrativeCodexSection[];
narrativeQaReport?: NarrativeQaReport | null;
}
@@ -58,7 +57,6 @@ export function InventoryPanel({
onCraftRecipe,
onDismantleItem: _onDismantleItem,
onReforgeItem: _onReforgeItem,
continueGameDigest = null,
narrativeCodex = [],
narrativeQaReport = null,
}: InventoryPanelProps) {
@@ -92,14 +90,6 @@ export function InventoryPanel({
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto scrollbar-hide">
{continueGameDigest && (
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
</div>
{continueGameDigest}
</div>
)}
<InventoryItemGrid
items={inventoryItems}
selectedItemId={selectedItem?.id ?? null}

View File

@@ -6,6 +6,7 @@ import { getConnectedScenePresets } from '../data/scenePresets';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
import { ScenePresetInfo, WorldType } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
function buildSceneBackdropStyle(imageSrc?: string | null): CSSProperties {
@@ -252,13 +253,7 @@ export function MapModal({
<span></span>
</div>
</div>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭地图" />
</div>
<div className="relative grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 md:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)] md:overflow-hidden">
@@ -385,13 +380,10 @@ export function MapModal({
<div className="text-[10px] tracking-[0.22em] text-amber-200/80"></div>
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.scene.name}</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setPendingScene(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭场景切换"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">

View File

@@ -25,7 +25,8 @@ import {
RuntimeNpcGiftItemView,
RuntimeNpcTradeItemView,
} from '../types';
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
interface NpcModalsProps {
@@ -232,13 +233,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} / {currencyName}{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={npcUi.closeTradeModal}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭交易"
placement="inline"
/>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
@@ -385,13 +384,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
{tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setTradeDetail(null)}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭物品详情"
placement="inline"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
@@ -474,9 +471,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
</div>
<button type="button" onClick={npcUi.closeGiftModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton
onClick={npcUi.closeGiftModal}
label="关闭赠礼"
placement="inline"
/>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
@@ -550,9 +549,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500"></div>
</div>
<button type="button" onClick={npcUi.closeRecruitModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton
onClick={npcUi.closeRecruitModal}
label="关闭招募"
placement="inline"
/>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">

View File

@@ -0,0 +1,45 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PixelCloseButton } from './PixelCloseButton';
test('pixel close button closes without bubbling to the overlay', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onOverlayClick = vi.fn();
render(
<div onClick={onOverlayClick}>
<PixelCloseButton onClick={onClose} label="关闭测试面板" />
</div>,
);
await user.click(screen.getByRole('button', { name: '关闭测试面板' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onOverlayClick).not.toHaveBeenCalled();
});
test('inline pixel close button keeps the same click boundary', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onHeaderClick = vi.fn();
render(
<div onClick={onHeaderClick}>
<PixelCloseButton
onClick={onClose}
label="关闭标题栏面板"
placement="inline"
/>
</div>,
);
await user.click(screen.getByRole('button', { name: '关闭标题栏面板' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onHeaderClick).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,45 @@
import type { MouseEvent } from 'react';
import { CHROME_ICONS } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
type PixelCloseButtonProps = {
onClick: () => void;
label?: string;
placement?: 'absolute' | 'inline';
className?: string;
};
/**
* RPG 像素风弹窗右上关闭按钮。
* 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。
*/
export function PixelCloseButton({
onClick,
label = '关闭面板',
placement = 'absolute',
className = '',
}: PixelCloseButtonProps) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onClick();
};
const placementClassName =
placement === 'absolute'
? 'absolute right-4 top-3 sm:right-5 sm:top-4'
: 'relative shrink-0';
return (
<button
type="button"
aria-label={label}
title={label}
onClick={handleClick}
className={`${placementClassName} z-20 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 ${className}`.trim()}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
);
}

View File

@@ -16,6 +16,7 @@ const baseUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '138****8000',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',

View File

@@ -78,6 +78,7 @@ const mockUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',

View File

@@ -410,6 +410,7 @@ export function AuthGate({ children }: AuthGateProps) {
requireAuth,
openSettingsModal,
openAccountModal,
setCurrentUser: setUser,
logout: logoutCurrentSession,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,

View File

@@ -17,6 +17,7 @@ type AuthUiContextValue = {
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
setCurrentUser: (user: AuthUser) => void;
logout: () => Promise<void>;
musicVolume: number;
setMusicVolume: (value: number) => void;

View File

@@ -62,7 +62,7 @@ export function BindPhoneScreen({
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">

View File

@@ -11,12 +11,62 @@ const noopCreateType = () => {};
const originalClipboard = navigator.clipboard;
afterEach(() => {
window.sessionStorage.clear();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
});
test('creation hub shows published metric growth from cached page snapshot', async () => {
window.sessionStorage.setItem(
'genarrative.creationHub.publishedMetrics.v1',
JSON.stringify({
'puzzle:puzzle:work-growth': {
'play-count': 7,
'remix-count': 1,
'like-count': 2,
},
}),
);
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-growth',
profileId: 'puzzle-profile-growth',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '涨潮拼图',
summary: '公开指标会从缓存快照涨到最新值。',
themeTags: ['涨潮'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 10,
remixCount: 4,
likeCount: 2,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(screen.getByLabelText('游玩 10次')).toBeTruthy();
expect(screen.getByLabelText('改造 4次')).toBeTruthy();
expect(await screen.findAllByText('↑')).toHaveLength(2);
});
const baseDraftItem: CustomWorldWorkSummary = {
workId: 'draft:session-1',
sourceType: 'agent_session',
@@ -52,10 +102,12 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.getByText('角色 3')).toBeTruthy();
expect(screen.getByText('地点 4')).toBeTruthy();
expect(screen.getByRole('button', { name: / RPG/u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
expect(
screen.getByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: /.*/u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
rerender(
@@ -83,8 +135,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(
screen.getByText('世界总卡和角色网已经继续长出了新的支线。'),
).toBeTruthy();
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
expect(screen.queryByText('角色 5')).toBeNull();
expect(screen.queryByText('地点 6')).toBeNull();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
@@ -105,7 +157,8 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
likeCount: 0,
remixCount: 2,
likeCount: 3,
publishReady: true,
},
]}
@@ -121,8 +174,14 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('沉钟拼图')).toBeTruthy();
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.getByLabelText('游玩 8次')).toBeTruthy();
expect(screen.getByLabelText('改造 2次')).toBeTruthy();
expect(screen.getByLabelText('点赞 3赞')).toBeTruthy();
expect(screen.queryByText('Remix')).toBeNull();
expect(screen.queryByText('PZ-PROFILE1')).toBeNull();
expect(screen.queryByText('潮雾')).toBeNull();
expect(screen.queryByText('沉钟')).toBeNull();
expect(screen.queryByText('我的拼图作品')).toBeNull();
});
@@ -159,7 +218,9 @@ test('creation hub shows RPG public work code from published library entry', ()
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
playCount: 12,
remixCount: 4,
likeCount: 5,
},
]}
loading={false}
@@ -172,7 +233,11 @@ test('creation hub shows RPG public work code from published library entry', ()
);
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
expect(screen.getByText('CW-00000001')).toBeTruthy();
expect(screen.getByLabelText('游玩 12次')).toBeTruthy();
expect(screen.getByLabelText('改造 4次')).toBeTruthy();
expect(screen.getByLabelText('点赞 5赞')).toBeTruthy();
expect(screen.queryByText('Remix')).toBeNull();
expect(screen.queryByText('CW-00000001')).toBeNull();
});
test('creation hub shows delete action for persisted rpg drafts', () => {
@@ -225,7 +290,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub work code copy button copies without opening the card', async () => {
test('creation hub published share button copies share text without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
@@ -251,6 +316,7 @@ test('creation hub work code copy button copies without opening the card', async
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 0,
publishReady: true,
},
@@ -265,11 +331,19 @@ test('creation hub work code copy button copies without opening the card', async
/>,
);
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }),
);
await user.click(screen.getByRole('button', { name: '分享' }));
expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1');
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('邀请你来玩《沉钟拼图》'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-PROFILE1'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
expect(await screen.findByText('已复制')).toBeTruthy();
expect(
await screen.findByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});

View File

@@ -42,12 +42,59 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('角色扮演 RPG');
expect(html).toContain('拼图玩法');
expect(html).toContain('角色扮演');
expect(html).toContain('剧情演绎,冒险成长');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).not.toContain('大鱼吃小鱼');
});
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '潮雾拼图',
summary: '一张被切成拼图的潮雾港口主视觉。',
themeTags: ['潮雾', '港口'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
playCount: 12,
remixCount: 3,
likeCount: 4,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).toContain('aria-label="游玩 12次"');
expect(html).toContain('aria-label="改造 3次"');
expect(html).toContain('aria-label="点赞 4赞"');
expect(html).not.toContain('作品号');
expect(html).not.toContain('PZ-PROFILE1');
expect(html).not.toContain('潮雾</span>');
expect(html).not.toContain('港口</span>');
expect(html).not.toContain('我的拼图作品');
});
test('creation hub published work spans full mobile row', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
@@ -79,9 +126,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
/>,
);
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).toContain('作品号');
expect(html).toContain('PZ-PROFILE1');
expect(html).not.toContain('我的拼图作品');
expect(html).toContain('grid-cols-2');
expect(html).toContain('col-span-2 sm:col-span-1');
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
});

View File

@@ -1,21 +1,32 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
} from './creationWorkShelf';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
} from './creationWorkShelf';
// 中文注释:草稿在手机端保持双列,已发布卡片由卡片自身跨两列展示公开指标。
const WORK_GRID_CLASS =
'grid grid-cols-2 gap-2.5 sm:gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4';
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
type WorkMetricSnapshot = Record<
string,
Partial<Record<CreationWorkShelfMetricId, number>>
>;
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
@@ -29,15 +40,12 @@ type CustomWorldCreationHubProps = {
onEnterPublished: (profileId: string) => void;
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onExperiencePuzzle?: ((profileId: string) => void) | null;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
};
@@ -51,6 +59,59 @@ function EmptyState({ title }: { title: string }) {
);
}
function buildWorkMetricCacheItemKey(item: CreationWorkShelfItem) {
return `${item.kind}:${item.id}`;
}
function readWorkMetricSnapshot(): WorkMetricSnapshot {
if (typeof window === 'undefined') {
return {};
}
try {
const rawSnapshot = window.sessionStorage.getItem(WORK_METRIC_CACHE_KEY);
if (!rawSnapshot) {
return {};
}
const parsed = JSON.parse(rawSnapshot) as WorkMetricSnapshot;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
if (typeof window === 'undefined') {
return;
}
const snapshot: WorkMetricSnapshot = {};
for (const item of items) {
if (item.status !== 'published' || item.metrics.length === 0) {
continue;
}
snapshot[buildWorkMetricCacheItemKey(item)] = Object.fromEntries(
item.metrics.map((metric) => [metric.id, metric.value]),
);
}
// 中文注释:缓存只作为下一次进入创作页的数字动画起点,真实展示值仍以接口返回为准。
if (Object.keys(snapshot).length === 0) {
return;
}
try {
window.sessionStorage.setItem(
WORK_METRIC_CACHE_KEY,
JSON.stringify(snapshot),
);
} catch {
// 中文注释:浏览器禁用 sessionStorage 时降级为无缓存动画,不影响作品列表使用。
}
}
export function CustomWorldCreationHub({
items,
loading,
@@ -63,15 +124,12 @@ export function CustomWorldCreationHub({
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
onExperienceRpg = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onExperienceBigFish = null,
onDeleteBigFish = null,
puzzleItems = [],
onOpenPuzzleDetail,
onExperiencePuzzle = null,
onDeletePuzzle = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
@@ -97,6 +155,12 @@ export function CustomWorldCreationHub({
rpgLibraryEntries,
],
);
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
readWorkMetricSnapshot(),
);
useEffect(() => {
writeWorkMetricSnapshot(shelfItems);
}, [shelfItems]);
const draftCount = shelfItems.filter(
(entry) => entry.status === 'draft',
).length;
@@ -131,33 +195,6 @@ export function CustomWorldCreationHub({
}
}
function buildExperienceAction(item: CreationWorkShelfItem) {
if (!item.canExperience) {
return null;
}
switch (item.source.kind) {
case 'puzzle': {
const sourceItem = item.source.item;
return () => {
onExperiencePuzzle?.(sourceItem.profileId);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
onExperienceBigFish?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onExperienceRpg?.(sourceItem);
};
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;
@@ -215,31 +252,33 @@ export function CustomWorldCreationHub({
) : null}
{loading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div className={WORK_GRID_CLASS}>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="platform-subpanel min-h-[12rem] rounded-[1.6rem] p-5"
className="platform-subpanel min-h-[10.5rem] rounded-[1.2rem] p-3 sm:min-h-[12rem] sm:rounded-[1.6rem] sm:p-5"
>
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 h-8 w-36 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-4 h-4 w-full rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-5 h-6 w-24 rounded-full bg-[var(--platform-track-fill)] sm:mt-6 sm:h-8 sm:w-36" />
<div className="mt-3 h-3 w-full rounded-full bg-[var(--platform-track-fill)] sm:mt-4 sm:h-4" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-8 flex gap-2">
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 flex flex-col gap-2 sm:mt-8 sm:flex-row">
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div className={WORK_GRID_CLASS}>
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.id}`}
item={item}
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => handleOpenShelfItem(item)}
onExperience={buildExperienceAction(item)}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
/>

View File

@@ -37,7 +37,7 @@ export function CustomWorldCreationStartCard({
</span>
</div>
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 scrollbar-hide sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
{visibleCreationTypes.map((item) => {
const disabled = item.locked || busy;
@@ -49,22 +49,18 @@ export function CustomWorldCreationStartCard({
onClick={() => {
onCreateType(item.id);
}}
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
className={`platform-interactive-card relative flex min-h-[4rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
item.locked
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
<span
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
<div className="flex min-h-5 items-center justify-end gap-2 sm:items-start sm:gap-3">
{item.locked ? (
<span className="platform-pill platform-pill--neutral px-2.5 text-xs text-[var(--platform-text-soft)] sm:px-3 sm:text-sm">
{item.badge}
</span>
) : null}
{item.locked ? (
<span className="text-base leading-none text-white/40">·</span>
) : (
@@ -72,15 +68,17 @@ export function CustomWorldCreationStartCard({
)}
</div>
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg xl:mt-4 xl:text-base">
{item.title}
</div>
<div
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
<div className="mt-auto pt-1.5 sm:pt-4 xl:pt-2">
<div className="truncate text-base font-black leading-tight text-inherit sm:text-lg xl:text-base">
{item.title}
</div>
<div
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
</div>
</div>
</button>
);

View File

@@ -1,64 +1,240 @@
import { Copy } from 'lucide-react';
import { useState } from 'react';
import { Share2, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import type { CreationWorkShelfItem } from './creationWorkShelf';
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '最近更新';
}
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
type CreationWorkShelfBadgeTone,
type CreationWorkShelfItem,
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
} from './creationWorkShelf';
type CustomWorldWorkCardProps = {
item: CreationWorkShelfItem;
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>;
onOpen: () => void;
onExperience?: (() => void) | null;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<
CreationWorkShelfItem['badges'][number]['tone'],
string
> = {
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
};
export function CustomWorldWorkCard({
item,
onOpen,
onExperience = null,
onDelete = null,
deleteBusy = false,
}: CustomWorldWorkCardProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
const METRIC_ANIMATION_DURATION_MS = 820;
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3;
}
function resolveMetricStartValue(
metric: CreationWorkShelfMetric,
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
) {
const previousValue = previousMetricValues?.[metric.id];
if (previousValue === undefined || previousValue >= metric.value) {
return metric.value;
}
return Math.max(0, Math.floor(previousValue));
}
function buildMetricValueMap(
metrics: CreationWorkShelfMetric[],
resolveValue: (metric: CreationWorkShelfMetric) => number,
) {
return Object.fromEntries(
metrics.map((metric) => [metric.id, resolveValue(metric)]),
) as Record<CreationWorkShelfMetricId, number>;
}
function shouldAnimatePublishedMetrics() {
if (typeof window === 'undefined') {
return false;
}
return !window.navigator.userAgent.toLowerCase().includes('jsdom');
}
function usePublishedMetricAnimation(
metrics: CreationWorkShelfMetric[],
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
) {
const cardRef = useRef<HTMLDivElement | null>(null);
const [hasEnteredView, setHasEnteredView] = useState(false);
const startValues = useMemo(
() =>
buildMetricValueMap(metrics, (metric) =>
resolveMetricStartValue(metric, previousMetricValues),
),
[metrics, previousMetricValues],
);
const copyPublicWorkCode = () => {
if (!item.publicWorkCode) {
const endValues = useMemo(
() => buildMetricValueMap(metrics, (metric) => metric.value),
[metrics],
);
const deltas = useMemo(
() =>
buildMetricValueMap(metrics, (metric) =>
Math.max(0, metric.value - startValues[metric.id]),
),
[metrics, startValues],
);
const hasGrowth = useMemo(
() => Object.values(deltas).some((delta) => delta > 0),
[deltas],
);
const [displayValues, setDisplayValues] = useState(endValues);
const [showGrowth, setShowGrowth] = useState(false);
useEffect(() => {
setShowGrowth(false);
setHasEnteredView(false);
setDisplayValues(hasGrowth ? startValues : endValues);
}, [endValues, hasGrowth, startValues]);
useEffect(() => {
const element = cardRef.current;
if (!element || !hasGrowth) {
setHasEnteredView(true);
return;
}
void copyTextToClipboard(item.publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
setHasEnteredView(true);
return;
}
// 中文注释:指标增长只在卡片进入视口后启动,避免列表刷新时离屏卡片提前播放。
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setHasEnteredView(true);
observer.disconnect();
}
},
{ rootMargin: '0px 0px -10% 0px', threshold: 0.28 },
);
observer.observe(element);
return () => observer.disconnect();
}, [hasGrowth]);
useEffect(() => {
if (!hasEnteredView) {
return;
}
if (!hasGrowth || !shouldAnimatePublishedMetrics()) {
setDisplayValues(endValues);
if (hasGrowth) {
setShowGrowth(true);
}
return;
}
if (typeof window === 'undefined') {
setDisplayValues(endValues);
setShowGrowth(true);
return;
}
let animationFrameId = 0;
const startTime = window.performance.now();
const tick = (now: number) => {
const progress = Math.min(
1,
(now - startTime) / METRIC_ANIMATION_DURATION_MS,
);
const easedProgress = easeOutCubic(progress);
setDisplayValues(
buildMetricValueMap(metrics, (metric) => {
const startValue = startValues[metric.id];
const endValue = endValues[metric.id];
return Math.round(
startValue + (endValue - startValue) * easedProgress,
);
}),
);
if (progress < 1) {
animationFrameId = window.requestAnimationFrame(tick);
return;
}
setDisplayValues(endValues);
setShowGrowth(true);
};
animationFrameId = window.requestAnimationFrame(tick);
return () => {
window.cancelAnimationFrame(animationFrameId);
};
}, [endValues, hasEnteredView, hasGrowth, metrics, startValues]);
return { cardRef, deltas, displayValues, showGrowth };
}
export function CustomWorldWorkCard({
item,
previousMetricValues,
onOpen,
onDelete = null,
deleteBusy = false,
}: CustomWorldWorkCardProps) {
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const isPublished = item.status === 'published';
const displayTitle = formatPlatformWorkDisplayName(item.title);
const { cardRef, deltas, displayValues, showGrowth } =
usePublishedMetricAnimation(
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
if (!publicWorkCode || !sharePath) {
return;
}
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const shareText = `邀请你来玩《${item.title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
shareResetTimerRef.current = window.setTimeout(() => {
shareResetTimerRef.current = null;
setShareState('idle');
}, 1400);
});
};
useEffect(
() => () => {
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
},
[],
);
return (
<div
ref={cardRef}
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}`}
@@ -71,7 +247,7 @@ export function CustomWorldWorkCard({
event.preventDefault();
onOpen();
}}
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
className={`platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
@@ -79,126 +255,127 @@ export function CustomWorldWorkCard({
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0"
className="platform-cover-artwork absolute inset-0 opacity-70 saturate-[1.08]"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
{!isPublished && onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto absolute right-0 top-0 z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="pointer-events-auto absolute right-0 top-0 z-30 inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} px-3 py-1 text-[10px]`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]`}
>
{badge.label}
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(item.updatedAt)}
</span>
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v5" />
<path d="M14 11v5" />
</svg>
)}
</button>
) : null}
</div>
</div>
<div className="mt-4 min-h-0 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{item.title}
<div className="mt-3 min-h-0 sm:mt-4 xl:mt-3">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl xl:text-xl">
{displayTitle}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{item.subtitle}
</div>
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
{item.summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="min-w-0 space-y-2">
{item.publicWorkCode ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyPublicWorkCode();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
aria-label={`复制作品号 ${item.publicWorkCode}`}
title="复制作品号"
{isPublished ? (
<div className="mt-auto grid grid-cols-3 gap-1.5 pt-3 sm:gap-2 sm:pt-4 xl:pt-3">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
{copyState !== 'idle' ? (
<span className="shrink-0">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
<div className="flex flex-wrap gap-2">
{item.metrics.map((metric) => (
<span
key={`${item.id}-${metric.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
))}
</div>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
</span>
) : null}
</div>
))}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
{onExperience ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onExperience();
}}
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>
) : null}
</div>
</div>
) : null}
</div>
</div>
);

View File

@@ -2,7 +2,11 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBigFishPublicWorkCode,
buildPuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
@@ -16,10 +20,19 @@ export type CreationWorkShelfBadge = {
tone: CreationWorkShelfBadgeTone;
};
export type CreationWorkShelfMetricId =
| 'play-count'
| 'remix-count'
| 'like-count';
export type CreationWorkShelfMetricTone = 'play' | 'remix' | 'like';
export type CreationWorkShelfMetric = {
id: string;
id: CreationWorkShelfMetricId;
label: string;
tone?: CreationWorkShelfBadgeTone;
value: number;
unit: string;
tone: CreationWorkShelfMetricTone;
};
export type CreationWorkShelfSource =
@@ -41,17 +54,16 @@ export type CreationWorkShelfItem = {
kind: CreationWorkShelfKind;
status: CreationWorkShelfStatus;
title: string;
subtitle: string;
summary: string;
updatedAt: string;
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
coverCharacterImageSrcs: string[];
publicWorkCode: string | null;
typeLabel: string;
sharePath: string | null;
openActionLabel: string;
canExperience: boolean;
canDelete: boolean;
canShare: boolean;
badges: CreationWorkShelfBadge[];
metrics: CreationWorkShelfMetric[];
source: CreationWorkShelfSource;
@@ -101,67 +113,43 @@ function mapRpgWorkToShelfItem(
const libraryEntry = item.profileId
? libraryEntries.find((entry) => entry.profileId === item.profileId)
: null;
const publicWorkCode =
item.status === 'published' ? (libraryEntry?.publicWorkCode ?? null) : null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
{ id: 'type', label: 'RPG', tone: 'neutral' },
];
if (item.stageLabel) {
badges.push({ id: 'stage', label: item.stageLabel, tone: 'neutral' });
}
const metrics: CreationWorkShelfMetric[] = [
{
id: 'playable-npc-count',
label: `${isDraft ? '角色' : '可扮演角色'} ${item.playableNpcCount}`,
},
{ id: 'landmark-count', label: `地点 ${item.landmarkCount}` },
];
if (item.roleVisualReadyCount) {
metrics.push({
id: 'role-visual-ready-count',
label: `主图 ${item.roleVisualReadyCount}`,
tone: 'warm',
});
}
if (item.roleAnimationReadyCount) {
metrics.push({
id: 'role-animation-ready-count',
label: `动作 ${item.roleAnimationReadyCount}`,
tone: 'success',
});
}
if (item.roleAssetSummaryLabel) {
metrics.push({
id: 'role-asset-summary',
label: item.roleAssetSummaryLabel,
});
}
const metrics = buildPublishedMetrics({
playCount: libraryEntry?.playCount,
remixCount: libraryEntry?.remixCount,
likeCount: libraryEntry?.likeCount,
});
return {
id: item.workId,
kind: 'rpg',
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image',
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
publicWorkCode:
item.status === 'published'
? (libraryEntry?.publicWorkCode ?? null)
publicWorkCode,
sharePath:
publicWorkCode && item.status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
typeLabel: 'RPG',
openActionLabel: isDraft
? item.playableNpcCount > 0 || item.landmarkCount > 0
? '继续完善'
: '继续创作'
: '查看详情',
canExperience: item.status === 'published' && item.canEnterWorld,
canDelete,
canShare: item.status === 'published' && Boolean(publicWorkCode),
badges,
metrics,
metrics: isDraft ? [] : metrics,
source: { kind: 'rpg', item },
};
}
@@ -170,47 +158,40 @@ function mapBigFishWorkToShelfItem(
item: BigFishWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
const isPublished = item.status === 'published';
const publicWorkCode = isPublished
? buildBigFishPublicWorkCode(item.sourceSessionId)
: null;
return {
id: item.workId,
kind: 'big-fish',
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
typeLabel: '大鱼',
publicWorkCode,
sharePath:
publicWorkCode && isPublished
? buildPublicWorkStagePath('big-fish-runtime', publicWorkCode)
: null,
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
canExperience: item.status === 'published',
canDelete,
canShare: isPublished && Boolean(publicWorkCode),
badges: [
buildStatusBadge(item.status),
{ id: 'type', label: '大鱼', tone: 'neutral' },
],
metrics: [
{ id: 'level-count', label: `关卡 ${item.levelCount}` },
{
id: 'level-main-image-ready-count',
label: `主图 ${item.levelMainImageReadyCount}`,
},
{
id: 'level-motion-ready-count',
label: `动作 ${item.levelMotionReadyCount}`,
},
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
...(item.backgroundReady
? [
{
id: 'background-ready',
label: '背景已就绪',
tone: 'success' as const,
},
]
: []),
],
metrics: isPublished
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: item.remixCount,
likeCount: item.likeCount,
})
: [],
source: { kind: 'big-fish', item },
};
}
@@ -220,42 +201,88 @@ function mapPuzzleWorkToShelfItem(
canDelete: boolean,
): CreationWorkShelfItem {
const status = item.publicationStatus;
const publicWorkCode =
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null;
return {
id: item.workId,
kind: 'puzzle',
status,
title: item.levelName,
subtitle: item.authorDisplayName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode:
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
typeLabel: '拼图',
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('puzzle-gallery-detail', publicWorkCode)
: null,
openActionLabel:
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
canExperience: status === 'published',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '拼图', tone: 'neutral' },
...item.themeTags.slice(0, 2).map((tag) => ({
id: `tag:${tag}`,
label: tag,
tone: 'neutral' as const,
})),
],
metrics: [
{ id: 'author', label: `作者 ${item.authorDisplayName}` },
{ id: 'play-count', label: `游玩 ${item.playCount}` },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: item.remixCount,
likeCount: item.likeCount,
})
: [],
source: { kind: 'puzzle', item },
};
}
function buildPublishedMetrics(params: {
playCount?: number | null;
remixCount?: number | null;
likeCount?: number | null;
}): CreationWorkShelfMetric[] {
return [
{
id: 'play-count',
label: '游玩',
value: normalizeMetricCount(params.playCount),
unit: '次',
tone: 'play',
},
{
id: 'remix-count',
label: '改造',
value: normalizeMetricCount(params.remixCount),
unit: '次',
tone: 'remix',
},
{
id: 'like-count',
label: '点赞',
value: normalizeMetricCount(params.likeCount),
unit: '赞',
tone: 'like',
},
];
}
export function normalizeMetricCount(value?: number | null) {
return Math.max(0, Math.floor(value ?? 0));
}
export function formatCreationMetricCount(value?: number | null) {
const normalized = Math.max(0, Math.floor(value ?? 0));
if (normalized >= 10000) {
const wanValue = normalized / 10000;
return `${Number.isInteger(wanValue) ? wanValue.toFixed(0) : wanValue.toFixed(1)}`;
}
return `${normalized}`;
}
function buildStatusBadge(
status: CreationWorkShelfStatus,
): CreationWorkShelfBadge {

View File

@@ -234,6 +234,55 @@ describe('GameCanvasEntityLayer', () => {
expect(html).toContain('aria-label="好感度变化 +3"');
});
it('keeps battle opponent visible when compat payload misses encounter context', () => {
const hostileNpc = createHostileNpc({
encounter: undefined,
name: '断桥匪首',
description: '刚进入战斗时的旧快照目标',
});
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[hostileNpc]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('查看断桥匪首详情');
expect(html).toContain('from-rose-500 to-red-400');
});
it('does not render affinity effect on a different npc', () => {
const html = renderEntityLayer('npc-other');

View File

@@ -98,6 +98,18 @@ interface GameCanvasEntityLayerProps {
const SCENE_ACT_BACK_ROW_ANCHOR_X_METERS = RESOLVED_ENTITY_X_METERS + 1.08;
const SCENE_ACT_BACK_ROW_OFFSET_PX = [62, -46] as const;
function buildFallbackCombatEncounter(hostileNpc: SceneHostileNpc): Encounter {
return {
id: hostileNpc.id,
kind: 'npc',
npcName: hostileNpc.name,
npcDescription: hostileNpc.description,
npcAvatar: '',
context: hostileNpc.action,
hostile: true,
};
}
function addCssPxOffset(value: string, offsetPx: number) {
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
}
@@ -440,8 +452,7 @@ export function GameCanvasEntityLayer({
</motion.div>
{sceneCombatants.map((hostileNpc, index) => {
const npcEncounter = hostileNpc.encounter;
if (!npcEncounter) return null;
const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc);
const hostileRenderKey = [
hostileNpc.id,
npcEncounter.id ?? npcEncounter.npcName,

View File

@@ -26,37 +26,35 @@ function CreationTypeCard(props: {
type="button"
disabled={disabled}
onClick={onSelect}
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
className={`platform-interactive-card relative flex min-h-[8.25rem] flex-col overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
item.locked
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<span
className={`platform-pill px-3 ${
item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
<div className="flex min-h-6 items-start justify-end gap-3">
{item.locked ? (
<span className="platform-pill platform-pill--neutral px-3 text-[var(--platform-text-soft)]">
{item.badge}
</span>
) : null}
{item.locked ? (
<span className="text-lg leading-none text-white/45">·</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
</div>
<div className="mt-8 text-xl font-black leading-tight text-inherit">
{item.title}
</div>
<div
className={`mt-2 text-sm ${
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
<div className="mt-auto pt-4">
<div className="text-xl font-black leading-tight text-inherit">
{item.title}
</div>
<div
className={`mt-2 text-sm ${
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
</div>
</div>
</button>
);

View File

@@ -25,6 +25,7 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
@@ -98,12 +99,20 @@ import {
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
updatePuzzleRunPause,
usePuzzleRuntimeProp as consumePuzzleRuntimeProp,
} from '../../services/puzzle-runtime';
import {
applyLocalPuzzleFreezeTime,
dragLocalPuzzlePiece,
isLocalPuzzleRun,
refreshLocalPuzzleTimer,
setLocalPuzzlePaused,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
@@ -114,8 +123,8 @@ import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreati
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetailByCode,
remixRpgEntryWorldGallery,
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
@@ -230,6 +239,33 @@ function mapBigFishWorkToPublicWorkDetail(
return mapBigFishWorkToPlatformGalleryCard(item);
}
function mapPublicWorkDetailToPuzzleWork(
entry: PlatformPublicGalleryCard,
): PuzzleWorkSummary | null {
if (!isPuzzleGalleryEntry(entry)) {
return null;
}
return {
workId: entry.workId,
profileId: entry.profileId,
ownerUserId: entry.ownerUserId,
sourceSessionId: null,
authorDisplayName: entry.authorDisplayName,
levelName: entry.worldName,
summary: entry.summaryText,
themeTags: entry.themeTags,
coverImageSrc: entry.coverImageSrc,
publicationStatus: 'published',
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
publishReady: true,
};
}
function mapPublicWorkDetailToBigFishWork(
entry: PlatformPublicGalleryCard,
): BigFishWorkSummary | null {
@@ -265,6 +301,26 @@ function mapPublicWorkDetailToBigFishWork(
};
}
async function resolvePublicWorkAuthorSummary(
entry: PlatformPublicGalleryCard,
): Promise<PublicUserSummary | null> {
if ('authorPublicUserCode' in entry && entry.authorPublicUserCode?.trim()) {
try {
return await getPublicAuthUserByCode(entry.authorPublicUserCode);
} catch {
if (!entry.ownerUserId.trim()) {
return null;
}
}
}
if (entry.ownerUserId.trim()) {
return getPublicAuthUserById(entry.ownerUserId);
}
return null;
}
function readProfileTextField(
profile: CustomWorldProfile | null,
paths: string[],
@@ -400,6 +456,18 @@ function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
return `puzzle-profile-${stableSuffix}`;
}
function buildPuzzleCompileActionFromFormPayload(
payload: CreatePuzzleAgentSessionRequest | null,
): PuzzleAgentActionRequest {
return {
action: 'compile_puzzle_draft',
promptText:
payload?.pictureDescription?.trim() || payload?.seedText?.trim(),
referenceImageSrc: payload?.referenceImageSrc || null,
candidateCount: 1,
};
}
const CustomWorldGenerationView = lazy(async () => {
const module = await import('../CustomWorldGenerationView');
return {
@@ -505,6 +573,9 @@ export function PlatformEntryFlowShellImpl({
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
useState<PlatformPublicGalleryCard | null>(null);
const [selectedPublicWorkAuthor, setSelectedPublicWorkAuthor] =
useState<PublicUserSummary | null>(null);
const publicWorkAuthorRequestKeyRef = useRef(0);
const [publicWorkDetailError, setPublicWorkDetailError] = useState<
string | null
>(null);
@@ -529,8 +600,9 @@ export function PlatformEntryFlowShellImpl({
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleOperation, setPuzzleOperation] =
useState<PuzzleAgentOperationRecord | null>(null);
const [, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>(
null,
);
const [puzzleWorks, setPuzzleWorks] = useState<PuzzleWorkSummary[]>([]);
const [puzzleGalleryEntries, setPuzzleGalleryEntries] = useState<
PuzzleWorkSummary[]
@@ -544,9 +616,12 @@ export function PlatformEntryFlowShellImpl({
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [puzzleGenerationState, setPuzzleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
useState<CreatePuzzleAgentSessionRequest | null>(null);
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
useState(false);
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
@@ -984,7 +1059,7 @@ export function PlatformEntryFlowShellImpl({
const puzzleFlow = usePlatformCreationAgentFlowController<
PuzzleAgentSessionSnapshot,
Record<string, never>,
CreatePuzzleAgentSessionRequest,
{ session: PuzzleAgentSessionSnapshot },
SendPuzzleAgentMessageRequest,
PuzzleAgentActionRequest,
@@ -1097,7 +1172,6 @@ export function PlatformEntryFlowShellImpl({
const setPuzzleError = puzzleFlow.setError;
const isPuzzleBusy = puzzleFlow.isBusy;
const setIsPuzzleBusy = puzzleFlow.setIsBusy;
const streamingPuzzleReplyText = puzzleFlow.streamingReplyText;
const isStreamingPuzzleReply = puzzleFlow.isStreamingReply;
const resetRpgSessionViewState = sessionController.resetSessionViewState;
const setRpgGeneratedCustomWorldProfile =
@@ -1106,6 +1180,11 @@ export function PlatformEntryFlowShellImpl({
const persistRpgAgentUiState = sessionController.persistAgentUiState;
const resetAutoSaveTrackingToIdle =
autosaveCoordinator.resetAutoSaveTrackingToIdle;
useEffect(() => {
puzzleRunRef.current = puzzleRun;
}, [puzzleRun]);
const openBigFishAgentWorkspace = useCallback(async () => {
setBigFishRun(null);
await bigFishFlow.openWorkspace();
@@ -1114,8 +1193,32 @@ export function PlatformEntryFlowShellImpl({
const openPuzzleAgentWorkspace = useCallback(async () => {
setPuzzleRun(null);
setPuzzleOperation(null);
await puzzleFlow.openWorkspace();
}, [puzzleFlow]);
setPuzzleGenerationState(null);
setPuzzleFormDraftPayload(null);
puzzleFlow.setSession(null);
puzzleFlow.setError(null);
puzzleFlow.setStreamingReplyText('');
puzzleFlow.setIsStreamingReply(false);
enterCreateTab();
setShowCreationTypeModal(false);
setSelectionStage('puzzle-agent-workspace');
}, [enterCreateTab, puzzleFlow, setSelectionStage]);
const createPuzzleDraftFromForm = useCallback(
async (payload: CreatePuzzleAgentSessionRequest) => {
setPuzzleFormDraftPayload(payload);
const nextSession = await puzzleFlow.openWorkspace(payload);
if (!nextSession) {
return;
}
await puzzleFlow.executeAction(
buildPuzzleCompileActionFromFormPayload(payload),
nextSession,
);
},
[puzzleFlow],
);
useEffect(() => {
if (platformBootstrap.canReadProtectedData) {
@@ -1325,6 +1428,8 @@ export function PlatformEntryFlowShellImpl({
async (
profileId: string,
returnStage: PuzzleRuntimeReturnStage = 'work-detail',
detailItem?: PuzzleWorkSummary,
mirrorErrorToPublicDetail = false,
) => {
if (isPuzzleBusy) {
return;
@@ -1334,7 +1439,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(null);
try {
const { item } = await getPuzzleGalleryDetail(profileId);
const item =
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
const { run } = await startPuzzleRun({ profileId: item.profileId });
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
@@ -1347,12 +1453,22 @@ export function PlatformEntryFlowShellImpl({
),
);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
const message = resolvePuzzleErrorMessage(error, '启动拼图玩法失败。');
setPuzzleError(message);
if (mirrorErrorToPublicDetail) {
setPublicWorkDetailError(message);
}
} finally {
setIsPuzzleBusy(false);
}
},
[isPuzzleBusy, resolvePuzzleErrorMessage, setSelectionStage],
[
isPuzzleBusy,
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
setSelectionStage,
],
);
const buildPuzzleTestWork = useCallback(
@@ -1449,9 +1565,20 @@ export function PlatformEntryFlowShellImpl({
}
setPuzzleError(null);
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
return;
}
void swapPuzzlePieces(puzzleRun.runId, payload)
.then(({ run }) => {
setPuzzleRun(run);
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
});
},
[isPuzzleBusy, puzzleRun],
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError],
);
const dragPuzzlePiece = useCallback(
@@ -1461,9 +1588,126 @@ export function PlatformEntryFlowShellImpl({
}
setPuzzleError(null);
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
return;
}
void dragPuzzlePieceOrGroup(puzzleRun.runId, payload)
.then(({ run }) => {
setPuzzleRun(run);
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
});
},
[isPuzzleBusy, puzzleRun],
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError],
);
useEffect(() => {
if (selectionStage !== 'puzzle-runtime' || !puzzleRun?.currentLevel) {
return;
}
if (puzzleRun.currentLevel.status !== 'playing') {
return;
}
const timerId = window.setInterval(() => {
if (!isLocalPuzzleRun(puzzleRun)) {
return;
}
setPuzzleRun((currentRun) =>
currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun,
);
}, 250);
return () => window.clearInterval(timerId);
}, [puzzleRun, selectionStage]);
const setPuzzleRuntimePaused = useCallback(
async (paused: boolean) => {
if (!puzzleRun?.currentLevel) {
return;
}
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun((currentRun) =>
currentRun ? setLocalPuzzlePaused(currentRun, paused) : currentRun,
);
return;
}
try {
const { run } = await updatePuzzleRunPause(puzzleRun.runId, {
paused,
});
setPuzzleRun(run);
void platformBootstrap.refreshProfileDashboard();
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '更新拼图计时状态失败。'),
);
}
},
[platformBootstrap, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError],
);
const syncPuzzleRuntimeTimeout = useCallback(async () => {
if (
!puzzleRun?.currentLevel ||
puzzleRun.currentLevel.status !== 'playing'
) {
return;
}
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun((currentRun) =>
currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun,
);
return;
}
try {
const { run } = await getPuzzleRun(puzzleRun.runId);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '同步拼图失败状态失败。'),
);
}
}, [puzzleRun, resolvePuzzleErrorMessage, setPuzzleError]);
const usePuzzleProp = useCallback(
async (propKind: 'hint' | 'reference' | 'freezeTime') => {
if (
!puzzleRun?.currentLevel ||
puzzleRun.currentLevel.status !== 'playing'
) {
return null;
}
if (isLocalPuzzleRun(puzzleRun)) {
const currentRun = puzzleRunRef.current ?? puzzleRun;
if (!currentRun.currentLevel) {
return null;
}
const nextRun =
propKind === 'freezeTime'
? applyLocalPuzzleFreezeTime(currentRun)
: setLocalPuzzlePaused(currentRun, propKind === 'reference');
puzzleRunRef.current = nextRun;
setPuzzleRun(nextRun);
return nextRun;
}
const { run } = await consumePuzzleRuntimeProp(puzzleRun.runId, {
propKind,
});
setPuzzleRun(run);
void platformBootstrap.refreshProfileDashboard();
return run;
},
[platformBootstrap, puzzleRun],
);
useEffect(() => {
@@ -1622,34 +1866,6 @@ export function PlatformEntryFlowShellImpl({
});
}, [handleCustomWorldSelect, runProtectedAction, selectedDetailEntry]);
const handleExperienceRpgWork = useCallback(
(work: (typeof creationHubItems)[number]) => {
if (!work.profileId) {
return;
}
runProtectedAction(() => {
const matchedEntry = platformBootstrap.savedCustomWorldEntries.find(
(entry) => entry.profileId === work.profileId,
);
if (!matchedEntry) {
platformBootstrap.setPlatformError(
'未找到可体验的作品,请刷新后重试。',
);
return;
}
handleCustomWorldSelect(matchedEntry.profile);
});
},
[
handleCustomWorldSelect,
platformBootstrap,
platformBootstrap.savedCustomWorldEntries,
runProtectedAction,
],
);
const handleDeleteLibraryEntry = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
if (!entry.profileId || deletingCreationWorkId) {
@@ -1815,6 +2031,32 @@ export function PlatformEntryFlowShellImpl({
],
);
const clearSelectedPublicWorkAuthor = useCallback(() => {
publicWorkAuthorRequestKeyRef.current += 1;
setSelectedPublicWorkAuthor(null);
}, []);
const loadSelectedPublicWorkAuthor = useCallback(
(entry: PlatformPublicGalleryCard) => {
const requestKey = publicWorkAuthorRequestKeyRef.current + 1;
publicWorkAuthorRequestKeyRef.current = requestKey;
setSelectedPublicWorkAuthor(null);
void resolvePublicWorkAuthorSummary(entry)
.then((author) => {
if (publicWorkAuthorRequestKeyRef.current === requestKey) {
setSelectedPublicWorkAuthor(author);
}
})
.catch(() => {
if (publicWorkAuthorRequestKeyRef.current === requestKey) {
setSelectedPublicWorkAuthor(null);
}
});
},
[],
);
const openPublicWorkDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
setSelectedPublicWorkDetail(entry);
@@ -1829,19 +2071,44 @@ export function PlatformEntryFlowShellImpl({
[setSelectionStage],
);
useEffect(() => {
const detailEntry =
selectionStage === 'work-detail'
? selectedPublicWorkDetail
: selectionStage === 'detail' &&
selectedDetailEntry &&
selectedDetailEntry.visibility !== 'draft'
? mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry)
: null;
if (!detailEntry) {
clearSelectedPublicWorkAuthor();
return;
}
loadSelectedPublicWorkAuthor(detailEntry);
}, [
clearSelectedPublicWorkAuthor,
loadSelectedPublicWorkAuthor,
selectedDetailEntry,
selectedPublicWorkDetail,
selectionStage,
]);
const openRpgPublicWorkDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
clearSelectedPublicWorkAuthor();
setSelectedPublicWorkDetail(entry);
setSelectionStage('work-detail');
try {
const detailEntry =
await detailNavigation.loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
setSelectedPublicWorkDetail(
mapRpgGalleryCardToPublicWorkDetail(detailEntry),
);
const detailCard = mapRpgGalleryCardToPublicWorkDetail(detailEntry);
setSelectedPublicWorkDetail(detailCard);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkStagePath('work-detail', detailEntry.publicWorkCode),
@@ -1856,7 +2123,12 @@ export function PlatformEntryFlowShellImpl({
setIsPublicWorkDetailBusy(false);
}
},
[detailNavigation, setSelectedDetailEntry, setSelectionStage],
[
clearSelectedPublicWorkAuthor,
detailNavigation,
setSelectedDetailEntry,
setSelectionStage,
],
);
const openPuzzlePublicWorkDetail = useCallback(
@@ -2004,7 +2276,13 @@ export function PlatformEntryFlowShellImpl({
}
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
void startPuzzleRunFromProfile(selectedPublicWorkDetail.profileId);
const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError('当前拼图作品信息不完整,暂时无法进入玩法。');
return;
}
setPublicWorkDetailError(null);
void startPuzzleRunFromProfile(work.profileId, 'work-detail', work, true);
return;
}
@@ -2106,7 +2384,7 @@ export function PlatformEntryFlowShellImpl({
(entry) => entry.profileId !== nextEntry.profileId,
),
]);
detailNavigation.openSavedCustomWorldEditor(nextEntry);
void detailNavigation.openSavedCustomWorldEditor(nextEntry);
})
.catch((error) => {
setPublicWorkDetailError(
@@ -2272,7 +2550,7 @@ export function PlatformEntryFlowShellImpl({
setSearchedPublicUser(user);
} catch (error) {
setPublicSearchError(
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
resolveRpgCreationErrorMessage(error, '未找到对应的陶泥号或作品号。'),
);
} finally {
setIsSearchingPublicCode(false);
@@ -2539,9 +2817,6 @@ export function PlatformEntryFlowShellImpl({
handleDeletePublishedWork(item);
}}
deletingWorkId={deletingCreationWorkId}
onExperienceRpg={(item) => {
handleExperienceRpgWork(item);
}}
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
bigFishItems={isBigFishCreationVisible ? bigFishWorks : []}
onOpenBigFishDetail={
@@ -2553,15 +2828,6 @@ export function PlatformEntryFlowShellImpl({
}
: undefined
}
onExperienceBigFish={
isBigFishCreationVisible
? (item) => {
runProtectedAction(() => {
void startBigFishRunFromWork(item, 'platform');
});
}
: null
}
onDeleteBigFish={
isBigFishCreationVisible
? (item) => {
@@ -2575,11 +2841,6 @@ export function PlatformEntryFlowShellImpl({
void openPuzzleDraft(item);
});
}}
onExperiencePuzzle={(profileId) => {
runProtectedAction(() => {
void startPuzzleRunFromProfile(profileId, 'platform');
});
}}
onDeletePuzzle={(item) => {
handleDeletePuzzleWork(item);
}}
@@ -2648,7 +2909,7 @@ export function PlatformEntryFlowShellImpl({
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
detailNavigation.openLibraryDetail(entry);
void detailNavigation.openLibraryDetail(entry);
});
}}
onDeleteLibraryEntry={(entry) => {
@@ -2691,10 +2952,12 @@ export function PlatformEntryFlowShellImpl({
>
<PlatformWorkDetailView
entry={selectedPublicWorkDetail}
authorAvatarUrl={selectedPublicWorkAuthor?.avatarUrl ?? null}
isBusy={isPublicWorkDetailBusy || isPuzzleBusy || isBigFishBusy}
error={publicWorkDetailError}
onBack={() => {
setPublicWorkDetailError(null);
clearSelectedPublicWorkAuthor();
setSelectionStage('platform');
}}
onStart={startSelectedPublicWork}
@@ -2720,10 +2983,12 @@ export function PlatformEntryFlowShellImpl({
) : selectedDetailEntry.visibility !== 'draft' ? (
<PlatformWorkDetailView
entry={mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry)}
authorAvatarUrl={selectedPublicWorkAuthor?.avatarUrl ?? null}
isBusy={detailNavigation.isMutatingDetail}
error={detailNavigation.detailError}
onBack={() => {
detailNavigation.setDetailError(null);
clearSelectedPublicWorkAuthor();
entryNavigation.backToPlatformHome();
}}
onStart={handleStartSelectedWorld}
@@ -2747,7 +3012,7 @@ export function PlatformEntryFlowShellImpl({
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
detailNavigation.openSavedCustomWorldEditor(
void detailNavigation.openSavedCustomWorldEditor(
selectedDetailEntry,
);
});
@@ -2988,9 +3253,6 @@ export function PlatformEntryFlowShellImpl({
>
<PuzzleAgentWorkspace
session={puzzleSession}
activeOperation={puzzleOperation}
streamingReplyText={streamingPuzzleReplyText}
isStreamingReply={isStreamingPuzzleReply}
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}
@@ -3000,6 +3262,10 @@ export function PlatformEntryFlowShellImpl({
onExecuteAction={(payload) => {
void executePuzzleAction(payload);
}}
initialFormPayload={puzzleFormDraftPayload}
onCreateFromForm={(payload) => {
void createPuzzleDraftFromForm(payload);
}}
/>
</Suspense>
</motion.div>
@@ -3033,7 +3299,11 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('puzzle-agent-workspace');
}}
onRetry={() => {
void executePuzzleAction({ action: 'compile_puzzle_draft' });
void executePuzzleAction(
buildPuzzleCompileActionFromFormPayload(
puzzleFormDraftPayload,
),
);
}}
onInterrupt={undefined}
backLabel="返回创作中心"
@@ -3117,6 +3387,7 @@ export function PlatformEntryFlowShellImpl({
void startPuzzleRunFromProfile(
selectedPuzzleDetail.profileId,
'puzzle-gallery-detail',
selectedPuzzleDetail,
);
}}
/>
@@ -3155,6 +3426,9 @@ export function PlatformEntryFlowShellImpl({
onAdvanceNextLevel={() => {
void advancePuzzleLevel();
}}
onPauseChange={setPuzzleRuntimePaused}
onUseProp={usePuzzleProp}
onTimeExpired={syncPuzzleRuntimeTimeout}
/>
</Suspense>
{isPuzzleNextLevelGenerating ? (
@@ -3474,7 +3748,7 @@ export function PlatformEntryFlowShellImpl({
{searchedPublicUser.displayName}
</div>
<div className="mt-2 text-sm text-[var(--platform-text-soft)]">
{searchedPublicUser.publicUserCode}
{searchedPublicUser.publicUserCode}
</div>
</div>
) : null}

View File

@@ -0,0 +1,54 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
function createPuzzleEntry(): PlatformPublicGalleryCard {
return {
sourceType: 'puzzle',
workId: 'work-1',
profileId: 'profile-1',
publicWorkCode: 'PZ-001',
ownerUserId: 'user-1',
authorDisplayName: '137****6613',
worldName: '关键词:逍遥游拼图',
subtitle: '拼图关卡',
summaryText: '适合公开游玩的拼图作品。',
coverImageSrc: null,
themeTags: ['拼图'],
playCount: 12,
remixCount: 3,
likeCount: 4,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-25T12:00:00.000Z',
};
}
test('PlatformWorkDetailView renders compact stats and recent update time', () => {
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getByText('改造')).toBeTruthy();
expect(screen.getByText('游玩')).toBeTruthy();
expect(screen.getByText('点赞')).toBeTruthy();
expect(screen.getByText('最近更新')).toBeTruthy();
expect(screen.queryByText('改造次数')).toBeNull();
expect(screen.queryByText('游玩次数')).toBeNull();
expect(screen.queryByText('上线日期')).toBeNull();
expect(screen.getByText('2026-04-25')).toBeTruthy();
expect(screen.getAllByText('次')).toHaveLength(2);
expect(screen.getByText('赞')).toBeTruthy();
});

View File

@@ -1,20 +1,32 @@
import { ArrowLeft, Copy, GitFork, Play, Share2 } from 'lucide-react';
import {
ArrowLeft,
Clock3,
Copy,
Gamepad2,
GitFork,
Heart,
Play,
Share2,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldTags,
buildPlatformWorldDisplayTags,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldStats,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
export interface PlatformWorkDetailViewProps {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
isBusy: boolean;
error: string | null;
onBack: () => void;
@@ -40,8 +52,13 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
return 'RPG';
}
function getAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '作')[0] ?? '作';
}
export function PlatformWorkDetailView({
entry,
authorAvatarUrl,
isBusy,
error,
onBack,
@@ -50,30 +67,51 @@ export function PlatformWorkDetailView({
}: PlatformWorkDetailViewProps) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = useMemo(
() =>
[
getSourceLabel(entry),
...buildPlatformWorldTags(entry).map((tag) => tag.trim()),
]
.filter(Boolean)
.slice(0, 4),
formatPlatformWorkDisplayTags(
[getSourceLabel(entry), ...buildPlatformWorldDisplayTags(entry, 3)],
4,
),
[entry],
);
const stats = resolvePlatformWorldStats(entry);
const statItems = [
{ label: '改造次数', value: formatCompactCount(stats.remixCount) },
{ label: '游玩次数', value: formatCompactCount(stats.playCount) },
{ label: '点赞次数', value: formatCompactCount(stats.likeCount) },
{
label: '上线日期',
value: formatPlatformWorldTime(stats.publishedAt),
label: '改造',
value: formatCompactCount(stats.remixCount),
unit: '次',
icon: GitFork,
tone: 'remix',
},
{
label: '游玩',
value: formatCompactCount(stats.playCount),
unit: '次',
icon: Gamepad2,
tone: 'play',
},
{
label: '点赞',
value: formatCompactCount(stats.likeCount),
unit: '赞',
icon: Heart,
tone: 'like',
},
{
label: '最近更新',
value: formatPlatformWorldTime(stats.updatedAt ?? stats.publishedAt),
icon: Clock3,
tone: 'time',
isTime: true,
},
];
@@ -162,10 +200,26 @@ export function PlatformWorkDetailView({
</div>
<div className="min-w-0 flex-1">
<div className="platform-work-detail__name">
{entry.worldName}
{displayName}
</div>
<div className="platform-work-detail__author">
{entry.authorDisplayName}
<span className="platform-work-detail__author-avatar">
{normalizedAuthorAvatarUrl ? (
<ResolvedAssetImage
src={normalizedAuthorAvatarUrl}
alt=""
aria-hidden="true"
className="platform-work-detail__author-avatar-image"
/>
) : (
<span className="platform-work-detail__author-avatar-label">
{getAuthorAvatarLabel(entry.authorDisplayName)}
</span>
)}
</span>
<span className="platform-work-detail__author-name">
{entry.authorDisplayName}
</span>
</div>
</div>
<button
@@ -175,18 +229,37 @@ export function PlatformWorkDetailView({
disabled={isBusy}
>
<GitFork className="h-5 w-5" />
Remix
</button>
</div>
<div className="platform-work-detail__stats">
{statItems.map((item) => (
<div key={item.label} className="platform-work-detail__stat">
<div className="platform-work-detail__stat-label">
{item.label}
<div
key={item.label}
className={`platform-work-detail__stat platform-work-detail__stat--${item.tone}`}
>
<div className="platform-work-detail__stat-head">
<span className="platform-work-detail__stat-icon">
<item.icon className="h-3.5 w-3.5" />
</span>
<span className="platform-work-detail__stat-label">
{item.label}
</span>
</div>
<div className="platform-work-detail__stat-value">
{item.value}
<div
className={`platform-work-detail__stat-value${
item.isTime ? ' platform-work-detail__stat-value--time' : ''
}`}
>
<span className="platform-work-detail__stat-number">
{item.value}
</span>
{item.unit ? (
<span className="platform-work-detail__stat-unit">
{item.unit}
</span>
) : null}
</div>
</div>
))}

View File

@@ -36,8 +36,8 @@ export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) {
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
{
id: 'rpg',
title: '角色扮演 RPG',
subtitle: 'Agent 共创',
title: '角色扮演',
subtitle: '剧情演绎,冒险成长',
badge: '可创建',
locked: false,
},
@@ -51,8 +51,8 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
},
{
id: 'puzzle',
title: '拼图玩法',
subtitle: '图像锚点共创',
title: '拼图',
subtitle: '创意礼物,生活分享',
badge: '可创建',
locked: false,
},
@@ -60,14 +60,14 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '锁定',
badge: '敬请期待',
locked: true,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '锁定',
badge: '敬请期待',
locked: true,
},
];

View File

@@ -131,9 +131,9 @@ export function usePlatformCreationAgentFlowController<
const [streamingReplyText, setStreamingReplyText] = useState('');
const [isStreamingReply, setIsStreamingReply] = useState(false);
const openWorkspace = useCallback(async () => {
const openWorkspace = useCallback(async (createPayload?: TCreatePayload) => {
if (isBusy) {
return;
return null;
}
setIsBusy(true);
@@ -142,15 +142,20 @@ export function usePlatformCreationAgentFlowController<
setIsStreamingReply(false);
try {
const response = await options.client.createSession(options.createPayload);
setSession(options.client.selectSession(response));
const response = await options.client.createSession(
createPayload ?? options.createPayload,
);
const nextSession = options.client.selectSession(response);
setSession(nextSession);
options.enterCreateTab();
options.onSessionOpened?.();
options.setSelectionStage(options.workspaceStage);
return nextSession;
} catch (caughtError) {
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.open),
);
return null;
} finally {
setIsBusy(false);
}
@@ -235,8 +240,9 @@ export function usePlatformCreationAgentFlowController<
);
const executeAction = useCallback(
async (payload: TActionPayload) => {
if (!session || isBusy) {
async (payload: TActionPayload, sessionOverride?: TSession | null) => {
const targetSession = sessionOverride ?? session;
if (!targetSession || isBusy) {
return;
}
@@ -244,15 +250,15 @@ export function usePlatformCreationAgentFlowController<
setError(null);
try {
options.beforeExecuteAction?.({ payload, session });
options.beforeExecuteAction?.({ payload, session: targetSession });
const response = await options.client.executeAction(
session.sessionId,
targetSession.sessionId,
payload,
);
await options.onActionComplete?.({
payload,
response,
session,
session: targetSession,
setSession,
});
if (options.isCompileAction(payload)) {

View File

@@ -1,7 +1,6 @@
/* @vitest-environment jsdom */
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
@@ -50,11 +49,11 @@ const baseSession: PuzzleAgentSessionSnapshot = {
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '画面主体已经清楚,继续收束剩余关键词。',
text: '旧会话消息不再渲染为聊天入口。',
createdAt: '2026-04-24T10:00:00.000Z',
},
],
lastAssistantReply: '画面主体已经清楚,继续收束剩余关键词。',
lastAssistantReply: '旧会话消息不再渲染为聊天入口。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
@@ -67,64 +66,54 @@ beforeEach(() => {
}
});
test('puzzle workspace submits quick keyword fill request after two turns', async () => {
const user = userEvent.setup();
const onSubmitMessage = vi.fn();
test('puzzle workspace submits the two-field form instead of agent chat', () => {
const onCreateFromForm = vi.fn();
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('拼图标题'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '暖灯猫街',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
});
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
test('puzzle workspace falls back to compile action for restored sessions', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleAgentWorkspace
session={baseSession}
onBack={() => {}}
onSubmitMessage={onSubmitMessage}
onExecuteAction={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '补充剩余设定' }));
expect(onSubmitMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '请补充剩余设定。',
quickFillRequested: true,
}),
);
});
test('puzzle workspace hides keyword fill before two turns', () => {
render(
<PuzzleAgentWorkspace
session={{ ...baseSession, currentTurn: 1 }}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
test('puzzle workspace does not render progress action messages as chat bubbles', () => {
render(
<PuzzleAgentWorkspace
session={{
...baseSession,
messages: [
...baseSession.messages,
{
id: 'message-action-result-1',
role: 'assistant',
kind: 'action_result',
text: '拼图结果页草稿已生成。',
createdAt: '2026-04-24T10:01:00.000Z',
},
],
}}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(screen.getByText('画面主体已经清楚,继续收束剩余关键词。')).toBeTruthy();
expect(screen.queryByText('拼图结果页草稿已生成。')).toBeNull();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'compile_puzzle_draft',
promptText: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
candidateCount: 1,
});
});

View File

@@ -1,146 +1,299 @@
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
import { type ChangeEvent, useEffect, useState } from 'react';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleAgentActionRequest,
PuzzleAgentOperationRecord,
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import {
buildCreationAgentChatMessage,
createCreationAgentChatQuickActions,
createCreationAgentClientMessageId,
resolveCreationAgentQuickActionMessage,
} from '../../services/creation-agent';
import {
type CreationAgentOperationView,
type CreationAgentSessionView,
type CreationAgentTheme,
CreationAgentWorkspace,
} from '../creation-agent';
type PuzzleAgentWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
activeOperation?: PuzzleAgentOperationRecord | null;
streamingReplyText?: string;
isStreamingReply?: boolean;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
};
const PUZZLE_AGENT_THEME: CreationAgentTheme = {
accentTextClass: 'text-amber-100/84',
accentBgClass: 'bg-amber-200',
accentButtonClass: 'bg-amber-200 shadow-amber-950/20',
userBubbleClass: 'bg-amber-600 text-white',
heroClass:
'border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.96),rgba(20,24,35,0.96))]',
anchorGridClass: 'grid gap-2 sm:grid-cols-2 xl:grid-cols-5',
type PuzzleFormState = {
title: string;
pictureDescription: string;
referenceImageSrc: string;
referenceImageLabel: string;
};
function mapPuzzleSession(
session: PuzzleAgentSessionSnapshot,
): CreationAgentSessionView {
// 中文注释:生成进度与草稿写回记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。
const chatMessages = session.messages.filter(
(message) =>
message.kind === 'chat' ||
message.kind === 'summary' ||
message.kind === 'warning',
);
const EMPTY_FORM_STATE: PuzzleFormState = {
title: '',
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
};
return {
sessionId: session.sessionId,
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
title: null,
assistantSummary: null,
currentTurn: session.currentTurn,
progressPercent: session.progressPercent,
anchors: [
session.anchorPack.themePromise,
session.anchorPack.visualSubject,
session.anchorPack.visualMood,
session.anchorPack.compositionHooks,
session.anchorPack.tagsAndForbidden,
],
messages: chatMessages,
recommendedReplies: [],
};
function readPuzzleReferenceImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
}
function mapPuzzleOperation(
operation: PuzzleAgentOperationRecord | null | undefined,
): CreationAgentOperationView | null {
if (!operation) {
return null;
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
): PuzzleFormState {
if (initialFormPayload) {
return {
title: initialFormPayload.seedText ?? '',
pictureDescription: initialFormPayload.pictureDescription ?? '',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择参考图'
: '',
};
}
if (!session) {
return EMPTY_FORM_STATE;
}
return {
operationId: operation.operationId,
type: operation.type,
status: operation.status,
phaseLabel: operation.phaseLabel,
phaseDetail: operation.phaseDetail,
progress: operation.progress,
error: operation.error,
title:
session.draft?.levelName ||
session.anchorPack.themePromise.value ||
session.messages.find((message) => message.role === 'user')?.text ||
'',
pictureDescription:
session.draft?.summary || session.anchorPack.visualSubject.value || '',
referenceImageSrc: '',
referenceImageLabel: '',
};
}
/**
* 拼图 Agent 共创工作区只保留品类适配,聊天 UI 与进度管理统一走 CreationAgentWorkspace
* 拼图创作入口已从 Agent 对话改为填表式
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
*/
export function PuzzleAgentWorkspace({
session,
activeOperation = null,
streamingReplyText = '',
isStreamingReply = false,
isBusy = false,
error = null,
onBack,
onSubmitMessage,
onExecuteAction,
onCreateFromForm,
initialFormPayload = null,
}: PuzzleAgentWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
useEffect(() => {
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
}, [initialFormPayload, session]);
const title = formState.title.trim();
const pictureDescription = formState.pictureDescription.trim();
const canSubmit = Boolean(title && pictureDescription) && !isBusy;
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageLabel: file.name.trim() || '本地参考图',
}));
setReferenceImageError(null);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const submitForm = () => {
if (!canSubmit) {
return;
}
const payload = {
seedText: title,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
};
if (onCreateFromForm) {
onCreateFromForm(payload);
return;
}
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
candidateCount: 1,
});
};
return (
<CreationAgentWorkspace
session={session ? mapPuzzleSession(session) : null}
theme={PUZZLE_AGENT_THEME}
loadingText="正在准备拼图共创工作区..."
composerPlaceholder="说说题材、主体、气质或你不希望出现的元素..."
primaryActionLabel="生成结果页"
activeOperation={mapPuzzleOperation(activeOperation)}
streamingReplyText={streamingReplyText}
isStreamingReply={isStreamingReply}
isBusy={isBusy}
error={error}
quickActions={createCreationAgentChatQuickActions()}
onBack={onBack}
onSubmitText={(text) => {
onSubmitMessage(
buildCreationAgentChatMessage({
clientMessageId: createCreationAgentClientMessageId('puzzle'),
text,
}),
);
}}
onPrimaryAction={() => {
onExecuteAction({ action: 'compile_puzzle_draft' });
}}
onQuickAction={(action) => {
const quickActionMessage = resolveCreationAgentQuickActionMessage(
action.key,
'请总结一下当前已经成形的拼图设定。',
);
onSubmitMessage(
buildCreationAgentChatMessage({
clientMessageId: createCreationAgentClientMessageId('puzzle'),
...quickActionMessage,
}),
);
}}
/>
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="space-y-5">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.title}
disabled={isBusy}
onChange={(event) =>
setFormState((current) => ({
...current,
title: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="拼图标题"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<div className="relative mt-2">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={10}
onChange={(event) =>
setFormState((current) => ({
...current,
pictureDescription: event.target.value,
}))
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={
formState.referenceImageSrc ? '更换参考图' : '添加参考图'
}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
</div>
</label>
{formState.referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={formState.referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{formState.referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setFormState((current) => ({
...current,
referenceImageSrc: '',
referenceImageLabel: '',
}));
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
{referenceImageError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{referenceImageError}
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={!canSubmit}
onClick={submitForm}
className={`platform-button platform-button--primary ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
稿
</span>
</button>
</div>
</div>
);
}

View File

@@ -6,6 +6,10 @@ import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
} from '../rpg-entry/rpgEntryWorldPresentation';
type PuzzleGalleryDetailViewProps = {
item: PuzzleWorkSummary;
@@ -35,6 +39,8 @@ export function PuzzleGalleryDetailView({
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const displayName = formatPlatformWorkDisplayName(item.levelName);
const displayTags = formatPlatformWorkDisplayTags(item.themeTags);
const copyPublicWorkCode = () => {
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
@@ -109,7 +115,7 @@ export function PuzzleGalleryDetailView({
<div className="mt-6">
<div className="text-2xl font-black leading-tight sm:text-3xl">
{item.levelName}
{displayName}
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-amber-50/82">
<span className="inline-flex items-center gap-2">
@@ -166,7 +172,7 @@ export function PuzzleGalleryDetailView({
</div>
<div className="mt-3 flex flex-wrap gap-2">
{item.themeTags.map((tag) => (
{displayTags.map((tag) => (
<span
key={tag}
className="rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1 text-xs font-semibold text-amber-700"

View File

@@ -11,8 +11,8 @@ import {
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import * as puzzleWorksService from '../../services/puzzle-works';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import { PuzzleResultView } from './PuzzleResultView';
vi.mock('../ResolvedAssetImage', () => ({
@@ -193,7 +193,7 @@ describe('PuzzleResultView', () => {
);
});
test('uses two tabs without author preview or persistent publish validation', () => {
test('uses one ordered list without tabs or persistent publish validation', () => {
render(
<PuzzleResultView
session={createSession()}
@@ -203,8 +203,13 @@ describe('PuzzleResultView', () => {
/>,
);
expect(screen.getByRole('button', { name: '基本信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '拼图图片' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '基本信息' })).toBeNull();
expect(screen.queryByRole('button', { name: '拼图图片' })).toBeNull();
const html = document.body.textContent ?? '';
expect(html.indexOf('关卡名称')).toBeLessThan(html.indexOf('画面预览'));
expect(html.indexOf('画面预览')).toBeLessThan(html.indexOf('画面描述'));
expect(html.indexOf('画面描述')).toBeLessThan(html.indexOf('重新生成画面'));
expect(html.indexOf('重新生成画面')).toBeLessThan(html.indexOf('题材标签'));
expect(screen.queryByText('作者预览')).toBeNull();
expect(screen.queryByText('发布校验')).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
@@ -289,6 +294,9 @@ describe('PuzzleResultView', () => {
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
@@ -299,11 +307,43 @@ describe('PuzzleResultView', () => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
summary: '一只猫在雨夜灯牌下回头。',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
});
test('auto saves edited picture description to the puzzle work profile', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
summary: '一只猫在雨夜灯牌下回头。',
}),
);
});
test('requires at least three theme tags before publish can pass', () => {
const onExecuteAction = vi.fn();
@@ -331,6 +371,50 @@ describe('PuzzleResultView', () => {
).toBe(true);
});
test('publishes with the edited picture description', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
themeTags: ['猫咪', '雨夜', '暖灯'],
},
resultPreview: {
draft: {
...createSession().draft!,
themeTags: ['猫咪', '雨夜', '暖灯'],
},
publishReady: true,
blockers: [],
qualityFindings: [],
},
})}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
'button',
{ name: '发布到广场' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'publish_puzzle_work',
levelName: '雨夜猫街',
summary: '一只猫在雨夜灯牌下回头。',
themeTags: ['猫咪', '雨夜', '暖灯'],
});
});
test('auto saves added and removed theme tags', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -387,14 +471,14 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
expect(screen.getByText('画面描述')).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByText(//u)).toBeNull();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
@@ -428,19 +512,22 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
const dialog = await screen.findByRole('dialog', {
name: '选择历史拼图素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: / user-1/u }));
fireEvent.click(
await within(dialog).findByRole('button', { name: / user-1/u }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史拼图素材' })).toBeNull();
expect(
screen.queryByRole('dialog', { name: '选择历史拼图素材' }),
).toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
@@ -459,10 +546,9 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
'/puzzle/candidate-1.png',
);
expect(
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
).toBe('/puzzle/candidate-1.png');
rerender(
<PuzzleResultView
@@ -492,9 +578,9 @@ describe('PuzzleResultView', () => {
);
await waitFor(() => {
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
'/puzzle/candidate-2.png',
);
expect(
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
).toBe('/puzzle/candidate-2.png');
});
});
@@ -534,12 +620,10 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
await waitFor(() => {
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
'/puzzle/candidate-2.png',
);
expect(
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
).toBe('/puzzle/candidate-2.png');
});
});
});

View File

@@ -33,7 +33,6 @@ type PuzzleResultViewProps = {
onStartTestRun?: (draft: PuzzleResultDraft) => void;
};
type PuzzleResultTab = 'basic' | 'images';
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type DraftEditState = {
@@ -115,7 +114,9 @@ function buildPublishReady(
...(editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT
? []
: [`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT}${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`]),
: [
`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT}${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`,
]),
...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']),
];
@@ -143,9 +144,7 @@ function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) {
null;
return (
selectedCandidate?.imageSrc?.trim() ||
draft.coverImageSrc?.trim() ||
''
selectedCandidate?.imageSrc?.trim() || draft.coverImageSrc?.trim() || ''
);
}
@@ -191,41 +190,7 @@ function PuzzleResultHeader({
);
}
function PuzzleResultTabs({
activeTab,
onActiveTabChange,
}: {
activeTab: PuzzleResultTab;
onActiveTabChange: (tab: PuzzleResultTab) => void;
}) {
const tabs: Array<{ key: PuzzleResultTab; label: string }> = [
{ key: 'basic', label: '基本信息' },
{ key: 'images', label: '拼图图片' },
];
return (
<div className="mb-3 overflow-x-auto">
<div className="inline-flex min-w-full gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-1 sm:min-w-0">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onActiveTabChange(tab.key)}
className={`min-h-10 flex-1 rounded-full px-4 text-sm font-bold transition sm:flex-none ${
activeTab === tab.key
? 'bg-[var(--platform-accent-strong)] text-white shadow-[0_12px_30px_rgba(255,79,139,0.18)]'
: 'text-[var(--platform-text-base)] hover:bg-white/62'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
);
}
function PuzzleBasicInfoTab({
function PuzzleThemeTagEditor({
editState,
isBusy,
onChange,
@@ -254,126 +219,103 @@ function PuzzleBasicInfoTab({
};
return (
<div className="h-full min-h-0 overflow-y-auto pr-1">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="space-y-5">
<div>
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={editState.levelName}
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{!isAddingTag ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
className="platform-icon-button h-9 w-9"
aria-label="新增题材标签"
title="新增题材标签"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{editState.themeTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1.5 text-xs font-semibold text-amber-700"
>
{tag}
<button
type="button"
disabled={isBusy}
onChange={(event) => {
onClick={() => {
onChange({
...editState,
levelName: event.target.value,
themeTags: editState.themeTags.filter(
(currentTag) => currentTag !== tag,
),
});
}}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</div>
className="rounded-full text-amber-800/70 transition hover:text-amber-950 disabled:opacity-45"
aria-label={`删除标签 ${tag}`}
title="删除标签"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
{editState.themeTags.length <= 0 ? (
<span className="text-sm text-[var(--platform-text-soft)]">
</span>
) : null}
</div>
<div>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{!isAddingTag ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
className="platform-icon-button h-9 w-9"
aria-label="新增题材标签"
title="新增题材标签"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{editState.themeTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1.5 text-xs font-semibold text-amber-700"
>
{tag}
<button
type="button"
disabled={isBusy}
onClick={() => {
onChange({
...editState,
themeTags: editState.themeTags.filter(
(currentTag) => currentTag !== tag,
),
});
}}
className="rounded-full text-amber-800/70 transition hover:text-amber-950 disabled:opacity-45"
aria-label={`删除标签 ${tag}`}
title="删除标签"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
{editState.themeTags.length <= 0 ? (
<span className="text-sm text-[var(--platform-text-soft)]">
</span>
) : null}
</div>
{isAddingTag ? (
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
<input
autoFocus
value={newTagText}
disabled={isBusy}
onChange={(event) => setNewTagText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTags();
}
if (event.key === 'Escape') {
setIsAddingTag(false);
setNewTagText('');
}
}}
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="输入新标签"
aria-label="新题材标签"
/>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={addTags}
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsAddingTag(false);
setNewTagText('');
}}
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
</div>
</div>
) : null}
{isAddingTag ? (
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
<input
autoFocus
value={newTagText}
disabled={isBusy}
onChange={(event) => setNewTagText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTags();
}
if (event.key === 'Escape') {
setIsAddingTag(false);
setNewTagText('');
}
}}
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="输入新标签"
aria-label="新题材标签"
/>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={addTags}
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsAddingTag(false);
setNewTagText('');
}}
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
</div>
</div>
</section>
</div>
) : null}
</section>
);
}
@@ -476,7 +418,7 @@ function PuzzleHistoryAssetPickerDialog({
) : null}
{!isLoading && assets.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{assets.map((asset) => (
<button
key={asset.assetObjectId}
@@ -485,7 +427,7 @@ function PuzzleHistoryAssetPickerDialog({
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.35rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<div className="aspect-[9/16] overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史拼图素材'}
@@ -511,12 +453,13 @@ function PuzzleHistoryAssetPickerDialog({
);
}
function PuzzleImagesTab({
function PuzzlePictureEditor({
draft,
editState,
formalImageSrc,
imageRefreshKey,
isBusy,
onSummaryChange,
onGenerate,
}: {
draft: PuzzleResultDraft;
@@ -524,18 +467,19 @@ function PuzzleImagesTab({
formalImageSrc: string;
imageRefreshKey: string;
isBusy: boolean;
onGenerate: (promptText?: string | null, referenceImageSrc?: string | null) => void;
onSummaryChange: (summary: string) => void;
onGenerate: (
promptText?: string | null,
referenceImageSrc?: string | null,
) => void;
}) {
const [promptText, setPromptText] = useState(draft.summary);
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [referenceImageError, setReferenceImageError] = useState<string | null>(null);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [referenceImageLabel, setReferenceImageLabel] = useState('');
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
useEffect(() => {
setPromptText(draft.summary);
}, [draft.summary]);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
@@ -560,133 +504,123 @@ function PuzzleImagesTab({
};
return (
<div className="h-full min-h-0 overflow-y-auto pr-1">
<div className="grid gap-3 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mx-auto mt-3 aspect-[9/16] w-full max-w-[24rem] overflow-hidden rounded-[1.25rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={imageRefreshKey}
alt={editState.levelName || draft.levelName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-white/66">
</div>
)}
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3">
<textarea
value={editState.summary}
disabled={isBusy}
rows={10}
onChange={(event) => onSummaryChange(event.target.value)}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<div className="absolute bottom-3 right-3 flex items-center gap-2">
<label
className={`inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="从历史拼图素材库选择"
title="从历史拼图素材库选择"
>
<Images className="h-4 w-4" />
</button>
</div>
<div className="mt-3 aspect-square overflow-hidden rounded-[1.25rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={imageRefreshKey}
alt={editState.levelName || draft.levelName}
</div>
{referenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-white/66">
</div>
)}
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3">
<div className="relative">
<textarea
value={promptText}
disabled={isBusy}
rows={10}
onChange={(event) => setPromptText(event.target.value)}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<div className="absolute bottom-3 right-3 flex items-center gap-2">
<label className={`inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`} title={referenceImageSrc ? '更换参考图' : '添加参考图'}>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="从历史拼图素材库选择"
title="从历史拼图素材库选择"
>
<Images className="h-4 w-4" />
</button>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
{referenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
}}
className="platform-button platform-button--ghost min-h-9 px-3 py-1.5 text-xs"
>
</button>
</div>
) : null}
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
</div>
) : null}
{draft.candidates[0]?.actualPrompt || draft.candidates[0]?.prompt ? (
<div className="mt-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-4 py-3 text-xs leading-6 text-[var(--platform-text-base)]">
{draft.candidates[0]?.actualPrompt || draft.candidates[0]?.prompt}
</div>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
promptText.trim() || undefined,
referenceImageSrc || undefined,
);
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
}}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
className="platform-button platform-button--ghost min-h-9 px-3 py-1.5 text-xs"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
</button>
</div>
</section>
</div>
) : null}
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
</div>
) : null}
</section>
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
editState.summary.trim() || undefined,
referenceImageSrc || undefined,
);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
</button>
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
@@ -694,13 +628,15 @@ function PuzzleImagesTab({
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setReferenceImageSrc(asset.imageSrc);
setReferenceImageLabel(`历史素材 · ${asset.ownerLabel || '未记录账号'}`);
setReferenceImageLabel(
`历史素材 · ${asset.ownerLabel || '未记录账号'}`,
);
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
/>
) : null}
</div>
</>
);
}
@@ -789,7 +725,7 @@ function PuzzlePublishDialog({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
<div className="aspect-[9/16] overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
@@ -904,7 +840,7 @@ function PuzzleResultActionBar({
}
/**
* 拼图结果页收口为两个 Tab基本信息负责标题和标签拼图图片负责单图编辑工作台
* 拼图结果页收口为单列表:关卡名称、画面预览、画面描述、重新生成画面、题材标签
* 发布校验只在点击发布后出现,避免结果页重新变成信息总表。
*/
export function PuzzleResultView({
@@ -919,11 +855,11 @@ export function PuzzleResultView({
const draft = session.draft;
const formalImageSrc = draft ? resolvePuzzleFormalImageSrc(draft) : '';
const imageRefreshKey = `${session.updatedAt}:${formalImageSrc}`;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('basic');
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
const [autoSaveState, setAutoSaveState] = useState<PuzzleAutoSaveState>('idle');
const [autoSaveState, setAutoSaveState] =
useState<PuzzleAutoSaveState>('idle');
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
useEffect(() => {
@@ -945,7 +881,9 @@ export function PuzzleResultView({
const normalizedLevelName = editState.levelName.trim();
const normalizedSummary = editState.summary.trim();
const normalizedTags = normalizeThemeTagInput(editState.themeTags.join(''));
const normalizedTags = normalizeThemeTagInput(
editState.themeTags.join(''),
);
const draftLevelName = draft.levelName.trim();
const draftSummary = draft.summary.trim();
const draftTags = normalizeThemeTagInput(draft.themeTags.join(''));
@@ -1022,25 +960,38 @@ export function PuzzleResultView({
onBack={onBack}
/>
<PuzzleResultTabs
activeTab={activeTab}
onActiveTabChange={setActiveTab}
/>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={editState.levelName}
disabled={isBusy}
onChange={(event) => {
setEditState({
...editState,
levelName: event.target.value,
});
}}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="关卡名称"
/>
</section>
<div className="min-h-0 flex-1 overflow-hidden">
{activeTab === 'basic' ? (
<PuzzleBasicInfoTab
editState={editState}
isBusy={isBusy}
onChange={setEditState}
/>
) : (
<PuzzleImagesTab
<PuzzlePictureEditor
draft={draft}
editState={editState}
formalImageSrc={formalImageSrc}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onSummaryChange={(summary) => {
setEditState({
...editState,
summary,
});
}}
onGenerate={(promptText, referenceImageSrc) => {
onExecuteAction({
action: 'generate_puzzle_images',
@@ -1050,7 +1001,13 @@ export function PuzzleResultView({
});
}}
/>
)}
<PuzzleThemeTagEditor
editState={editState}
isBusy={isBusy}
onChange={setEditState}
/>
</div>
</div>
{error ? (

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
@@ -13,8 +13,8 @@ import {
} from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({
resolvedUrl: '',
useResolvedAssetReadUrl: (src: string | null) => ({
resolvedUrl: src ?? '',
isResolving: false,
shouldResolve: false,
}),
@@ -32,6 +32,7 @@ function createAuthValue() {
requireAuth: (action: () => void) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
setCurrentUser: vi.fn(),
logout: async () => {},
musicVolume: 0.42,
setMusicVolume: vi.fn(),
@@ -87,6 +88,13 @@ const clearedRun: PuzzleRunSnapshot = {
startedAtMs: 1000,
clearedAtMs: 13_340,
elapsedMs: 12_340,
timeLimitMs: 300_000,
remainingMs: 287_660,
pausedAccumulatedMs: 0,
pauseStartedAtMs: null,
freezeAccumulatedMs: 0,
freezeStartedAtMs: null,
freezeUntilMs: null,
leaderboardEntries: [
{
rank: 1,
@@ -198,12 +206,43 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
});
test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => {
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={{
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
}}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const board = screen.getByTestId('puzzle-board');
expect(board.className).toContain('aspect-[9/16]');
expect(board.className).not.toContain('aspect-video');
expect(board.className).not.toContain('aspect-square');
expect(board.getAttribute('style')).toContain('grid-template-rows');
expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull();
});
test('合并块按实际拼块外轮廓描边', () => {
const mergedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
@@ -245,18 +284,196 @@ test('合并块按实际拼块外轮廓描边', () => {
expect(outlinedPieces[0]?.className).toContain('border-r-0');
expect(outlinedPieces[0]?.className).toContain('border-b-0');
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
expect(outlinedPieces[0]?.className).toContain('rounded-tr-none');
expect(outlinedPieces[0]?.className).toContain('rounded-bl-none');
expect(outlinedPieces[0]?.className).toContain('rounded-br-[0.35rem]');
expect(outlinedPieces[1]?.className).toContain('border-l-0');
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
expect(outlinedPieces[1]?.className).toContain('rounded-bl-[0.35rem]');
expect(outlinedPieces[2]?.className).toContain('border-t-0');
expect(outlinedPieces[2]?.className).toContain('rounded-tr-[0.35rem]');
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
});
test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
remainingMs: 180_000,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
pieces: clearedRun.currentLevel!.board.pieces.map((piece, index) => {
if (index === 0) {
return { ...piece, currentRow: 2, currentCol: 2 };
}
if (index === 8) {
return { ...piece, currentRow: 0, currentCol: 0 };
}
return piece;
}),
},
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onPauseChange={onPauseChange}
onUseProp={onUseProp}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提示' }));
expect(screen.getByRole('dialog', { name: '使用提示' })).toBeTruthy();
expect(screen.getByText('消耗 1 陶泥币')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(onUseProp).toHaveBeenCalledWith('hint');
expect(onPauseChange).toHaveBeenLastCalledWith(false);
expect(
(container.querySelector('[data-piece-cell-id="piece-0"]') as HTMLElement)
.style.transform,
).toBe('translate(-66.66666666666666%, -66.66666666666666%) scale(1.03)');
});
test('道具使用失败时保留确认弹窗和暂停态', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
remainingMs: 180_000,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onPauseChange={onPauseChange}
onUseProp={onUseProp}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '冻结' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy();
expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
});
test('倒计时归零时通知父层同步失败态', () => {
vi.useFakeTimers();
const onTimeExpired = vi.fn();
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now() - 181_000,
timeLimitMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onTimeExpired={onTimeExpired}
/>,
);
expect(screen.getByRole('dialog', { name: '关卡失败' })).toBeTruthy();
expect(onTimeExpired).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
remainingMs: 180_000,
coverImageSrc: '/puzzle.png',
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onPauseChange={onPauseChange}
onUseProp={onUseProp}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '原图' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(onUseProp).toHaveBeenCalledWith('reference');
expect(screen.getByTestId('puzzle-original-overlay')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: '原图' }));
expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
expect(onPauseChange).toHaveBeenLastCalledWith(false);
});
test('拖拽层级辅助函数只提升当前被拖动对象', () => {
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', false)).toBe(80);
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-1', false)).toBeUndefined();
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', true)).toBeUndefined();
expect(
resolveDraggedPieceCellLayer('piece-0', 'piece-1', false),
).toBeUndefined();
expect(
resolveDraggedPieceCellLayer('piece-0', 'piece-0', true),
).toBeUndefined();
expect(resolveDraggedPieceLayer('piece-0', 'piece-0', false)).toBe(81);
expect(resolveDraggedPieceLayer('piece-0', null, false)).toBeUndefined();

View File

@@ -1,4 +1,14 @@
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react';
import {
ArrowLeft,
ArrowRight,
Clock,
Eye,
Lightbulb,
Loader2,
Snowflake,
Sparkles,
Trophy,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
@@ -6,6 +16,8 @@ import type {
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleMergedGroupState,
PuzzleRuntimeLevelSnapshot,
PuzzleRuntimePropKind,
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
@@ -23,6 +35,11 @@ type PuzzleRuntimeShellProps = {
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
onAdvanceNextLevel: () => void;
onPauseChange?: (paused: boolean) => void | Promise<void>;
onUseProp?: (
propKind: PuzzleRuntimePropKind,
) => Promise<PuzzleRunSnapshot | null | void>;
onTimeExpired?: () => void | Promise<void>;
};
type PuzzleBoardPieceViewModel = {
@@ -103,6 +120,13 @@ function resolveMergedPieceOutlineClass(
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
),
);
const hasCell = (row: number, col: number) =>
groupCellKeys.has(buildLocalCellKey(row, col));
const hasTopBoundary = (row: number, col: number) => !hasCell(row - 1, col);
const hasRightBoundary = (row: number, col: number) => !hasCell(row, col + 1);
const hasBottomBoundary = (row: number, col: number) =>
!hasCell(row + 1, col);
const hasLeftBoundary = (row: number, col: number) => !hasCell(row, col - 1);
const hasTopEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow - 1, piece.localCol),
);
@@ -115,15 +139,63 @@ function resolveMergedPieceOutlineClass(
const hasLeftEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow, piece.localCol - 1),
);
const topLeftRadius =
hasTopEdge && hasLeftEdge
? 'rounded-tl-[0.85rem]'
: (!hasTopEdge && !hasLeftEdge) ||
(hasTopEdge &&
!hasLeftEdge &&
!hasTopBoundary(piece.localRow, piece.localCol - 1)) ||
(hasLeftEdge &&
!hasTopEdge &&
!hasLeftBoundary(piece.localRow - 1, piece.localCol))
? 'rounded-tl-[0.35rem]'
: 'rounded-tl-none';
const topRightRadius =
hasTopEdge && hasRightEdge
? 'rounded-tr-[0.85rem]'
: (!hasTopEdge && !hasRightEdge) ||
(hasTopEdge &&
!hasRightEdge &&
!hasTopBoundary(piece.localRow, piece.localCol + 1)) ||
(hasRightEdge &&
!hasTopEdge &&
!hasRightBoundary(piece.localRow - 1, piece.localCol))
? 'rounded-tr-[0.35rem]'
: 'rounded-tr-none';
const bottomRightRadius =
hasBottomEdge && hasRightEdge
? 'rounded-br-[0.85rem]'
: (!hasBottomEdge && !hasRightEdge) ||
(hasBottomEdge &&
!hasRightEdge &&
!hasBottomBoundary(piece.localRow, piece.localCol + 1)) ||
(hasRightEdge &&
!hasBottomEdge &&
!hasRightBoundary(piece.localRow + 1, piece.localCol))
? 'rounded-br-[0.35rem]'
: 'rounded-br-none';
const bottomLeftRadius =
hasBottomEdge && hasLeftEdge
? 'rounded-bl-[0.85rem]'
: (!hasBottomEdge && !hasLeftEdge) ||
(hasBottomEdge &&
!hasLeftEdge &&
!hasBottomBoundary(piece.localRow, piece.localCol - 1)) ||
(hasLeftEdge &&
!hasBottomEdge &&
!hasLeftBoundary(piece.localRow + 1, piece.localCol))
? 'rounded-bl-[0.35rem]'
: 'rounded-bl-none';
return [
hasTopEdge ? 'border-t-2' : 'border-t-0',
hasRightEdge ? 'border-r-2' : 'border-r-0',
hasBottomEdge ? 'border-b-2' : 'border-b-0',
hasLeftEdge ? 'border-l-2' : 'border-l-0',
hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : 'rounded-tl-none',
hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : 'rounded-tr-none',
hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : 'rounded-br-none',
hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : 'rounded-bl-none',
topLeftRadius,
topRightRadius,
bottomRightRadius,
bottomLeftRadius,
].join(' ');
}
@@ -180,9 +252,82 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
.padStart(2, '0')}`;
}
function formatTimerMs(value: number | null | undefined) {
const normalizedMs = Math.max(0, Math.ceil((value ?? 0) / 1000) * 1000);
const totalSeconds = Math.floor(normalizedMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function resolveActiveFreezeElapsedMs(
level: PuzzleRuntimeLevelSnapshot,
nowMs: number,
) {
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
return 0;
}
return Math.max(
0,
Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs,
);
}
function resolveRuntimeRemainingMs(
level: PuzzleRuntimeLevelSnapshot,
nowMs: number,
uiPauseStartedAtMs: number | null,
) {
if (level.status !== 'playing') {
return level.remainingMs;
}
const timeLimitMs = level.timeLimitMs || level.remainingMs;
const snapshotPauseElapsedMs = level.pauseStartedAtMs
? Math.max(0, nowMs - level.pauseStartedAtMs)
: 0;
const optimisticPauseElapsedMs =
!level.pauseStartedAtMs && uiPauseStartedAtMs
? Math.max(0, nowMs - uiPauseStartedAtMs)
: 0;
const effectiveElapsedMs = Math.max(
0,
nowMs -
level.startedAtMs -
level.pausedAccumulatedMs -
snapshotPauseElapsedMs -
optimisticPauseElapsedMs -
level.freezeAccumulatedMs -
resolveActiveFreezeElapsedMs(level, nowMs),
);
return Math.max(0, timeLimitMs - effectiveElapsedMs);
}
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
type PuzzlePropDialogState = {
propKind: PuzzleRuntimePropKind;
title: string;
};
type PuzzleMergeFlashState = {
key: string;
groupId: string;
leftPercent: number;
topPercent: number;
};
type PuzzleHintDemoState = {
key: string;
pieceIds: string[];
offsetXPercent: number;
offsetYPercent: number;
};
/**
* 拼图运行时壳层。
@@ -196,10 +341,34 @@ export function PuzzleRuntimeShell({
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
onPauseChange,
onUseProp,
onTimeExpired,
}: PuzzleRuntimeShellProps) {
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
null,
);
const [isOriginalOverlayVisible, setIsOriginalOverlayVisible] =
useState(false);
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
const [isPropConfirming, setIsPropConfirming] = useState(false);
const [propConfirmError, setPropConfirmError] = useState<string | null>(null);
const [hintDemo, setHintDemo] = useState<PuzzleHintDemoState | null>(null);
const [mergeFlash, setMergeFlash] = useState<PuzzleMergeFlashState | null>(
null,
);
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
const [uiPauseStartedAtMs, setUiPauseStartedAtMs] = useState<number | null>(
null,
);
const onPauseChangeRef = useRef(onPauseChange);
const onTimeExpiredRef = useRef(onTimeExpired);
const previousUiPauseActiveRef = useRef(false);
const pauseChangePromiseRef = useRef<Promise<void>>(Promise.resolve());
const timeExpiredSyncKeyRef = useRef<string | null>(null);
const dragSessionRef = useRef<{
pieceId: string;
pointerId: number;
@@ -229,9 +398,20 @@ export function PuzzleRuntimeShell({
const [isClearResultReady, setIsClearResultReady] = useState(false);
const clearPresentationKeyRef = useRef<string | null>(null);
const clearPresentationTimeoutIdsRef = useRef<number[]>([]);
const mergeGroupSignatureRef = useRef<string | null>(null);
const hintDemoTimeoutRef = useRef<number | null>(null);
const mergeFlashTimeoutRef = useRef<number | null>(null);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const board = currentLevel?.board ?? null;
const displayRemainingMs = currentLevel
? resolveRuntimeRemainingMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
: 0;
const runtimeStatus = currentLevel
? currentLevel.status === 'playing' && displayRemainingMs <= 0
? 'failed'
: currentLevel.status
: 'playing';
const clearResultKey = currentLevel
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
: null;
@@ -262,6 +442,23 @@ export function PuzzleRuntimeShell({
return buildMergedGroupViewModels(board.mergedGroups, pieces);
}, [board, pieces]);
const largestMovableGroup = useMemo(() => {
const groups = mergedGroups.filter((group) =>
group.pieces.some(
(piece) =>
piece.row !== piece.correctRow || piece.col !== piece.correctCol,
),
);
return (
groups.sort(
(left, right) =>
right.pieceIds.length - left.pieceIds.length ||
left.minRow - right.minRow ||
left.minCol - right.minCol,
)[0] ?? null
);
}, [mergedGroups]);
const mergedCellKeys = useMemo(
() =>
new Set(
@@ -284,6 +481,54 @@ export function PuzzleRuntimeShell({
[pieces],
);
useEffect(() => {
const signature =
board?.mergedGroups
.map(
(group) =>
`${group.groupId}:${group.pieceIds.slice().sort().join(',')}`,
)
.sort()
.join('|') ?? '';
const previousSignature = mergeGroupSignatureRef.current;
mergeGroupSignatureRef.current = signature;
if (!previousSignature || !board || currentLevel?.status !== 'playing') {
return;
}
const previousGroupSizes = new Map(
previousSignature
.split('|')
.filter(Boolean)
.map((entry) => {
const [groupId, pieceIds = ''] = entry.split(':');
return [groupId, pieceIds.split(',').filter(Boolean).length] as const;
}),
);
const newGroup = mergedGroups.find(
(group) =>
group.pieceIds.length > 1 &&
group.pieceIds.length > (previousGroupSizes.get(group.groupId) ?? 0),
);
if (!newGroup) {
return;
}
if (mergeFlashTimeoutRef.current !== null) {
window.clearTimeout(mergeFlashTimeoutRef.current);
}
setMergeFlash({
key: `${newGroup.groupId}:${Date.now()}`,
groupId: newGroup.groupId,
leftPercent:
((newGroup.minCol + newGroup.colSpan / 2) / board.cols) * 100,
topPercent: ((newGroup.minRow + newGroup.rowSpan / 2) / board.rows) * 100,
});
mergeFlashTimeoutRef.current = window.setTimeout(() => {
setMergeFlash(null);
}, PUZZLE_MERGE_FLASH_DURATION_MS);
}, [board, currentLevel?.status, mergedGroups]);
const resolvePieceCellElement = (pieceId: string) => {
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
const pieceCellElement =
@@ -447,6 +692,76 @@ export function PuzzleRuntimeShell({
[],
);
useEffect(() => {
onPauseChangeRef.current = onPauseChange;
}, [onPauseChange]);
useEffect(() => {
onTimeExpiredRef.current = onTimeExpired;
}, [onTimeExpired]);
const isUiPauseActive =
isSettingsPanelOpen || Boolean(propDialog) || isOriginalOverlayVisible;
useEffect(() => {
if (previousUiPauseActiveRef.current === isUiPauseActive) {
return;
}
previousUiPauseActiveRef.current = isUiPauseActive;
setUiPauseStartedAtMs((currentValue) =>
isUiPauseActive ? (currentValue ?? Date.now()) : null,
);
pauseChangePromiseRef.current = Promise.resolve(
onPauseChangeRef.current?.(isUiPauseActive),
).catch(() => undefined);
}, [isUiPauseActive]);
useEffect(() => {
if (!currentLevel || currentLevel.status !== 'playing') {
return;
}
const timerId = window.setInterval(() => {
setTimerNowMs(Date.now());
}, 250);
return () => window.clearInterval(timerId);
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
useEffect(() => {
if (!run || !currentLevel || currentLevel.status !== 'playing') {
return;
}
if (displayRemainingMs > 0) {
return;
}
const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`;
if (timeExpiredSyncKeyRef.current === syncKey) {
return;
}
timeExpiredSyncKeyRef.current = syncKey;
void onTimeExpiredRef.current?.();
}, [
currentLevel?.levelIndex,
currentLevel?.startedAtMs,
currentLevel?.status,
displayRemainingMs,
run?.runId,
]);
useEffect(
() => () => {
if (hintDemoTimeoutRef.current !== null) {
window.clearTimeout(hintDemoTimeoutRef.current);
}
if (mergeFlashTimeoutRef.current !== null) {
window.clearTimeout(mergeFlashTimeoutRef.current);
}
},
[],
);
useEffect(() => {
if (!currentLevel || !clearResultKey) {
clearPresentationKeyRef.current = null;
@@ -498,7 +813,7 @@ export function PuzzleRuntimeShell({
}
const handlePieceClick = (pieceId: string) => {
if (isBusy) {
if (isInteractionLocked) {
return;
}
@@ -585,7 +900,7 @@ export function PuzzleRuntimeShell({
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
if (isBusy) {
if (isInteractionLocked) {
return;
}
event.preventDefault();
@@ -631,7 +946,18 @@ export function PuzzleRuntimeShell({
scheduleDragVisual();
};
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
const draggingGroupId = dragRenderTarget?.groupId ?? null;
const freezeRemainingMs =
currentLevel.freezeUntilMs && currentLevel.status === 'playing'
? Math.max(0, currentLevel.freezeUntilMs - timerNowMs)
: 0;
const statusLabel =
runtimeStatus === 'cleared'
? '已通关'
: runtimeStatus === 'failed'
? '失败'
: '进行中';
const nextAvailable =
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
const levelLabel = `${currentLevel.levelIndex}`;
@@ -643,8 +969,85 @@ export function PuzzleRuntimeShell({
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
const draggingGroupId = dragRenderTarget?.groupId ?? null;
const isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
if (runtimeStatus !== 'playing') {
return;
}
setPropConfirmError(null);
setPropDialog({ propKind, title });
};
const playHintDemo = () => {
const targetGroup = largestMovableGroup;
const targetPieces = targetGroup?.pieces ?? [];
const fallbackPiece = pieces.find(
(piece) =>
piece.row !== piece.correctRow || piece.col !== piece.correctCol,
);
const anchorPiece = targetPieces[0] ?? fallbackPiece ?? null;
if (!anchorPiece) {
return;
}
const pieceIds =
targetPieces.length > 0
? targetPieces.map((piece) => piece.pieceId)
: [anchorPiece.pieceId];
const offsetXPercent =
((anchorPiece.correctCol - anchorPiece.col) / board.cols) * 100;
const offsetYPercent =
((anchorPiece.correctRow - anchorPiece.row) / board.rows) * 100;
setHintDemo({
key: `${anchorPiece.pieceId}:${Date.now()}`,
pieceIds,
offsetXPercent,
offsetYPercent,
});
if (hintDemoTimeoutRef.current !== null) {
window.clearTimeout(hintDemoTimeoutRef.current);
}
hintDemoTimeoutRef.current = window.setTimeout(() => {
setHintDemo(null);
}, PUZZLE_HINT_DEMO_DURATION_MS);
};
const handleConfirmProp = async () => {
if (!propDialog) {
return;
}
const propKind = propDialog.propKind;
setIsPropConfirming(true);
setPropConfirmError(null);
try {
await pauseChangePromiseRef.current;
const useResult = await onUseProp?.(propKind);
if (useResult === null) {
return;
}
setPropDialog(null);
} catch (error) {
setPropConfirmError(
error instanceof Error ? error.message : '使用拼图道具失败',
);
return;
} finally {
setIsPropConfirming(false);
}
if (propKind === 'hint') {
playHintDemo();
}
if (propKind === 'reference') {
setIsOriginalOverlayVisible(true);
}
if (propKind === 'freezeTime') {
setIsFreezeEffectVisible(true);
window.setTimeout(() => {
setIsFreezeEffectVisible(false);
}, 900);
}
};
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
@@ -680,6 +1083,16 @@ export function PuzzleRuntimeShell({
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
{levelLabel}
</div>
<div
className={`mt-1 inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-mono text-xs font-black ${
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
? 'bg-red-500/22 text-red-100'
: 'bg-white/10 text-white/86'
}`}
>
<Clock className="h-3.5 w-3.5" />
{formatTimerMs(displayRemainingMs)}
</div>
</div>
<button
@@ -697,13 +1110,14 @@ export function PuzzleRuntimeShell({
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
<div className="absolute inset-0 flex items-center justify-center p-3 pt-28 pb-32 sm:p-4">
<div
ref={boardRef}
data-testid="puzzle-board"
className="relative grid aspect-square w-full max-w-[min(92vw,92vh)] touch-none select-none rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
className="relative grid aspect-[9/16] w-full max-w-[min(96vw,calc(56.25vh_-_8.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:rounded-[1.45rem]"
style={{
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
}}
>
{buildBoardCells(board).map((cell) => {
@@ -726,13 +1140,22 @@ export function PuzzleRuntimeShell({
pieceCellElementRefMap.current.delete(piece.pieceId);
}}
data-piece-cell-id={piece?.pieceId ?? undefined}
className="relative p-1"
className="relative"
style={{
zIndex: resolveDraggedPieceCellLayer(
piece?.pieceId,
draggingPieceId,
isMerged,
),
transform:
piece && hintDemo?.pieceIds.includes(piece.pieceId)
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.03)`
: undefined,
transition: hintDemo?.pieceIds.includes(
piece?.pieceId ?? '',
)
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
: undefined,
}}
>
<div
@@ -747,13 +1170,13 @@ export function PuzzleRuntimeShell({
pieceElementRefMap.current.delete(piece.pieceId);
}}
data-piece-id={piece?.pieceId ?? undefined}
className={`relative flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
className={`relative flex h-full items-center justify-center border-2 border-white/22 text-sm font-black transition ${
occupied
? isSelected
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
: isMerged
? 'border-transparent bg-transparent text-white'
: 'border-white/18 bg-white/12 text-white'
: 'bg-white/12 text-white'
: 'border-white/8 bg-black/18 text-white/20'
} ${
isMerged
@@ -792,7 +1215,7 @@ export function PuzzleRuntimeShell({
}}
>
{piece ? (
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
<div className="relative h-full w-full overflow-hidden">
{isMerged ? null : resolvedCoverImage ? (
<div
className="absolute inset-0"
@@ -833,12 +1256,22 @@ export function PuzzleRuntimeShell({
groupElementRefMap.current.delete(group.groupId);
}}
data-merged-group-id={group.groupId}
className="pointer-events-none absolute z-10 p-1"
className="pointer-events-none absolute z-10"
style={{
zIndex: resolveDraggedMergedGroupLayer(
group.groupId,
draggingGroupId,
),
transform: hintDemo?.pieceIds.some((pieceId) =>
group.pieceIds.includes(pieceId),
)
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
: undefined,
transition: hintDemo?.pieceIds.some((pieceId) =>
group.pieceIds.includes(pieceId),
)
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
: undefined,
left: `${(group.minCol / board.cols) * 100}%`,
top: `${(group.minRow / board.rows) * 100}%`,
width: `${(group.colSpan / board.cols) * 100}%`,
@@ -855,7 +1288,7 @@ export function PuzzleRuntimeShell({
{group.pieces.map((piece) => (
<div
key={piece.pieceId}
className={`pointer-events-auto relative touch-none overflow-hidden border-emerald-100/72 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
className={`pointer-events-auto relative touch-none overflow-hidden border-white/22 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
group,
piece,
)}`}
@@ -906,17 +1339,86 @@ export function PuzzleRuntimeShell({
</div>
</div>
))}
{isOriginalOverlayVisible && resolvedCoverImage ? (
<div
data-testid="puzzle-original-overlay"
className="pointer-events-none absolute inset-0 z-40 bg-black/10"
>
<div
className="absolute inset-0 opacity-70"
style={{
backgroundImage: `url("${resolvedCoverImage}")`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
}}
/>
</div>
) : null}
{mergeFlash ? (
<div
key={mergeFlash.key}
data-testid="puzzle-merge-flash"
className="pointer-events-none absolute z-50"
style={{
left: `${mergeFlash.leftPercent}%`,
top: `${mergeFlash.topPercent}%`,
}}
>
<div className="puzzle-merge-center-flash" />
</div>
) : null}
</div>
</div>
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-end gap-3 px-4 py-4">
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex items-center gap-2 rounded-full bg-black/32 p-1.5 backdrop-blur">
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('hint', '使用提示')}
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
>
<Lightbulb className="h-4 w-4 text-amber-100" />
</button>
<button
type="button"
disabled={runtimeStatus !== 'playing'}
aria-pressed={isOriginalOverlayVisible}
onClick={() => {
if (isOriginalOverlayVisible) {
setIsOriginalOverlayVisible(false);
return;
}
openPropDialog('reference', '查看原图');
}}
className={`inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold transition hover:bg-white/10 disabled:opacity-45 ${
isOriginalOverlayVisible
? 'bg-sky-200 text-slate-950'
: 'text-white/86'
}`}
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('freezeTime', '冻结时间')}
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
>
<Snowflake className="h-4 w-4 text-cyan-100" />
</button>
</div>
<div className="flex flex-col items-end gap-2">
{error ? (
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
{error}
</div>
) : null}
{selectedPieceId && currentLevel.status !== 'cleared' ? (
{selectedPieceId && runtimeStatus === 'playing' ? (
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
</div>
@@ -935,9 +1437,11 @@ export function PuzzleRuntimeShell({
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
{isBusy
? '同步中...'
: currentLevel.status === 'cleared'
: runtimeStatus === 'cleared'
? '等待下一关候选'
: '完成整张图即可通关'}
: runtimeStatus === 'failed'
? '本关失败'
: '完成整张图即可通关'}
</div>
)}
</div>
@@ -954,6 +1458,81 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{freezeRemainingMs > 0 || isFreezeEffectVisible ? (
<div
data-testid="puzzle-freeze-effect"
className="pointer-events-none absolute inset-0 z-30"
>
<div className="puzzle-freeze-effect-layer absolute inset-0 backdrop-saturate-150" />
<div className="absolute left-1/2 top-28 -translate-x-1/2 rounded-full border border-cyan-100/30 bg-cyan-950/50 px-3 py-1.5 font-mono text-xs font-black text-cyan-50 backdrop-blur">
{formatTimerMs(freezeRemainingMs)}
</div>
</div>
) : null}
{propDialog ? (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={() => {
if (!isPropConfirming) {
setPropDialog(null);
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-prop-confirm-title"
className="pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-center gap-3 border-b border-white/10 px-5 py-4">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
<Sparkles className="h-4 w-4" />
</span>
<h2
id="puzzle-prop-confirm-title"
className="text-sm font-black text-white"
>
{propDialog.title}
</h2>
</header>
<div className="px-5 py-4 text-sm text-white/72">
1
{propConfirmError ? (
<div className="mt-3 rounded-[0.9rem] border border-red-300/20 bg-red-500/12 px-3 py-2 text-xs leading-5 text-red-100">
{propConfirmError}
</div>
) : null}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={() => setPropDialog(null)}
disabled={isPropConfirming}
className="rounded-full border border-white/12 bg-black/20 px-4 py-2 text-xs font-bold text-zinc-200 transition hover:text-white"
>
</button>
<button
type="button"
disabled={isPropConfirming}
onClick={() => {
void handleConfirmProp();
}}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-60"
>
{isPropConfirming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
</button>
</footer>
</section>
</div>
) : null}
{isSettingsPanelOpen ? (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
@@ -1079,6 +1658,38 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{runtimeStatus === 'failed' ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-failed-title"
className="flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
>
<header className="border-b border-white/10 px-5 py-4">
<h2
id="puzzle-failed-title"
className="text-lg font-black text-white"
>
</h2>
<div className="mt-1 text-xs text-white/62">
{currentLevel.levelName}
</div>
</header>
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onBack}
className="rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100"
>
</button>
</footer>
</section>
</div>
) : null}
{isClearResultOpen ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
<section
@@ -1163,7 +1774,9 @@ export function PuzzleRuntimeShell({
))
) : (
<div className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
{isBusy
? '正在同步真实排行榜…'
: '暂无真实排行榜成绩'}
</div>
)}
</div>

View File

@@ -219,7 +219,7 @@ export function RpgCreationRoleAnimationSection(props: {
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}叙世`}
subLabel={`消耗${animationPointCost}陶泥`}
onClick={onGenerateAnimation}
disabled={
isSelectedAnimationGenerating ||

View File

@@ -790,7 +790,7 @@ export function RpgCreationRoleAssetStudioModal({
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 叙世币。\n${params.description}`,
`${params.kindLabel}预计消耗 ${params.points} 陶泥币。\n${params.description}`,
);
};

View File

@@ -166,7 +166,7 @@ export function RpgCreationRoleVisualSection(props: {
? '重新生成角色形象'
: '生成角色形象'
}
subLabel={`消耗${visualPointCost}叙世`}
subLabel={`消耗${visualPointCost}陶泥`}
onClick={onGenerateVisuals}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
tone="sky"

View File

@@ -16,9 +16,9 @@ export function RpgEntryBrandLogo({
className={`platform-brand-logo ${className}`.trim()}
role={decorative ? undefined : 'img'}
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : '叙世 GENARRATIVE'}
aria-label={decorative ? undefined : '陶泥 GENARRATIVE'}
>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
</span>
);

View File

@@ -7,6 +7,7 @@ import { beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -27,6 +28,7 @@ import {
getPuzzleGalleryDetail,
listPuzzleGallery,
} from '../../services/puzzle-gallery';
import { startPuzzleRun } from '../../services/puzzle-runtime';
import { listPuzzleWorks } from '../../services/puzzle-works';
import {
createRpgCreationSession,
@@ -81,12 +83,12 @@ async function clickFirstAsyncButtonByName(
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
expect(await screen.findByText('角色扮演')).toBeTruthy();
}
async function openNewRpgCreation(user: ReturnType<typeof userEvent.setup>) {
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await user.click(screen.getByRole('button', { name: /.*/u }));
}
function getPlatformTabPanel(tab: string) {
@@ -134,9 +136,20 @@ vi.mock('../../services/puzzle-gallery', () => ({
listPuzzleGallery: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime', () => ({
advanceLocalPuzzleNextLevel: vi.fn(),
dragPuzzlePieceOrGroup: vi.fn(),
startPuzzleRun: vi.fn(),
swapPuzzlePieces: vi.fn(),
submitPuzzleLeaderboard: vi.fn(),
updatePuzzleRunPause: vi.fn(),
usePuzzleRuntimeProp: vi.fn(),
}));
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
deleteRpgEntryWorldProfile: vi.fn(),
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
getRpgEntryWorldLibraryDetail: vi.fn(),
}));
vi.mock('../../services/big-fish-creation', () => ({
@@ -362,6 +375,7 @@ const mockAuthUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumberMasked: null,
loginMethod: 'password',
@@ -369,6 +383,62 @@ const mockAuthUser: AuthUser = {
wechatBound: false,
};
function buildMockPuzzleRun(
profileId: string,
levelName: string,
): PuzzleRunSnapshot {
const gridSize = 3 as const;
return {
runId: `run-${profileId}`,
entryProfileId: profileId,
clearedLevelCount: 0,
currentLevelIndex: 1,
currentGridSize: gridSize,
playedProfileIds: [profileId],
previousLevelTags: ['机关'],
recommendedNextProfileId: null,
leaderboardEntries: [],
currentLevel: {
runId: `run-${profileId}`,
levelIndex: 1,
gridSize,
profileId,
levelName,
authorDisplayName: '拼图作者',
themeTags: ['机关'],
coverImageSrc: null,
status: 'playing',
startedAtMs: 1_000,
clearedAtMs: null,
elapsedMs: null,
timeLimitMs: 300_000,
remainingMs: 300_000,
pausedAccumulatedMs: 0,
pauseStartedAtMs: null,
freezeAccumulatedMs: 0,
freezeStartedAtMs: null,
freezeUntilMs: null,
leaderboardEntries: [],
board: {
rows: 3,
cols: 3,
selectedPieceId: null,
allTilesResolved: false,
mergedGroups: [],
pieces: Array.from({ length: 9 }, (_, index) => ({
pieceId: `piece-${index}`,
correctRow: Math.floor(index / 3),
correctCol: index % 3,
currentRow: Math.floor(index / 3),
currentCol: index % 3,
mergedGroupId: null,
})),
},
},
};
}
const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
...mockSession,
stage: 'object_refining',
@@ -540,14 +610,14 @@ function buildResultViewForSession(
session,
profile,
profileSource: profile ? 'result_preview' : 'none',
targetStage: profile && isResultStage
? 'custom-world-result'
: session.stage === 'error'
? 'custom-world-generating'
: 'agent-workspace',
generationViewSource: session.stage === 'error'
? 'agent-draft-foundation'
: null,
targetStage:
profile && isResultStage
? 'custom-world-result'
: session.stage === 'error'
? 'custom-world-generating'
: 'agent-workspace',
generationViewSource:
session.stage === 'error' ? 'agent-draft-foundation' : null,
resultViewSource: profile && isResultStage ? 'agent-draft' : null,
canAutosaveLibrary: Boolean(profile && isResultStage),
canSyncResultProfile:
@@ -558,11 +628,12 @@ function buildResultViewForSession(
publishReady: Boolean(session.resultPreview?.publishReady),
canEnterWorld: Boolean(session.resultPreview?.canEnterWorld),
blockerCount: session.resultPreview?.blockers?.length ?? 0,
recoveryAction: profile && isResultStage
? 'open_result'
: session.stage === 'error'
? 'resume_generation'
: 'continue_agent',
recoveryAction:
profile && isResultStage
? 'open_result'
: session.stage === 'error'
? 'resume_generation'
: 'continue_agent',
recoveryReason: null,
};
}
@@ -574,6 +645,7 @@ type TestAuthValue = {
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
setCurrentUser: (user: AuthUser) => void;
logout: () => Promise<void>;
musicVolume: number;
setMusicVolume: (value: number) => void;
@@ -594,6 +666,7 @@ function createAuthValue(
requireAuth: (action) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
setCurrentUser: () => {},
logout: async () => {},
musicVolume: 0.42,
setMusicVolume: () => {},
@@ -1160,7 +1233,7 @@ test('create hub exposes direct template entry, keeps AIRP and visual novel lock
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await user.click(screen.getByRole('button', { name: /.*/u }));
await waitFor(() => {
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
@@ -1183,7 +1256,7 @@ test('platform create hub does not prefetch hidden big fish platform data', asyn
await openCreationHub(user);
expect(
await screen.findByRole('button', { name: / RPG/u }),
await screen.findByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(listBigFishWorks).not.toHaveBeenCalled();
@@ -1406,7 +1479,9 @@ test('opening a compiled draft with a missing agent session falls back to create
code: 'NOT_FOUND',
});
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(missingSessionError);
vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(missingSessionError);
vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(
missingSessionError,
);
render(<TestWrapper withAuth />);
@@ -1569,7 +1644,7 @@ test('creation hub clears all private work shelves immediately after logout stat
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
});
test('published puzzle works appear on home and category public shelves', async () => {
test('published puzzle works appear on home and mobile game category channel', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
@@ -1596,6 +1671,12 @@ test('published puzzle works appear on home and category public shelves', async
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(
publishedPuzzleWork.profileId,
publishedPuzzleWork.levelName,
),
});
render(<TestWrapper />);
@@ -1603,18 +1684,18 @@ test('published puzzle works appear on home and category public shelves', async
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
await user.click(screen.getByRole('button', { name: '分类' }));
await user.click(screen.getByRole('button', { name: '游戏分类' }));
const categoryPanel = getPlatformTabPanel('category');
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(
0,
);
const homePanel = getPlatformTabPanel('home');
expect(within(homePanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
expect(
within(categoryPanel).getAllByRole('button', { name: //u }).length,
within(homePanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
});
test('published big fish works stay hidden from platform home and category shelves', async () => {
test('published big fish works stay hidden from platform home and game category channel', async () => {
const user = userEvent.setup();
const publishedBigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
@@ -1644,16 +1725,16 @@ test('published big fish works stay hidden from platform home and category shelv
});
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
await user.click(screen.getByRole('button', { name: '分类' }));
await user.click(screen.getByRole('button', { name: '游戏分类' }));
const categoryPanel = getPlatformTabPanel('category');
expect(within(categoryPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
const homePanel = getPlatformTabPanel('home');
expect(within(homePanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
expect(
within(categoryPanel).queryAllByRole('button', { name: //u }).length,
within(homePanel).queryAllByRole('button', { name: //u }).length,
).toBe(0);
});
test('published puzzle detail returns to the source platform tab', async () => {
test('published puzzle detail returns to the ranking platform tab', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
@@ -1680,37 +1761,48 @@ test('published puzzle detail returns to the source platform tab', async () => {
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(
publishedPuzzleWork.profileId,
publishedPuzzleWork.levelName,
),
});
render(<TestWrapper withAuth />);
await user.click(await screen.findByRole('button', { name: '分类' }));
await user.click(await screen.findByRole('button', { name: '排行' }));
await waitFor(() => {
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
});
await waitFor(() => {
const categoryPanel = getPlatformTabPanel('category');
const rankingPanel = getPlatformTabPanel('category');
expect(
within(categoryPanel).getAllByText('星桥机关').length,
within(rankingPanel).getAllByText('星桥机关').length,
).toBeGreaterThan(0);
});
const categoryPanel = getPlatformTabPanel('category');
const rankingPanel = getPlatformTabPanel('category');
await user.click(
within(categoryPanel).getByRole('button', {
name: /.*/u,
within(rankingPanel).getByRole('button', {
name: //u,
}),
);
expect(
await screen.findByRole('button', { name: '进入第 1 关' }),
).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '启动' }));
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回上一页' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
const returnedCategoryPanel = getPlatformTabPanel('category');
expect(returnedCategoryPanel.getAttribute('aria-hidden')).toBe('false');
const returnedRankingPanel = getPlatformTabPanel('category');
expect(returnedRankingPanel.getAttribute('aria-hidden')).toBe('false');
expect(
within(returnedCategoryPanel).getAllByText('星桥机关').length,
within(returnedRankingPanel).getAllByText('星桥机关').length,
).toBeGreaterThan(0);
});
});
@@ -1854,7 +1946,7 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await user.click(screen.getByRole('button', { name: /.*/u }));
await waitFor(() => {
expect(
@@ -1892,7 +1984,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
await openCreationHub(user);
const button = screen.getByRole('button', { name: //u });
const button = screen.getByRole('button', { name: /.*/u });
await user.click(button);
await waitFor(() => {
@@ -2787,7 +2879,7 @@ test('agent draft result back button returns to creation hub without syncing res
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByText('角色扮演 RPG')).toBeTruthy();
expect(screen.getByText('角色扮演')).toBeTruthy();
});
expect(
@@ -2905,23 +2997,25 @@ test('agent draft result auto-save syncs result profile before persisting backen
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(syncedSession),
);
vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => ({
operation: {
operationId:
payload.action === 'sync_result_profile'
? 'operation-sync-result-profile-1'
: 'operation-draft-foundation-1',
type: payload.action,
status: 'queued',
phaseLabel: '已接收请求',
phaseDetail:
payload.action === 'sync_result_profile'
? '正在同步结果页档案。'
: '正在准备生成世界底稿。',
progress: 10,
error: null,
},
}));
vi.mocked(executeRpgCreationAction).mockImplementation(
async (_, payload) => ({
operation: {
operationId:
payload.action === 'sync_result_profile'
? 'operation-sync-result-profile-1'
: 'operation-draft-foundation-1',
type: payload.action,
status: 'queued',
phaseLabel: '已接收请求',
phaseDetail:
payload.action === 'sync_result_profile'
? '正在同步结果页档案。'
: '正在准备生成世界底稿。',
progress: 10,
error: null,
},
}),
);
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-sync-result-profile-1',
type: 'sync_result_profile',
@@ -3070,13 +3164,13 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
expect(await screen.findByText('角色扮演')).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText('角色扮演 RPG'),
within(getPlatformTabPanel('create')).getByText('角色扮演'),
).toBeTruthy();
});

View File

@@ -1,7 +1,8 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext';
@@ -46,14 +47,14 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
pointProducts: [
{
productId: 'points_60',
title: '60叙世币',
title: '60陶泥币',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60叙世币',
description: '首充送60陶泥币',
tier: 'normal',
},
],
@@ -73,7 +74,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
],
benefits: [
{
benefitName: '免叙世币回合数',
benefitName: '免陶泥币回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
@@ -87,7 +88,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
order: {
orderId: 'order-1',
productId: 'points_60',
productTitle: '60叙世币',
productTitle: '60陶泥币',
kind: 'points',
amountCents: 600,
status: 'paid',
@@ -138,6 +139,64 @@ const puzzlePublicEntry = {
updatedAt: '2026-04-25T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
const remixRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-remix-rank',
profileId: 'puzzle-profile-remix-rank',
publicWorkCode: 'PZ-REMIX1',
worldName: '改造高分拼图',
playCount: 2,
remixCount: 18,
likeCount: 1,
recentPlayCount7d: 0,
publishedAt: '2026-04-25T11:00:00.000Z',
updatedAt: '2026-04-25T11:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
const hotRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-hot-rank',
profileId: 'puzzle-profile-hot-rank',
publicWorkCode: 'PZ-HOT001',
worldName: '热门高分拼图',
themeTags: ['奇幻', '机关'],
playCount: 40,
remixCount: 1,
likeCount: 4,
recentPlayCount7d: 0,
publishedAt: '2026-04-24T10:00:00.000Z',
updatedAt: '2026-04-24T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
const newRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-new-rank',
profileId: 'puzzle-profile-new-rank',
publicWorkCode: 'PZ-NEW001',
worldName: '新品增长拼图',
playCount: 1,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 9,
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
const longTextRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-long-text-rank',
profileId: 'puzzle-profile-long-text-rank',
publicWorkCode: 'PZ-LONG01',
worldName: '关键词逍遥游拼图关卡',
themeTags: ['逍遥游拼图', '古风机关'],
playCount: 88,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
publishedAt: '2026-04-29T10:00:00.000Z',
updatedAt: '2026-04-29T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
function mockDesktopLayout() {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
@@ -155,7 +214,12 @@ function mockDesktopLayout() {
});
}
function renderProfileView(onRechargeSuccess = vi.fn()) {
function renderProfileView(
onRechargeSuccess = vi.fn(),
profileDashboardOverrides: Partial<
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
> = {},
) {
return render(
<AuthUiContext.Provider
value={{
@@ -164,6 +228,7 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
@@ -174,6 +239,7 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
requireAuth: (action) => action(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
@@ -200,6 +266,7 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
...profileDashboardOverrides,
}}
isLoadingPlatform={false}
isLoadingDashboard={false}
@@ -240,6 +307,7 @@ function renderLoggedOutHomeView(
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
@@ -279,6 +347,67 @@ function renderLoggedOutHomeView(
);
}
function renderStatefulLoggedOutHomeView(
overrides: Partial<
Pick<RpgEntryHomeViewProps, 'featuredEntries' | 'latestEntries'>
> = {},
) {
function StatefulLoggedOutHomeView() {
const [activeTab, setActiveTab] =
useState<RpgEntryHomeViewProps['activeTab']>('home');
return (
<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={activeTab}
onTabChange={setActiveTab}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={overrides.featuredEntries ?? []}
latestEntries={overrides.latestEntries ?? []}
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()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
</AuthUiContext.Provider>
);
}
return render(<StatefulLoggedOutHomeView />);
}
afterEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, 'matchMedia', {
@@ -296,9 +425,9 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByText('剩余叙世币'));
await user.click(screen.getByText('剩余陶泥币'));
expect(await screen.findByText('叙世币账单')).toBeTruthy();
expect(await screen.findByText('陶泥币账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
expect(screen.getByText('资产操作消耗')).toBeTruthy();
expect(screen.getByText('-1')).toBeTruthy();
@@ -306,17 +435,30 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy();
});
test('profile total play time card always uses hours', () => {
renderProfileView(vi.fn(), {
totalPlayTimeMs: 90 * 60 * 1000,
});
const playTimeCard = screen.getByRole('button', {
name: //u,
});
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
expect(within(playTimeCard).queryByText('90分')).toBeNull();
});
test('wallet ledger modal shows empty and error states', async () => {
const user = userEvent.setup();
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByText('剩余叙世币'));
await user.click(screen.getByText('剩余陶泥币'));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByLabelText('关闭叙世币账单'));
await user.click(screen.getByLabelText('关闭陶泥币账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByText('剩余叙世币'));
await user.click(screen.getByText('剩余陶泥币'));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
@@ -345,6 +487,7 @@ test('mobile home search submits public work code', async () => {
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
@@ -410,6 +553,82 @@ test('public gallery cards hide work code until detail is opened', async () => {
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});
test('mobile public work cards render cover, content and like count', () => {
const { container } = renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
});
const card = screen.getByRole('button', {
name: /12/u,
});
expect(
card.querySelector('.platform-public-work-card__cover.aspect-video'),
).toBeTruthy();
expect(screen.getByText('奇幻拼图')).toBeTruthy();
expect(screen.getByText('一张用于公开分享的拼图作品。')).toBeTruthy();
expect(screen.getByText('奇幻')).toBeTruthy();
expect(screen.getByText('12')).toBeTruthy();
expect(screen.getByText('点赞')).toBeTruthy();
expect(
container.querySelector('.platform-mobile-home-channel--active')
?.textContent,
).toBe('推荐');
});
test('mobile today channel only shows newly published works from today', async () => {
const user = userEvent.setup();
const now = new Date();
const todayPublishedAt = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
10,
).toISOString();
const yesterdayPublishedAt = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - 1,
10,
).toISOString();
const todayEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-today',
profileId: 'puzzle-profile-today',
publicWorkCode: 'PZ-TODAY1',
worldName: '今日新游',
publishedAt: todayPublishedAt,
updatedAt: todayPublishedAt,
} satisfies PlatformPublicGalleryCard;
const yesterdayEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-yesterday',
profileId: 'puzzle-profile-yesterday',
publicWorkCode: 'PZ-YDAY01',
worldName: '昨日旧作',
publishedAt: yesterdayPublishedAt,
updatedAt: yesterdayPublishedAt,
} satisfies PlatformPublicGalleryCard;
const updatedTodayEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-updated-today',
profileId: 'puzzle-profile-updated-today',
publicWorkCode: 'PZ-UPDAY1',
worldName: '今日更新旧作',
publishedAt: yesterdayPublishedAt,
updatedAt: todayPublishedAt,
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry],
});
await user.click(screen.getByRole('button', { name: '今日游戏' }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText('昨日旧作')).toBeNull();
expect(screen.queryByText('今日更新旧作')).toBeNull();
});
test('desktop trending list shows kind instead of work code or timestamp text', () => {
mockDesktopLayout();
@@ -421,3 +640,96 @@ test('desktop trending list shows kind instead of work code or timestamp text',
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
});
test('mobile home moves category shelf into game category channel', async () => {
const user = userEvent.setup();
const { container } = renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
});
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
await user.click(screen.getByRole('button', { name: '游戏分类' }));
expect(screen.getAllByText('游戏分类').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: '奇幻' })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(container.querySelector('.platform-category-game-list')).toBeTruthy();
expect(container.querySelector('.platform-category-game-item')).toBeTruthy();
expect(
container.querySelector('.platform-category-game-item__action')
?.textContent,
).toBe('试玩');
});
test('mobile game category list orders works by composite public metric', async () => {
const user = userEvent.setup();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry, hotRankEntry],
});
await user.click(screen.getByRole('button', { name: '游戏分类' }));
await user.click(screen.getByRole('button', { name: '奇幻' }));
const gameItems = Array.from(
document.querySelectorAll('.platform-category-game-item__title'),
).map((element) => element.textContent);
expect(gameItems).toEqual(['热门高分拼图', '奇幻拼图']);
});
test('bottom category tab becomes ranking and switches ranking metrics', async () => {
const user = userEvent.setup();
renderStatefulLoggedOutHomeView({
latestEntries: [remixRankEntry, hotRankEntry, newRankEntry],
});
expect(screen.queryByRole('button', { name: '分类' })).toBeNull();
await user.click(screen.getByRole('button', { name: '排行' }));
expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '改造榜' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '新品榜' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '点赞榜' })).toBeTruthy();
const rankingPanel = document.getElementById('platform-tab-panel-category');
expect(rankingPanel?.getAttribute('aria-hidden')).toBe('false');
expect(within(rankingPanel!).getByText('热门高分拼图')).toBeTruthy();
expect(within(rankingPanel!).getByText('40')).toBeTruthy();
expect(within(rankingPanel!).getAllByText('游玩').length).toBeGreaterThan(0);
await user.click(screen.getByRole('tab', { name: '改造榜' }));
expect(within(rankingPanel!).getByText('改造高分拼图')).toBeTruthy();
expect(within(rankingPanel!).getByText('18')).toBeTruthy();
expect(within(rankingPanel!).getAllByText('改造').length).toBeGreaterThan(0);
await user.click(screen.getByRole('tab', { name: '新品榜' }));
expect(within(rankingPanel!).getByText('新品增长拼图')).toBeTruthy();
expect(within(rankingPanel!).getByText('9')).toBeTruthy();
expect(within(rankingPanel!).getAllByText('近7日').length).toBeGreaterThan(0);
});
test('ranking rows limit displayed work name and show two short tags on the third line', async () => {
const user = userEvent.setup();
renderStatefulLoggedOutHomeView({
latestEntries: [longTextRankEntry],
});
await user.click(screen.getByRole('button', { name: '排行' }));
const rankingPanel = document.getElementById('platform-tab-panel-category');
expect(rankingPanel).toBeTruthy();
expect(within(rankingPanel!).getByText('关键词逍遥游拼图')).toBeTruthy();
expect(within(rankingPanel!).queryByText('关键词逍遥游拼图关卡')).toBeNull();
expect(within(rankingPanel!).getByText('逍遥游拼')).toBeTruthy();
expect(within(rankingPanel!).getByText('古风机关')).toBeTruthy();
expect(within(rankingPanel!).queryByText(/2026-04-29/u)).toBeNull();
expect(within(rankingPanel!).queryByText('拼图玩家')).toBeNull();
});

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,10 @@ import { copyTextToClipboard } from '../../services/clipboard';
import type { CustomWorldProfile } from '../../types';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldTags,
buildPlatformWorldDisplayTags,
describePlatformThemeLabel,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
@@ -83,13 +85,8 @@ export function RpgEntryWorldDetailView({
entry.profile,
).slice(0, 3);
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
const tags = [
...new Set(
buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean),
),
].slice(0, 3);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 3);
const copyPublicWorkCode = () => {
if (!publicWorkCode) {
return;
@@ -152,7 +149,9 @@ export function RpgEntryWorldDetailView({
<div className="relative z-10">
<div className="flex flex-wrap items-center gap-2">
<span className="platform-pill platform-pill--warm">
{describePlatformThemeLabel(entry.themeMode)}
{formatPlatformWorkDisplayTag(
describePlatformThemeLabel(entry.themeMode),
)}
</span>
<span className="platform-pill platform-pill--neutral px-3">
{entry.authorDisplayName}
@@ -198,7 +197,7 @@ export function RpgEntryWorldDetailView({
) : null}
</div>
<div className="mt-4 text-3xl font-black text-white">
{entry.worldName}
{displayName}
</div>
{entry.subtitle ? (
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-300/88">

View File

@@ -0,0 +1,38 @@
import { expect, test } from 'vitest';
import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
} from './rpgEntryWorldPresentation';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
});
test('formatPlatformWorldTime keeps full year for iso date strings', () => {
expect(formatPlatformWorldTime('2026-04-25T12:00:00.000Z')).toBe(
'2026-04-25',
);
});
test('formatPlatformWorldTime uses utc calendar date for zulu time', () => {
expect(formatPlatformWorldTime('2026-04-25T00:30:00.000Z')).toBe(
'2026-04-25',
);
});
test('formatPlatformWorldTime keeps fallback text for invalid values', () => {
expect(formatPlatformWorldTime(null)).toBe('未发布');
expect(formatPlatformWorldTime('not-a-date')).toBe('not-a-date');
});
test('platform work display text limits names and tags by character count', () => {
expect(formatPlatformWorkDisplayName('热门高分拼图超长标题')).toBe(
'热门高分拼图超长',
);
expect(formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签'])).toEqual([
'超长机关',
'星桥',
]);
});

View File

@@ -12,6 +12,9 @@ import {
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8;
export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4;
export type PlatformWorldCardLike =
| CustomWorldGalleryCard
| CustomWorldLibraryEntry<CustomWorldProfile>
@@ -33,6 +36,7 @@ export type PlatformPuzzleGalleryCard = {
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -53,6 +57,7 @@ export type PlatformBigFishGalleryCard = {
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -99,6 +104,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
recentPlayCount7d: work.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: work.publishedAt,
updatedAt: work.updatedAt,
@@ -114,7 +120,7 @@ export function mapBigFishWorkToPlatformGalleryCard(
profileId: work.sourceSessionId,
publicWorkCode: buildBigFishPublicWorkCode(work.sourceSessionId),
ownerUserId: work.ownerUserId,
authorDisplayName: '大鱼创作者',
authorDisplayName: '大鱼陶泥主',
worldName: work.title,
subtitle: work.subtitle || '大鱼吃小鱼',
summaryText: work.summary,
@@ -123,6 +129,7 @@ export function mapBigFishWorkToPlatformGalleryCard(
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
recentPlayCount7d: work.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: work.publishedAt ?? work.updatedAt,
updatedAt: work.updatedAt,
@@ -134,7 +141,10 @@ export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
remixCount: 'remixCount' in entry ? (entry.remixCount ?? 0) : 0,
likeCount: 'likeCount' in entry ? (entry.likeCount ?? 0) : 0,
recentPlayCount7d:
'recentPlayCount7d' in entry ? (entry.recentPlayCount7d ?? 0) : 0,
publishedAt: entry.publishedAt ?? null,
updatedAt: entry.updatedAt ?? null,
};
}
@@ -158,6 +168,44 @@ export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
return buildCustomWorldPlayableCharacters(entry.profile)[0]?.portrait ?? '';
}
function limitPlatformDisplayText(value: string, maxLength: number) {
const normalized = value.trim();
const chars = Array.from(normalized);
if (chars.length <= maxLength) {
return normalized;
}
return chars.slice(0, maxLength).join('');
}
export function formatPlatformWorkDisplayName(value: string) {
return limitPlatformDisplayText(value, PLATFORM_WORK_NAME_DISPLAY_LIMIT);
}
export function formatPlatformWorkDisplayTag(value: string) {
return limitPlatformDisplayText(value, PLATFORM_WORK_TAG_DISPLAY_LIMIT);
}
export function formatPlatformWorkDisplayTags(
tags: string[],
limit = tags.length,
) {
return [
...new Set(
tags
.map((tag) => formatPlatformWorkDisplayTag(tag))
.filter(Boolean),
),
].slice(0, limit);
}
export function buildPlatformWorldDisplayTags(
entry: PlatformWorldCardLike,
limit = 3,
) {
return formatPlatformWorkDisplayTags(buildPlatformWorldTags(entry), limit);
}
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
if (isBigFishGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
@@ -184,20 +232,50 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
.slice(0, 3);
}
function parsePlatformWorldDate(value: string) {
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
const timestampMs =
absoluteTimestamp >= 1_000_000_000_000_000
? rawTimestamp / 1000
: absoluteTimestamp >= 1_000_000_000_000
? rawTimestamp
: absoluteTimestamp >= 1_000_000_000
? rawTimestamp * 1000
: Number.NaN;
const date = new Date(timestampMs);
if (!Number.isNaN(date.getTime())) {
return date;
}
}
}
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatPlatformDateOnly(date: Date) {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function formatPlatformWorldTime(value: string | null) {
if (!value) {
return '未发布';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
const date = parsePlatformWorldDate(value);
if (!date) {
return value;
}
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
});
return formatPlatformDateOnly(date);
}
export function resolvePlatformPublicWorkCode(

View File

@@ -14,6 +14,7 @@ import {
import { ApiClientError } from '../../services/apiClient';
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldLibraryDetail,
getRpgEntryWorldGalleryDetail,
listRpgEntryWorldLibrary,
publishRpgEntryWorldProfile,
@@ -138,7 +139,7 @@ export function useRpgEntryLibraryDetail(
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);
const openLibraryDetail = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
async (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
if (entry.visibility === 'published') {
void appendBrowseHistoryEntry({
ownerUserId: entry.ownerUserId,
@@ -157,8 +158,29 @@ export function useRpgEntryLibraryDetail(
if (entry.publicWorkCode?.trim()) {
pushAppHistoryPath(buildPublicWorkDetailPath(entry.publicWorkCode));
}
if (!userId || entry.ownerUserId !== userId) {
return;
}
setIsDetailLoading(true);
try {
const detailEntry = await getRpgEntryWorldLibraryDetail(entry.profileId);
setSelectedDetailEntry(detailEntry);
} catch (error) {
setDetailError(
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
);
} finally {
setIsDetailLoading(false);
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
[
appendBrowseHistoryEntry,
setSelectedDetailEntry,
setSelectionStage,
userId,
],
);
const loadGalleryDetailEntry = useCallback(
@@ -213,21 +235,31 @@ export function useRpgEntryLibraryDetail(
);
const openSavedCustomWorldEditor = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
setSelectedDetailEntry(entry);
resetAutoSaveTrackingToIdle();
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setCustomWorldError(null);
setGeneratedCustomWorldProfile(null);
setGeneratedCustomWorldProfile(entry.profile);
markAutoSavedProfile(entry.profile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldError(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('saved-profile');
setSelectionStage('custom-world-result');
async (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
try {
const detailEntry =
userId && entry.ownerUserId === userId
? await getRpgEntryWorldLibraryDetail(entry.profileId)
: entry;
setSelectedDetailEntry(detailEntry);
resetAutoSaveTrackingToIdle();
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setCustomWorldError(null);
setGeneratedCustomWorldProfile(null);
setGeneratedCustomWorldProfile(detailEntry.profile);
markAutoSavedProfile(detailEntry.profile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldError(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('saved-profile');
setSelectionStage('custom-world-result');
} catch (error) {
setDetailError(
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
);
}
},
[
markAutoSavedProfile,
@@ -239,6 +271,7 @@ export function useRpgEntryLibraryDetail(
setGeneratedCustomWorldProfile,
setSelectedDetailEntry,
setSelectionStage,
userId,
],
);
@@ -346,7 +379,7 @@ export function useRpgEntryLibraryDetail(
}
if (matchedEntry) {
openLibraryDetail(matchedEntry);
void openLibraryDetail(matchedEntry);
return;
}

View File

@@ -41,12 +41,12 @@ import type {
WorldType,
} from '../../types';
import {
CHROME_ICONS,
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../../uiAssets';
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import { PixelCloseButton } from '../PixelCloseButton';
import { PixelIcon } from '../PixelIcon';
type AdventureStatisticCard = {
@@ -961,13 +961,7 @@ export function RpgAdventurePanelOverlays({
</div>
</div>
<button
type="button"
onClick={closeGoalPanel}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={closeGoalPanel} label="关闭任务更新" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-hide">
@@ -1037,13 +1031,10 @@ export function RpgAdventurePanelOverlays({
退
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setIsSettingsPanelOpen(false)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭冒险设置"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
@@ -1162,13 +1153,10 @@ export function RpgAdventurePanelOverlays({
: {statistics.currentSceneName}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setIsStatsPanelOpen(false)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭冒险统计"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
@@ -1244,16 +1232,13 @@ export function RpgAdventurePanelOverlays({
: {quests.length}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => {
setIsQuestPanelOpen(false);
setSelectedQuestId(null);
}}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭任务日志"
/>
</div>
<div className="flex-1 overflow-y-auto p-3 scrollbar-hide">
@@ -1352,13 +1337,10 @@ export function RpgAdventurePanelOverlays({
{selectedQuest.issuerNpcName}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedQuestId(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭任务详情"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide sm:p-5">
@@ -1543,18 +1525,15 @@ export function RpgAdventurePanelOverlays({
{rewardQuest.title}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => {
setRewardQuestId(null);
setRewardQuestHandoff(null);
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
}}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭任务奖励"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
@@ -1621,16 +1600,13 @@ export function RpgAdventurePanelOverlays({
: {battleReward.defeatedHostileNpcs.length}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => {
battleRewardUi.dismiss();
setSelectedBattleRewardItemId(null);
}}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭战斗奖励"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
@@ -1716,17 +1692,14 @@ export function RpgAdventurePanelOverlays({
{selectedRewardItem.category}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => {
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
setSelectedBattleRewardItemId(null);
}}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭奖励物品"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">

View File

@@ -203,7 +203,6 @@ export function RpgRuntimePanelRouter({
activeBuildBuffs={visibleGameState.activeBuildBuffs}
companionRenderStates={companionRenderStates}
npcStates={visibleGameState.npcStates}
quests={visibleGameState.quests}
companionArcStates={
visibleGameState.storyEngineMemory?.companionArcStates ?? []
}
@@ -292,9 +291,6 @@ export function RpgRuntimePanelRouter({
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
continueGameDigest={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
narrativeCodex={
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
}

View File

@@ -7,9 +7,9 @@ import type {
StoryGenerationNpcUi,
} from '../../hooks/rpg-runtime-story';
import type { CompanionRenderState, GameState } from '../../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { PixelCloseButton } from '../PixelCloseButton';
import {
ModalLoadingFallback,
PanelLoadingFallback,
@@ -172,13 +172,7 @@ export function RpgRuntimeOverlayHost({
<div className="min-w-0 pr-10 text-sm font-semibold text-white">
{overlayPanel === 'character' ? '队伍' : '背包'}
</div>
<button
type="button"
onClick={closeOverlayPanel}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={closeOverlayPanel} label="关闭运行面板" />
</div>
<div className="flex min-h-0 flex-1 p-5">
{overlayPanel === 'character' ? (
@@ -198,7 +192,6 @@ export function RpgRuntimeOverlayHost({
activeBuildBuffs={gameState.activeBuildBuffs}
companionRenderStates={companionRenderStates}
npcStates={gameState.npcStates}
quests={gameState.quests}
onOpenCamp={() => {
closeOverlayPanel();
openCampModal();
@@ -234,9 +227,6 @@ export function RpgRuntimeOverlayHost({
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
continueGameDigest={
gameState.storyEngineMemory?.continueGameDigest ?? null
}
narrativeCodex={
gameState.storyEngineMemory?.narrativeCodex ?? []
}