1
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
45
src/components/PixelCloseButton.test.tsx
Normal file
45
src/components/PixelCloseButton.test.tsx
Normal 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();
|
||||
});
|
||||
45
src/components/PixelCloseButton.tsx
Normal file
45
src/components/PixelCloseButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -78,6 +78,7 @@ const mockUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
|
||||
@@ -410,6 +410,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
requireAuth,
|
||||
openSettingsModal,
|
||||
openAccountModal,
|
||||
setCurrentUser: setUser,
|
||||
logout: logoutCurrentSession,
|
||||
musicVolume: settings.musicVolume,
|
||||
setMusicVolume: settings.setMusicVolume,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -790,7 +790,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
}
|
||||
|
||||
return window.confirm(
|
||||
`${params.kindLabel}预计消耗 ${params.points} 叙世币。\n${params.description}`,
|
||||
`${params.kindLabel}预计消耗 ${params.points} 陶泥币。\n${params.description}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export function RpgCreationRoleVisualSection(props: {
|
||||
? '重新生成角色形象'
|
||||
: '生成角色形象'
|
||||
}
|
||||
subLabel={`消耗${visualPointCost}叙世币`}
|
||||
subLabel={`消耗${visualPointCost}陶泥币`}
|
||||
onClick={onGenerateVisuals}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
tone="sky"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
38
src/components/rpg-entry/rpgEntryWorldPresentation.test.ts
Normal file
38
src/components/rpg-entry/rpgEntryWorldPresentation.test.ts
Normal 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([
|
||||
'超长机关',
|
||||
'星桥',
|
||||
]);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user